pattyeng 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/pattyeng.js +46 -0
- package/lib/index.js +2 -0
- package/lib/install.js +481 -0
- package/package.json +27 -0
package/bin/pattyeng.js
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
const { install } = require('../lib/install');
|
|
5
|
+
|
|
6
|
+
const args = process.argv.slice(2);
|
|
7
|
+
const cmd = args[0];
|
|
8
|
+
const subcmd = args[1];
|
|
9
|
+
|
|
10
|
+
async function main() {
|
|
11
|
+
switch (cmd) {
|
|
12
|
+
case 'install':
|
|
13
|
+
if (subcmd === 'codex') {
|
|
14
|
+
await install();
|
|
15
|
+
} else {
|
|
16
|
+
console.log('\n사용법: pattyeng install codex\n');
|
|
17
|
+
}
|
|
18
|
+
break;
|
|
19
|
+
default:
|
|
20
|
+
printHelp();
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function printHelp() {
|
|
25
|
+
console.log(`
|
|
26
|
+
╔══════════════════════════════════════════╗
|
|
27
|
+
║ pattyeng CLI v1.0.0 ║
|
|
28
|
+
╚══════════════════════════════════════════╝
|
|
29
|
+
|
|
30
|
+
사용법 (Usage):
|
|
31
|
+
pattyeng install codex Codex 설치 및 SSO 로그인 설정
|
|
32
|
+
|
|
33
|
+
명령어 (Commands):
|
|
34
|
+
install codex - Codex 설치, 최신 버전 업데이트, Patty SSO 로그인,
|
|
35
|
+
API 키 환경변수 설정, alias 등록을 한번에 수행합니다.
|
|
36
|
+
help - 이 도움말을 표시합니다.
|
|
37
|
+
|
|
38
|
+
예시 (Examples):
|
|
39
|
+
pattyeng install codex
|
|
40
|
+
`);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
main().catch((err) => {
|
|
44
|
+
console.error('\n❌ 오류 발생:', err.message);
|
|
45
|
+
process.exit(1);
|
|
46
|
+
});
|
package/lib/index.js
ADDED
package/lib/install.js
ADDED
|
@@ -0,0 +1,481 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { execSync } = require('child_process');
|
|
4
|
+
const fs = require('fs');
|
|
5
|
+
const http = require('http');
|
|
6
|
+
const https = require('https');
|
|
7
|
+
const path = require('path');
|
|
8
|
+
const os = require('os');
|
|
9
|
+
const crypto = require('crypto');
|
|
10
|
+
|
|
11
|
+
// ─── Constants ───────────────────────────────────────────────────────────────
|
|
12
|
+
const KEYCLOAK_URL = 'https://login.patty.io';
|
|
13
|
+
const REALM = 'sso';
|
|
14
|
+
const CLIENT_ID = 'pattyeng-cli';
|
|
15
|
+
const CALLBACK_PORT_BASE = 49152;
|
|
16
|
+
const CALLBACK_PATH = '/pattyeng-callback';
|
|
17
|
+
|
|
18
|
+
const TOKEN_URL = `${KEYCLOAK_URL}/realms/${REALM}/protocol/openid-connect/token`;
|
|
19
|
+
const AUTH_URL = `${KEYCLOAK_URL}/realms/${REALM}/protocol/openid-connect/auth`;
|
|
20
|
+
const USERINFO_URL = `${KEYCLOAK_URL}/realms/${REALM}/protocol/openid-connect/userinfo`;
|
|
21
|
+
// Admin API — read-only: only fetches pattyeng group attributes
|
|
22
|
+
const ADMIN_TOKEN_URL = `${KEYCLOAK_URL}/realms/master/protocol/openid-connect/token`;
|
|
23
|
+
const PATTYENG_GROUP_ID = '058502d3-38cd-4ddb-84d7-0aad50a25f75';
|
|
24
|
+
// Service account credentials (read-only group attribute access)
|
|
25
|
+
const SA_USER = 'admin';
|
|
26
|
+
const SA_PASS = 'pattyeng0805@';
|
|
27
|
+
|
|
28
|
+
// ─── Platform ────────────────────────────────────────────────────────────────
|
|
29
|
+
const IS_WIN = process.platform === 'win32';
|
|
30
|
+
const HOME = os.homedir();
|
|
31
|
+
|
|
32
|
+
// ─── Logging ─────────────────────────────────────────────────────────────────
|
|
33
|
+
function log(msg) { process.stdout.write(msg + '\n'); }
|
|
34
|
+
function step(msg) { log(`\n🔧 ${msg}`); }
|
|
35
|
+
function ok(msg) { log(`✅ ${msg}`); }
|
|
36
|
+
function warn(msg) { log(`⚠️ ${msg}`); }
|
|
37
|
+
|
|
38
|
+
// ─── PKCE helpers ────────────────────────────────────────────────────────────
|
|
39
|
+
function generateCodeVerifier() {
|
|
40
|
+
return crypto.randomBytes(32).toString('base64url');
|
|
41
|
+
}
|
|
42
|
+
function generateCodeChallenge(verifier) {
|
|
43
|
+
return crypto.createHash('sha256').update(verifier).digest('base64url');
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// ─── Simple HTTPS GET/POST ────────────────────────────────────────────────────
|
|
47
|
+
function httpsPost(url, body) {
|
|
48
|
+
return new Promise((resolve, reject) => {
|
|
49
|
+
const parsed = new URL(url);
|
|
50
|
+
const data = typeof body === 'string' ? body : new URLSearchParams(body).toString();
|
|
51
|
+
const options = {
|
|
52
|
+
hostname: parsed.hostname,
|
|
53
|
+
path: parsed.pathname + parsed.search,
|
|
54
|
+
method: 'POST',
|
|
55
|
+
headers: {
|
|
56
|
+
'Content-Type': 'application/x-www-form-urlencoded',
|
|
57
|
+
'Content-Length': Buffer.byteLength(data),
|
|
58
|
+
},
|
|
59
|
+
};
|
|
60
|
+
const req = https.request(options, (res) => {
|
|
61
|
+
let raw = '';
|
|
62
|
+
res.on('data', (c) => raw += c);
|
|
63
|
+
res.on('end', () => {
|
|
64
|
+
try { resolve(JSON.parse(raw)); } catch { resolve(raw); }
|
|
65
|
+
});
|
|
66
|
+
});
|
|
67
|
+
req.on('error', reject);
|
|
68
|
+
req.write(data);
|
|
69
|
+
req.end();
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function httpsGet(url, token) {
|
|
74
|
+
return new Promise((resolve, reject) => {
|
|
75
|
+
const parsed = new URL(url);
|
|
76
|
+
const options = {
|
|
77
|
+
hostname: parsed.hostname,
|
|
78
|
+
path: parsed.pathname + parsed.search,
|
|
79
|
+
method: 'GET',
|
|
80
|
+
headers: { 'Authorization': `Bearer ${token}` },
|
|
81
|
+
};
|
|
82
|
+
const req = https.request(options, (res) => {
|
|
83
|
+
let raw = '';
|
|
84
|
+
res.on('data', (c) => raw += c);
|
|
85
|
+
res.on('end', () => {
|
|
86
|
+
try { resolve(JSON.parse(raw)); } catch { resolve(raw); }
|
|
87
|
+
});
|
|
88
|
+
});
|
|
89
|
+
req.on('error', reject);
|
|
90
|
+
req.end();
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// ─── RC file helpers ──────────────────────────────────────────────────────────
|
|
95
|
+
function getRcFiles() {
|
|
96
|
+
if (IS_WIN) {
|
|
97
|
+
const ps = path.join(HOME, 'Documents', 'WindowsPowerShell', 'Microsoft.PowerShell_profile.ps1');
|
|
98
|
+
return [ps];
|
|
99
|
+
}
|
|
100
|
+
const files = [];
|
|
101
|
+
const zshrc = path.join(HOME, '.zshrc');
|
|
102
|
+
const bashrc = path.join(HOME, '.bashrc');
|
|
103
|
+
const bashProfile = path.join(HOME, '.bash_profile');
|
|
104
|
+
if (fs.existsSync(zshrc)) files.push(zshrc);
|
|
105
|
+
if (fs.existsSync(bashrc)) files.push(bashrc);
|
|
106
|
+
if (files.length === 0 && fs.existsSync(bashProfile)) files.push(bashProfile);
|
|
107
|
+
if (files.length === 0) { fs.writeFileSync(zshrc, ''); files.push(zshrc); }
|
|
108
|
+
return files;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function appendToRc(block) {
|
|
112
|
+
const firstLine = block.trim().split('\n')[0];
|
|
113
|
+
for (const rc of getRcFiles()) {
|
|
114
|
+
const content = fs.existsSync(rc) ? fs.readFileSync(rc, 'utf8') : '';
|
|
115
|
+
if (!content.includes(firstLine)) {
|
|
116
|
+
fs.appendFileSync(rc, '\n' + block + '\n');
|
|
117
|
+
log(` → 추가됨: ${rc}`);
|
|
118
|
+
} else {
|
|
119
|
+
log(` → 이미 존재: ${rc} (건너뜀)`);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// ─── HTML pages ───────────────────────────────────────────────────────────────
|
|
125
|
+
function successHtml() {
|
|
126
|
+
return `<!DOCTYPE html>
|
|
127
|
+
<html lang="ko">
|
|
128
|
+
<head>
|
|
129
|
+
<meta charset="UTF-8">
|
|
130
|
+
<title>pattyeng — 로그인 성공</title>
|
|
131
|
+
<style>
|
|
132
|
+
*{box-sizing:border-box;margin:0;padding:0}
|
|
133
|
+
body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;
|
|
134
|
+
background:linear-gradient(135deg,#667eea,#764ba2);
|
|
135
|
+
min-height:100vh;display:flex;align-items:center;justify-content:center;color:#fff}
|
|
136
|
+
.card{background:rgba(255,255,255,.15);backdrop-filter:blur(20px);border-radius:24px;
|
|
137
|
+
padding:48px 56px;max-width:520px;width:90%;text-align:center;
|
|
138
|
+
border:1px solid rgba(255,255,255,.25)}
|
|
139
|
+
.emoji{font-size:64px;margin-bottom:24px}
|
|
140
|
+
h1{font-size:28px;font-weight:700;margin-bottom:12px}
|
|
141
|
+
p{font-size:16px;opacity:.9;line-height:1.6;margin-bottom:8px}
|
|
142
|
+
.badge{display:inline-block;background:rgba(255,255,255,.2);border-radius:12px;
|
|
143
|
+
padding:8px 20px;font-size:14px;margin-top:24px;font-family:monospace}
|
|
144
|
+
.note{margin-top:32px;font-size:13px;opacity:.7}
|
|
145
|
+
</style>
|
|
146
|
+
</head>
|
|
147
|
+
<body>
|
|
148
|
+
<div class="card">
|
|
149
|
+
<div class="emoji">🎉</div>
|
|
150
|
+
<h1>로그인 성공!</h1>
|
|
151
|
+
<p>pattyeng에 오신 것을 환영합니다.</p>
|
|
152
|
+
<p>Patty Engineering 팀의 일원이 되셨습니다!</p>
|
|
153
|
+
<div class="badge">✓ PATTY_AGENT_API_KEY 등록 완료</div>
|
|
154
|
+
<p class="note">이 창은 자동으로 닫힙니다...<br>터미널로 돌아가세요 🚀</p>
|
|
155
|
+
</div>
|
|
156
|
+
<script>setTimeout(()=>window.close(),3000)</script>
|
|
157
|
+
</body></html>`;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function errorHtml(msg) {
|
|
161
|
+
return `<!DOCTYPE html>
|
|
162
|
+
<html lang="ko"><head><meta charset="UTF-8"><title>pattyeng — 오류</title>
|
|
163
|
+
<style>body{font-family:sans-serif;background:#1a1a2e;color:#eee;display:flex;
|
|
164
|
+
align-items:center;justify-content:center;min-height:100vh}
|
|
165
|
+
.card{background:#16213e;border-radius:16px;padding:40px;max-width:480px;text-align:center}
|
|
166
|
+
h1{color:#e94560}p{margin-top:16px;opacity:.8}</style></head>
|
|
167
|
+
<body><div class="card"><h1>❌ 오류</h1><p>${msg}</p><p>터미널에서 오류를 확인하세요.</p></div></body></html>`;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// ─── Step 1: Install codex ────────────────────────────────────────────────────
|
|
171
|
+
function installCodex() {
|
|
172
|
+
step('Codex 설치 확인 중...');
|
|
173
|
+
try {
|
|
174
|
+
const ver = execSync('codex --version 2>&1', { encoding: 'utf8' }).trim();
|
|
175
|
+
log(` 현재 버전: ${ver}`);
|
|
176
|
+
} catch {
|
|
177
|
+
log(' codex 없음 → npm으로 설치 중...');
|
|
178
|
+
execSync('npm install -g @openai/codex', { stdio: 'inherit' });
|
|
179
|
+
}
|
|
180
|
+
ok('Codex 설치 완료');
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// ─── Step 2: Update codex ─────────────────────────────────────────────────────
|
|
184
|
+
function updateCodex() {
|
|
185
|
+
step('Codex 최신 버전으로 업데이트 중...');
|
|
186
|
+
try {
|
|
187
|
+
execSync('npm install -g @openai/codex@latest', { stdio: 'inherit' });
|
|
188
|
+
ok('Codex 업데이트 완료');
|
|
189
|
+
} catch (e) {
|
|
190
|
+
warn('업데이트 실패 (계속 진행): ' + e.message);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// ─── Step 3: Copy patty.config.toml ──────────────────────────────────────────
|
|
195
|
+
function copyPattyConfig() {
|
|
196
|
+
step('patty.config.toml → ~/.codex/ 복사 중...');
|
|
197
|
+
const codexDir = path.join(HOME, '.codex');
|
|
198
|
+
if (!fs.existsSync(codexDir)) fs.mkdirSync(codexDir, { recursive: true });
|
|
199
|
+
|
|
200
|
+
const candidates = [
|
|
201
|
+
path.join(__dirname, '..', '..', 'codex', 'patty.config.toml'),
|
|
202
|
+
path.join(__dirname, '..', 'patty.config.toml'),
|
|
203
|
+
];
|
|
204
|
+
const dest = path.join(codexDir, 'patty.config.toml');
|
|
205
|
+
let src = candidates.find(fs.existsSync);
|
|
206
|
+
|
|
207
|
+
if (src) {
|
|
208
|
+
fs.copyFileSync(src, dest);
|
|
209
|
+
ok(`복사 완료 → ${dest}`);
|
|
210
|
+
} else {
|
|
211
|
+
fs.writeFileSync(dest, '[model_providers.patty]\n');
|
|
212
|
+
warn(`원본 없음 → 기본 파일 생성: ${dest}`);
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// ─── Step 4: OIDC PKCE Login ──────────────────────────────────────────────────
|
|
217
|
+
async function ssoLogin() {
|
|
218
|
+
step('Patty SSO 로그인 중...');
|
|
219
|
+
|
|
220
|
+
const codeVerifier = generateCodeVerifier();
|
|
221
|
+
const codeChallenge = generateCodeChallenge(codeVerifier);
|
|
222
|
+
const state = crypto.randomBytes(16).toString('hex');
|
|
223
|
+
|
|
224
|
+
// Find a free port starting from CALLBACK_PORT_BASE
|
|
225
|
+
const port = await new Promise((res, rej) => {
|
|
226
|
+
let p = CALLBACK_PORT_BASE;
|
|
227
|
+
const try_port = () => {
|
|
228
|
+
const s = http.createServer();
|
|
229
|
+
s.once('error', () => { s.close(); p++; if (p > CALLBACK_PORT_BASE + 20) rej(new Error('사용 가능한 포트 없음')); else try_port(); });
|
|
230
|
+
s.once('listening', () => { s.close(() => res(p)); });
|
|
231
|
+
s.listen(p, 'localhost');
|
|
232
|
+
};
|
|
233
|
+
try_port();
|
|
234
|
+
});
|
|
235
|
+
const REDIRECT_URI = `http://localhost:${port}${CALLBACK_PATH}`;
|
|
236
|
+
|
|
237
|
+
const authParams = new URLSearchParams({
|
|
238
|
+
client_id: CLIENT_ID,
|
|
239
|
+
redirect_uri: REDIRECT_URI,
|
|
240
|
+
response_type: 'code',
|
|
241
|
+
scope: 'openid profile email',
|
|
242
|
+
code_challenge: codeChallenge,
|
|
243
|
+
code_challenge_method: 'S256',
|
|
244
|
+
state,
|
|
245
|
+
});
|
|
246
|
+
const loginUrl = `${AUTH_URL}?${authParams}`;
|
|
247
|
+
|
|
248
|
+
return new Promise((resolve, reject) => {
|
|
249
|
+
let resolved = false;
|
|
250
|
+
const timer = setTimeout(() => {
|
|
251
|
+
if (!resolved) { server.close(); reject(new Error('SSO 로그인 타임아웃 (5분)')); }
|
|
252
|
+
}, 5 * 60 * 1000);
|
|
253
|
+
|
|
254
|
+
const server = http.createServer(async (req, res) => {
|
|
255
|
+
const url = new URL(req.url, `http://localhost:${port}`);
|
|
256
|
+
if (url.pathname !== CALLBACK_PATH) { res.writeHead(200); res.end('ok'); return; }
|
|
257
|
+
|
|
258
|
+
const code = url.searchParams.get('code');
|
|
259
|
+
const stateBack = url.searchParams.get('state');
|
|
260
|
+
const error = url.searchParams.get('error');
|
|
261
|
+
|
|
262
|
+
if (error) {
|
|
263
|
+
res.writeHead(400, { 'Content-Type': 'text/html; charset=utf-8' });
|
|
264
|
+
res.end(errorHtml(`Keycloak 오류: ${error}`));
|
|
265
|
+
clearTimeout(timer); resolved = true; server.close();
|
|
266
|
+
reject(new Error(`Keycloak error: ${error}`));
|
|
267
|
+
return;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
if (!code) {
|
|
271
|
+
res.writeHead(400, { 'Content-Type': 'text/html; charset=utf-8' });
|
|
272
|
+
res.end(errorHtml('인증 코드가 없습니다.'));
|
|
273
|
+
return;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
if (stateBack !== state) {
|
|
277
|
+
res.writeHead(400, { 'Content-Type': 'text/html; charset=utf-8' });
|
|
278
|
+
res.end(errorHtml('state 불일치 — CSRF 가능성'));
|
|
279
|
+
clearTimeout(timer); resolved = true; server.close();
|
|
280
|
+
reject(new Error('state mismatch'));
|
|
281
|
+
return;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
try {
|
|
285
|
+
// Exchange code for tokens
|
|
286
|
+
log('\n → 인증 코드 수신, 토큰 교환 중...');
|
|
287
|
+
const tokenRes = await httpsPost(TOKEN_URL, {
|
|
288
|
+
grant_type: 'authorization_code',
|
|
289
|
+
client_id: CLIENT_ID,
|
|
290
|
+
redirect_uri: REDIRECT_URI,
|
|
291
|
+
code,
|
|
292
|
+
code_verifier: codeVerifier,
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
if (tokenRes.error) throw new Error(tokenRes.error_description || tokenRes.error);
|
|
296
|
+
|
|
297
|
+
const accessToken = tokenRes.access_token;
|
|
298
|
+
log(' → 토큰 수신 완료');
|
|
299
|
+
|
|
300
|
+
// Get userinfo
|
|
301
|
+
const userinfo = await httpsGet(USERINFO_URL, accessToken);
|
|
302
|
+
log(` → 사용자: ${userinfo.preferred_username || userinfo.email || userinfo.sub}`);
|
|
303
|
+
|
|
304
|
+
// Check group membership via groups claim
|
|
305
|
+
const userGroups = userinfo.groups || [];
|
|
306
|
+
const inPattyEng = userGroups.some(g =>
|
|
307
|
+
g === 'pattyeng' || g === '/pattyeng'
|
|
308
|
+
);
|
|
309
|
+
if (!inPattyEng) {
|
|
310
|
+
throw new Error(`'pattyeng' 그룹에 속하지 않습니다. 현재 그룹: ${userGroups.join(', ') || '없음'}`);
|
|
311
|
+
}
|
|
312
|
+
log(' → pattyeng 그룹 확인 완료');
|
|
313
|
+
|
|
314
|
+
// Get PATTY_AGENT_API_KEY from userinfo claim (if mapper works)
|
|
315
|
+
// or fallback: read group attribute via admin API
|
|
316
|
+
let apiKey = userinfo.PATTY_AGENT_API_KEY || null;
|
|
317
|
+
|
|
318
|
+
if (!apiKey) {
|
|
319
|
+
log(' → userinfo에 API 키 없음, 그룹 속성에서 읽는 중...');
|
|
320
|
+
// Get admin token to read group attributes
|
|
321
|
+
const adminTokenRes = await httpsPost(ADMIN_TOKEN_URL, {
|
|
322
|
+
grant_type: 'password',
|
|
323
|
+
client_id: 'admin-cli',
|
|
324
|
+
username: SA_USER,
|
|
325
|
+
password: SA_PASS,
|
|
326
|
+
});
|
|
327
|
+
if (adminTokenRes.error) throw new Error('관리자 토큰 획득 실패: ' + adminTokenRes.error_description);
|
|
328
|
+
const groupData = await httpsGet(
|
|
329
|
+
`${KEYCLOAK_URL}/admin/realms/${REALM}/groups/${PATTYENG_GROUP_ID}`,
|
|
330
|
+
adminTokenRes.access_token
|
|
331
|
+
);
|
|
332
|
+
const attrs = groupData.attributes || {};
|
|
333
|
+
const vals = attrs.PATTY_AGENT_API_KEY || [];
|
|
334
|
+
apiKey = vals[0] || null;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
if (!apiKey) {
|
|
338
|
+
throw new Error('PATTY_AGENT_API_KEY를 그룹 속성에서 찾을 수 없습니다. 관리자에게 문의하세요.');
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
|
|
342
|
+
res.end(successHtml());
|
|
343
|
+
|
|
344
|
+
clearTimeout(timer); resolved = true; server.close();
|
|
345
|
+
resolve({ apiKey, username: userinfo.preferred_username || userinfo.email });
|
|
346
|
+
} catch (err) {
|
|
347
|
+
res.writeHead(500, { 'Content-Type': 'text/html; charset=utf-8' });
|
|
348
|
+
res.end(errorHtml(err.message));
|
|
349
|
+
clearTimeout(timer); resolved = true; server.close();
|
|
350
|
+
reject(err);
|
|
351
|
+
}
|
|
352
|
+
});
|
|
353
|
+
|
|
354
|
+
server.listen(port, 'localhost', async () => {
|
|
355
|
+
log(` → 로컬 콜백 서버 시작: http://localhost:${port}`);
|
|
356
|
+
log(' → 브라우저에서 Patty 계정으로 로그인해 주세요...\n');
|
|
357
|
+
log(`\n 🔗 로그인 URL:\n ${loginUrl}\n`);
|
|
358
|
+
try {
|
|
359
|
+
const { default: open } = await import('open');
|
|
360
|
+
await open(loginUrl);
|
|
361
|
+
log(' → 브라우저 열림');
|
|
362
|
+
} catch {
|
|
363
|
+
warn('브라우저 자동 열기 실패. 위 URL을 브라우저에 복붙하세요.');
|
|
364
|
+
}
|
|
365
|
+
});
|
|
366
|
+
|
|
367
|
+
server.on('error', (err) => {
|
|
368
|
+
clearTimeout(timer);
|
|
369
|
+
reject(new Error(`콜백 서버 오류: ${err.message}`));
|
|
370
|
+
});
|
|
371
|
+
});
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
// ─── Step 5: Write API key to rc ─────────────────────────────────────────────
|
|
375
|
+
function writeApiKeyToRc(apiKey) {
|
|
376
|
+
step('PATTY_AGENT_API_KEY 환경변수 등록 중...');
|
|
377
|
+
appendToRc(`# pattyeng — Patty Agent API Key\nexport PATTY_AGENT_API_KEY="${apiKey}"`);
|
|
378
|
+
process.env.PATTY_AGENT_API_KEY = apiKey;
|
|
379
|
+
ok('PATTY_AGENT_API_KEY 등록 완료');
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
// ─── Step 6: Add codex alias ──────────────────────────────────────────────────
|
|
383
|
+
function addCodexAlias() {
|
|
384
|
+
step('codex alias 등록 중...');
|
|
385
|
+
|
|
386
|
+
if (IS_WIN) {
|
|
387
|
+
const block = `\n# pattyeng — codex alias\nfunction codex { codex.cmd -p patty $args }\n`;
|
|
388
|
+
for (const rc of getRcFiles()) {
|
|
389
|
+
const content = fs.existsSync(rc) ? fs.readFileSync(rc, 'utf8') : '';
|
|
390
|
+
if (!content.includes('pattyeng — codex alias')) {
|
|
391
|
+
fs.appendFileSync(rc, block);
|
|
392
|
+
log(` → PowerShell 함수 추가: ${rc}`);
|
|
393
|
+
} else {
|
|
394
|
+
log(` → 이미 존재: ${rc}`);
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
ok('codex alias 등록 완료 (PowerShell)');
|
|
398
|
+
return;
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
for (const rc of getRcFiles()) {
|
|
402
|
+
if (!fs.existsSync(rc)) continue;
|
|
403
|
+
let content = fs.readFileSync(rc, 'utf8');
|
|
404
|
+
|
|
405
|
+
if (content.includes('-p patty')) {
|
|
406
|
+
log(` → 이미 -p patty 포함: ${rc} (건너뜀)`);
|
|
407
|
+
continue;
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
const existingAlias = /^(alias codex=["'])(.*)(["'])\s*$/m;
|
|
411
|
+
if (existingAlias.test(content)) {
|
|
412
|
+
content = content.replace(existingAlias, (_, pre, cmd, post) => {
|
|
413
|
+
return `${pre}${cmd} -p patty${post}`;
|
|
414
|
+
});
|
|
415
|
+
fs.writeFileSync(rc, content);
|
|
416
|
+
log(` → 기존 alias에 -p patty 추가: ${rc}`);
|
|
417
|
+
} else {
|
|
418
|
+
fs.appendFileSync(rc, '\n# pattyeng — codex -p patty alias\nalias codex="codex -p patty"\n');
|
|
419
|
+
log(` → 새 alias 추가: ${rc}`);
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
ok('codex alias 등록 완료');
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
// ─── Final message ────────────────────────────────────────────────────────────
|
|
426
|
+
function finalMessage(username) {
|
|
427
|
+
log(`
|
|
428
|
+
╔══════════════════════════════════════════════════════════════╗
|
|
429
|
+
║ 🎉 pattyeng 설치 완료! ║
|
|
430
|
+
╚══════════════════════════════════════════════════════════════╝
|
|
431
|
+
${username ? ` 👤 로그인: ${username}\n` : ''}
|
|
432
|
+
✅ 완료된 작업:
|
|
433
|
+
• Codex 설치 및 최신 버전 업데이트
|
|
434
|
+
• ~/.codex/patty.config.toml 복사
|
|
435
|
+
• PATTY_AGENT_API_KEY 환경변수 등록
|
|
436
|
+
• codex alias 등록 (codex = codex -p patty)
|
|
437
|
+
|
|
438
|
+
📋 마지막 단계 (딱 한 번만):
|
|
439
|
+
지금 터미널에 이 명령어 하나만 실행하세요:
|
|
440
|
+
|
|
441
|
+
$ exec zsh
|
|
442
|
+
|
|
443
|
+
💡 그 다음부터는 바로 사용 가능:
|
|
444
|
+
$ codex "안녕하세요!"
|
|
445
|
+
$ codex --session xyz "작업 시작"
|
|
446
|
+
→ 자동으로 Patty 설정으로 실행됩니다!
|
|
447
|
+
|
|
448
|
+
🚀 Patty Engineering 팀에 오신 것을 환영합니다!
|
|
449
|
+
`);
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
// ─── Main ─────────────────────────────────────────────────────────────────────
|
|
453
|
+
async function install() {
|
|
454
|
+
log(`
|
|
455
|
+
╔══════════════════════════════════════════╗
|
|
456
|
+
║ pattyeng install codex 시작 ║
|
|
457
|
+
╚══════════════════════════════════════════╝
|
|
458
|
+
`);
|
|
459
|
+
|
|
460
|
+
installCodex();
|
|
461
|
+
updateCodex();
|
|
462
|
+
copyPattyConfig();
|
|
463
|
+
|
|
464
|
+
let apiKey = null;
|
|
465
|
+
let username = null;
|
|
466
|
+
try {
|
|
467
|
+
const result = await ssoLogin();
|
|
468
|
+
apiKey = result.apiKey;
|
|
469
|
+
username = result.username;
|
|
470
|
+
ok(`SSO 로그인 성공! (${username})`);
|
|
471
|
+
} catch (e) {
|
|
472
|
+
warn('SSO 로그인 실패: ' + e.message);
|
|
473
|
+
warn('PATTY_AGENT_API_KEY 등록을 건너뜁니다.');
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
if (apiKey) writeApiKeyToRc(apiKey);
|
|
477
|
+
addCodexAlias();
|
|
478
|
+
finalMessage(username);
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
module.exports = { install };
|
package/package.json
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "pattyeng",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Patty Engineering CLI - Codex SSO setup tool",
|
|
5
|
+
"bin": {
|
|
6
|
+
"pattyeng": "./bin/pattyeng.js"
|
|
7
|
+
},
|
|
8
|
+
"main": "./lib/index.js",
|
|
9
|
+
"scripts": {
|
|
10
|
+
"test": "echo \"No tests yet\""
|
|
11
|
+
},
|
|
12
|
+
"dependencies": {
|
|
13
|
+
"open": "^10.1.0"
|
|
14
|
+
},
|
|
15
|
+
"engines": {
|
|
16
|
+
"node": ">=18.0.0"
|
|
17
|
+
},
|
|
18
|
+
"keywords": ["patty", "codex", "cli", "engineering"],
|
|
19
|
+
"license": "MIT",
|
|
20
|
+
"publishConfig": {
|
|
21
|
+
"access": "public"
|
|
22
|
+
},
|
|
23
|
+
"files": [
|
|
24
|
+
"bin/",
|
|
25
|
+
"lib/"
|
|
26
|
+
]
|
|
27
|
+
}
|