diff --git a/package-lock.json b/package-lock.json index 0ff96b2..8d2ebd4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2360,8 +2360,7 @@ "version": "0.0.1566079", "resolved": "https://registry.npmjs.org/devtools-protocol/-/devtools-protocol-0.0.1566079.tgz", "integrity": "sha512-MJfAEA1UfVhSs7fbSQOG4czavUp1ajfg6prlAN0+cmfa2zNjaIbvq8VneP7do1WAQQIvgNJWSMeP6UyI90gIlQ==", - "license": "BSD-3-Clause", - "peer": true + "license": "BSD-3-Clause" }, "node_modules/diff": { "version": "8.0.3", @@ -3797,7 +3796,6 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -4658,7 +4656,6 @@ "integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "~0.27.0", "get-tsconfig": "^4.7.5" @@ -4698,7 +4695,6 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "devOptional": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -4745,7 +4741,6 @@ "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", diff --git a/src/bot.ts b/src/bot.ts index f00f9c6..cba3706 100644 --- a/src/bot.ts +++ b/src/bot.ts @@ -15,6 +15,7 @@ import { APPLICATION_ID, SLASH_COMMAND_GUILD_ID, LINE_CHANNEL_ACCESS_TOKEN, LINE_CHANNEL_SECRET, } from "./config.js"; +import { startSetupServer, envExists } from "./setup/setupServer.js"; import { LineBotServer } from "./lineBot.js"; import type { BotClient, PrefixCommand, CommandContext } from "./types.js"; import type { ISessionManager } from "./sessions/types.js"; @@ -68,6 +69,15 @@ function ask(question: string): Promise { // ── First-run .env setup ───────────────────────────────────────────── +// Validation helpers +function isValidDiscordId(id: string): boolean { + return /^\d{17,19}$/.test(id); +} + +function isValidLineUserId(id: string): boolean { + return /^U[a-f0-9]{32}$/i.test(id); +} + async function setupEnv(): Promise { const envPath = path.join(process.cwd(), ".env"); if (fs.existsSync(envPath)) return; @@ -80,35 +90,137 @@ async function setupEnv(): Promise { console.log(" .env 파일이 없습니다. 필수 정보를 입력해주세요."); console.log(); - // 1) Discord 봇 토큰 - let token: string; + // 1) Platform selection + console.log(" [1] 사용할 플랫폼을 선택하세요:"); + console.log(); + console.log(" 1) Discord만"); + console.log(" 2) LINE만"); + console.log(" 3) Discord + LINE 둘 다"); + console.log(); + + let platformChoice: number; while (true) { - token = await ask(" [1/2] Discord 봇 토큰: "); - if (token) break; - console.log(" 봇 토큰은 필수입니다. 다시 입력해주세요."); + const raw = await ask(" 선택 [1-3] (기본: 1): "); + if (raw === "") { + platformChoice = 1; + break; + } + const n = parseInt(raw, 10); + if (n >= 1 && n <= 3) { + platformChoice = n; + break; + } + console.log(" 잘못된 선택입니다. 1, 2, 3 중 하나를 입력해주세요."); } - // 2) Discord 유저 ID - let userId: string; - while (true) { - userId = await ask(" [2/2] Discord 유저 ID: "); - if (userId) break; - console.log(" 유저 ID는 필수입니다. 다시 입력해주세요."); + const useDiscord = platformChoice === 1 || platformChoice === 3; + const useLine = platformChoice === 2 || platformChoice === 3; + + const platformNames = []; + if (useDiscord) platformNames.push("Discord"); + if (useLine) platformNames.push("LINE"); + console.log(` -> ${platformNames.join(" + ")} 선택됨`); + console.log(); + + // Build env content + const envLines: string[] = []; + + // Discord settings + if (useDiscord) { + console.log(" ─── Discord 설정 ───"); + console.log(); + + // Discord bot token + let discordToken: string; + while (true) { + discordToken = await ask(" Discord 봇 토큰: "); + if (discordToken) break; + console.log(" 봇 토큰은 필수입니다. 다시 입력해주세요."); + } + + // Discord user ID + let discordUserId: string; + while (true) { + discordUserId = await ask(" Discord 유저 ID (17-19자리 숫자): "); + if (isValidDiscordId(discordUserId)) break; + console.log(" 유효하지 않은 ID입니다. 17-19자리 숫자를 입력해주세요."); + } + + envLines.push("# Discord 설정"); + envLines.push(`DISCORD_BOT_TOKEN=${discordToken}`); + envLines.push(`ALLOWED_USER_IDS=${discordUserId}`); + envLines.push(""); + console.log(); } - const envContent = [ - `DISCORD_BOT_TOKEN=${token}`, - `ALLOWED_USER_IDS=${userId}`, - `COMMAND_PREFIX=!`, - `COMMAND_TIMEOUT=30`, - `AI_CLI_TIMEOUT=300`, - ].join("\n") + "\n"; + // LINE settings + if (useLine) { + console.log(" ─── LINE 설정 ───"); + console.log(); + console.log(" LINE Developers Console에서 토큰과 시크릿을 복사하세요."); + console.log(" https://developers.line.biz/console/"); + console.log(); + + // LINE Channel Access Token + let lineToken: string; + while (true) { + lineToken = await ask(" LINE Channel Access Token: "); + if (lineToken) break; + console.log(" Access Token은 필수입니다. 다시 입력해주세요."); + } + // LINE Channel Secret + let lineSecret: string; + while (true) { + lineSecret = await ask(" LINE Channel Secret: "); + if (lineSecret) break; + console.log(" Channel Secret은 필수입니다. 다시 입력해주세요."); + } + + // LINE User ID + let lineUserId: string; + while (true) { + lineUserId = await ask(" LINE 유저 ID (U로 시작하는 33자): "); + if (isValidLineUserId(lineUserId)) break; + console.log(" 유효하지 않은 LINE ID입니다. U로 시작하는 33자 문자열을 입력해주세요."); + } + + // LINE Webhook Port + const portRaw = await ask(" LINE 웹훅 포트 (기본: 3000): "); + const linePort = portRaw || "3000"; + + envLines.push("# LINE 설정"); + envLines.push(`LINE_CHANNEL_ACCESS_TOKEN=${lineToken}`); + envLines.push(`LINE_CHANNEL_SECRET=${lineSecret}`); + envLines.push(`ALLOWED_LINE_USER_IDS=${lineUserId}`); + envLines.push(`LINE_WEBHOOK_PORT=${linePort}`); + envLines.push(""); + + console.log(); + console.log(" ─── LINE 웹훅 안내 ───"); + console.log(); + console.log(" LINE 봇은 웹훅 URL이 필요합니다."); + console.log(" 로컬 테스트: Cloudflare Tunnel 사용 (무료)"); + console.log(); + console.log(" 1. 설치: winget install cloudflare.cloudflared"); + console.log(` 2. 실행: cloudflared tunnel --url http://localhost:${linePort}`); + console.log(" 3. 표시된 URL + /webhook 을 LINE Developers에 등록"); + console.log(); + } + + // Common settings + envLines.push("# 공통 설정"); + envLines.push("COMMAND_PREFIX=!"); + envLines.push("COMMAND_TIMEOUT=30"); + envLines.push("AI_CLI_TIMEOUT=300"); + + const envContent = envLines.join("\n") + "\n"; fs.writeFileSync(envPath, envContent, "utf-8"); reloadConfig(); - console.log(); + console.log("=".repeat(48)); console.log(" .env 파일이 생성되었습니다!"); + console.log("=".repeat(48)); console.log(); } @@ -288,7 +400,35 @@ export function createSession(cliName: string, cwd: string): ISessionManager { // ── Main ───────────────────────────────────────────────────────────── async function main(): Promise { - await setupEnv(); + let cliName: string; + let workingDir: string; + + // Check if .env exists - if not, open web setup page + if (!envExists()) { + console.log(); + console.log(" .env 파일이 없습니다. 웹 설정 페이지를 엽니다..."); + + try { + const setupResult = await startSetupServer(5000); + reloadConfig(); + cliName = setupResult.cliName; + workingDir = setupResult.workingDir || process.cwd(); + } catch (err: any) { + // Fallback to terminal setup if web setup fails + console.log(); + console.log(" 웹 설정을 사용할 수 없습니다. 터미널 설정으로 전환합니다..."); + console.log(); + await setupEnv(); + const result = await startupSetup(); + cliName = result.cliName; + workingDir = result.workingDir; + } + } else { + // .env exists, use terminal setup for CLI/working dir selection + const result = await startupSetup(); + cliName = result.cliName; + workingDir = result.workingDir; + } const hasDiscord = !!DISCORD_BOT_TOKEN; const hasLine = !!(LINE_CHANNEL_ACCESS_TOKEN && LINE_CHANNEL_SECRET); @@ -305,7 +445,15 @@ async function main(): Promise { console.warn(); } - const { cliName, workingDir } = await startupSetup(); + // Ensure rules file exists + workingDir = path.resolve(workingDir || process.cwd()); + ensureRulesMd(cliName, workingDir); + + console.log(); + console.log("=".repeat(48)); + console.log(` ${CLI_TOOLS[cliName].name} @ ${workingDir}`); + console.log("=".repeat(48)); + console.log(); // Initialize audit logger if (AUDIT_LOG_ENABLED) { diff --git a/src/setup/setupPage.html b/src/setup/setupPage.html new file mode 100644 index 0000000..7f96438 --- /dev/null +++ b/src/setup/setupPage.html @@ -0,0 +1,718 @@ + + + + + + AI CLI Gateway Bot - Setup + + + +
+
+

AI CLI Gateway Bot

+

Discord / LINE에서 AI CLI 도구를 원격 제어하세요

+
+ +
+ +
+
+
+
+
플랫폼 선택
+
사용할 메신저를 선택하세요
+
+
+ +
+
+ 💬 + Discord +
+
+
+ +
+
+ 💬 + LINE +
+
+
+
+ + +
+
+
💬
+
+
Discord 설정
+
Discord 봇 정보를 입력하세요
+
+
+ +
+ +
+ + + +
+ +
+ + +
Discord Developer Portal > Bot > Reset Token
+
+ +
+ + +
Discord 설정 > 고급 > 개발자 모드 ON > 프로필 우클릭 > ID 복사
+
+
+
+
+ + + + + +
+
+
🤖
+
+
AI CLI 도구
+
사용할 AI CLI를 선택하세요
+
+
+ +
+ +
+ +
+ + +
AI가 작업할 폴더 경로 (비워두면 현재 폴더)
+
+
+ + + +
+
+ + +
+ + + + diff --git a/src/setup/setupServer.ts b/src/setup/setupServer.ts new file mode 100644 index 0000000..224e9d7 --- /dev/null +++ b/src/setup/setupServer.ts @@ -0,0 +1,180 @@ +import http from "node:http"; +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { spawn } from "node:child_process"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); + +interface SetupData { + platforms: { discord: boolean; line: boolean }; + discord: { token: string; userId: string } | null; + line: { token: string; secret: string; userId: string; port: string } | null; + cli: string; + workingDir: string; +} + +interface SetupResult { + cliName: string; + workingDir: string; +} + +/** + * Starts a local web server for the setup page. + * Returns a promise that resolves when the user completes setup. + */ +export function startSetupServer(port = 5000): Promise { + return new Promise((resolve, reject) => { + let setupComplete = false; + + const server = http.createServer(async (req, res) => { + const url = new URL(req.url || "/", `http://localhost:${port}`); + + // Serve the setup page + if (url.pathname === "/" || url.pathname === "/index.html") { + const htmlPath = path.join(__dirname, "setupPage.html"); + try { + const html = fs.readFileSync(htmlPath, "utf-8"); + res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" }); + res.end(html); + } catch (err) { + res.writeHead(500, { "Content-Type": "text/plain" }); + res.end("Failed to load setup page"); + } + return; + } + + // Handle API endpoint + if (url.pathname === "/api/setup" && req.method === "POST") { + let body = ""; + req.on("data", (chunk) => (body += chunk)); + req.on("end", () => { + try { + const data: SetupData = JSON.parse(body); + const result = saveSetup(data); + + res.writeHead(200, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ success: true })); + + setupComplete = true; + + // Close server after response is sent + setTimeout(() => { + server.close(); + resolve(result); + }, 500); + } catch (err: any) { + res.writeHead(400, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ success: false, error: err.message })); + } + }); + return; + } + + // 404 for other paths + res.writeHead(404, { "Content-Type": "text/plain" }); + res.end("Not Found"); + }); + + server.listen(port, () => { + console.log(); + console.log("=".repeat(48)); + console.log(" AI CLI Gateway Bot - Setup"); + console.log("=".repeat(48)); + console.log(); + console.log(` Setup page: http://localhost:${port}`); + console.log(); + console.log(" Opening browser..."); + console.log(); + + // Open browser + openBrowser(`http://localhost:${port}`); + }); + + server.on("error", (err) => { + reject(err); + }); + + // Timeout after 10 minutes + setTimeout(() => { + if (!setupComplete) { + server.close(); + reject(new Error("Setup timed out")); + } + }, 10 * 60 * 1000); + }); +} + +/** + * Save the setup data to .env file + */ +function saveSetup(data: SetupData): SetupResult { + const envPath = path.join(process.cwd(), ".env"); + const lines: string[] = []; + + // Discord settings + if (data.discord) { + lines.push("# Discord 설정"); + lines.push(`DISCORD_BOT_TOKEN=${data.discord.token}`); + lines.push(`ALLOWED_USER_IDS=${data.discord.userId}`); + lines.push(""); + } + + // LINE settings + if (data.line) { + lines.push("# LINE 설정"); + lines.push(`LINE_CHANNEL_ACCESS_TOKEN=${data.line.token}`); + lines.push(`LINE_CHANNEL_SECRET=${data.line.secret}`); + lines.push(`ALLOWED_LINE_USER_IDS=${data.line.userId}`); + lines.push(`LINE_WEBHOOK_PORT=${data.line.port}`); + lines.push(""); + } + + // Common settings + lines.push("# 공통 설정"); + lines.push("COMMAND_PREFIX=!"); + lines.push("COMMAND_TIMEOUT=30"); + lines.push("AI_CLI_TIMEOUT=300"); + + fs.writeFileSync(envPath, lines.join("\n") + "\n", "utf-8"); + + return { + cliName: data.cli || "claude", + workingDir: data.workingDir || process.cwd(), + }; +} + +/** + * Open the default browser with the given URL + */ +function openBrowser(url: string): void { + const platform = process.platform; + + let command: string; + let args: string[]; + + if (platform === "win32") { + command = "cmd"; + args = ["/c", "start", '""', url]; + } else if (platform === "darwin") { + command = "open"; + args = [url]; + } else { + command = "xdg-open"; + args = [url]; + } + + const child = spawn(command, args, { + detached: true, + stdio: "ignore", + shell: platform === "win32", + }); + child.unref(); +} + +/** + * Check if .env file exists + */ +export function envExists(): boolean { + return fs.existsSync(path.join(process.cwd(), ".env")); +}