Deploy: exdev sync 2026-08-03 13:59:26

This commit is contained in:
Macbook
2026-08-03 13:59:26 +09:00
parent 39f41d90b0
commit 62604fe1b7
19 changed files with 6505 additions and 10 deletions
+5
View File
@@ -0,0 +1,5 @@
node_modules/
dist/
.venv-icons/
*.log
.DS_Store
+48
View File
@@ -0,0 +1,48 @@
# EX AI Chatbot 데스크톱 앱
Electron 래퍼 — 브라우저 주소표시줄 없이 챗봇을 독립 창으로 실행합니다.
## 빌드 · 배포
```bash
./build-and-publish.sh
# 또는
npm run dist:mac # dmg(설치) + zip(포터블), intel + silicon
npm run dist:win # Setup.exe(설치) + portable.exe, win32 + win64
npm run publish-downloads
```
산출물은 `chatbotApp-chatbot2.0-agent/public-ai/downloads/` 로 복사됩니다.
| 구분 | 파일 |
|------|------|
| Mac 설치 (Intel) | `exAIChatbot 2.0-mac-intel.dmg` |
| Mac 포터블 (Intel) | `exAIChatbot 2.0-mac-intel.zip` |
| Mac 설치 (Apple Silicon) | `exAIChatbot 2.0-mac-silicon.dmg` |
| Mac 포터블 (Apple Silicon) | `exAIChatbot 2.0-mac-silicon.zip` |
| Windows 설치 64비트 | `exAIChatbot 2.0-win64-Setup.exe` |
| Windows 포터블 64비트 | `exAIChatbot 2.0-win64-portable.exe` |
| Windows 설치 32비트 | `exAIChatbot 2.0-win32-Setup.exe` |
| Windows 포터블 32비트 | `exAIChatbot 2.0-win32-portable.exe` |
다운로드 URL 예:
- `/chatbot/ai/downloads/exAIChatbot%202.0-mac-silicon.dmg`
- `/chatbot/ai/downloads/exAIChatbot%202.0-win64-Setup.exe`
## 앱 아이콘
공식 길통이·차로(`ai-mascot.png`)로 Dock/바로가기 아이콘을 생성합니다.
```bash
npm run build:icons # build/icon.png · icon.icns · icon.ico
```
`dist:mac` / `dist:win` 실행 시 자동으로 아이콘을 다시 만든 뒤 패키징합니다.
## 사용 안내
- **Mac dmg:** 앱을 Applications로 드래그. 차단 시 설정 화면「Mac 설치 명령 복사」.
- **Mac zip:** `실행하기.command` 또는 `.app` 실행.
- **Windows Setup:** 설치 마법사 실행.
- **Windows portable:** 압축 없이 exe 바로 실행. SmartScreen → 추가 정보 → 실행.
+90
View File
@@ -0,0 +1,90 @@
#!/usr/bin/env bash
# Electron 데스크톱 앱 재빌드 → chatbotApp public-ai/downloads/ 배포
#
# ./chatbotDesktopApp-chatbot2.0/build-and-publish.sh
# SKIP_WIN=1 ./chatbotDesktopApp-chatbot2.0/build-and-publish.sh # Mac만
# SKIP_MAC=1 ./chatbotDesktopApp-chatbot2.0/build-and-publish.sh # Win만
set -euo pipefail
DESKTOP_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
ROOT_DIR="$(cd "${DESKTOP_DIR}/.." && pwd)"
DOWNLOADS_DIR="${ROOT_DIR}/chatbotApp-chatbot2.0-agent/public-ai/downloads"
export PATH="/opt/homebrew/bin:/usr/local/bin:${PATH}"
export CSC_IDENTITY_AUTO_DISCOVERY="${CSC_IDENTITY_AUTO_DISCOVERY:-false}"
log() { echo "[desktop-build $(date '+%H:%M:%S')] $*"; }
warn() { log "⚠️ $*"; }
fail() { log "ERROR: $*"; exit 1; }
[[ -f "${DESKTOP_DIR}/package.json" ]] || fail "package.json 없음: ${DESKTOP_DIR}"
cd "${DESKTOP_DIR}"
if [[ ! -d node_modules/electron ]]; then
log "npm 의존성 설치..."
npm ci --prefer-offline 2>/dev/null || npm install
else
log "npm 의존성 확인..."
npm ci --prefer-offline 2>/dev/null || npm install --prefer-offline
fi
SKIP_MAC="${SKIP_MAC:-0}"
SKIP_WIN="${SKIP_WIN:-0}"
built_any=false
case "$(uname -s)" in
Darwin)
if [[ "${SKIP_MAC}" != "1" ]]; then
log "잔여 DMG 볼륨 정리..."
hdiutil detach "/Volumes/EX AI Chatbot" -force 2>/dev/null || true
hdiutil detach "/Volumes/EX AI Chatbot Intel" -force 2>/dev/null || true
hdiutil detach "/Volumes/EX AI Chatbot Silicon" -force 2>/dev/null || true
log "Mac Intel + Apple Silicon (dmg+zip) 빌드..."
# electron-builder는 arch별 dir(.app)만 순차 생성 → finalize-mac이 dmg/zip 생성
# (dmg를 동시 빌드하면 hdiutil detach 충돌로 실패함)
npm run dist:mac
built_any=true
fi
if [[ "${SKIP_WIN}" != "1" ]]; then
log "Windows 32/64비트 (Setup+portable) 빌드 (크로스)..."
if npm run dist:win; then
built_any=true
else
warn "Windows 빌드 실패 — Mac 산출물만 배포합니다"
fi
fi
;;
Linux)
if [[ "${SKIP_WIN}" != "1" ]]; then
log "Windows 32/64비트 (Setup+portable) 빌드..."
if npm run dist:win; then
built_any=true
else
warn "Windows 빌드 실패"
fi
fi
if [[ "${SKIP_MAC}" != "1" ]]; then
warn "Linux에서는 Mac 빌드를 건너뜁니다 (SKIP_MAC 강제)"
fi
;;
MINGW*|MSYS*|CYGWIN*|Windows_NT)
if [[ "${SKIP_WIN}" != "1" ]]; then
log "Windows 32/64비트 (Setup+portable) 빌드..."
npm run dist:win
built_any=true
fi
;;
*)
fail "지원하지 않는 OS: $(uname -s)"
;;
esac
$built_any || fail "빌드된 산출물이 없습니다"
log "downloads/ 로 복사..."
npm run publish-downloads
[[ -d "${DOWNLOADS_DIR}" ]] || fail "downloads 디렉터리 없음: ${DOWNLOADS_DIR}"
log "✅ 데스크톱 앱 배포 완료 → ${DOWNLOADS_DIR}"
ls -lh "${DOWNLOADS_DIR}" | sed 's/^/ /'
+176
View File
@@ -0,0 +1,176 @@
const { app, BrowserWindow, shell, Menu, session } = require("electron");
const path = require("node:path");
const fs = require("node:fs");
// Gatekeeper / ad-hoc 서명 환경에서 GPU Helper SIGTRAP 완화
app.disableHardwareAcceleration();
app.commandLine.appendSwitch("disable-gpu");
app.commandLine.appendSwitch("disable-gpu-compositing");
app.commandLine.appendSwitch("disable-software-rasterizer");
app.commandLine.appendSwitch("no-sandbox");
app.commandLine.appendSwitch("disable-dev-shm-usage");
const WINDOW_WIDTH = 390;
const WINDOW_HEIGHT = 720;
const DEFAULT_URL =
process.env.CHATBOT_APP_URL ||
"https://chatbot.exdev.co.kr/chatbot/ai/?desktop=1";
const logFile = path.join(app.getPath("userData"), "ex-ai-chatbot.log");
function log(msg) {
const line = `[${new Date().toISOString()}] ${msg}\n`;
try {
fs.appendFileSync(logFile, line);
} catch {
/* ignore */
}
console.log(msg);
}
/** 챗봇 주소에서 온 마이크 요청은 매번 묻지 않고 허용한다. (음성인식 반복 권한창 방지) */
function allowMicrophoneForChatbot() {
const allowed = new Set(["media", "audioCapture", "mediaKeySystem"]);
const appOrigin = (() => {
try {
return new URL(DEFAULT_URL).origin;
} catch {
return null;
}
})();
const isChatbotOrigin = (url) => {
if (!appOrigin || !url) return false;
try {
return new URL(url).origin === appOrigin;
} catch {
return false;
}
};
const ses = session.defaultSession;
ses.setPermissionRequestHandler((webContents, permission, callback, details) => {
const url = details?.requestingUrl || webContents?.getURL();
callback(allowed.has(permission) && isChatbotOrigin(url));
});
ses.setPermissionCheckHandler((_webContents, permission, requestingOrigin, details) => {
const url = requestingOrigin || details?.requestingUrl;
return allowed.has(permission) && isChatbotOrigin(url);
});
if (typeof ses.setDevicePermissionHandler === "function") {
ses.setDevicePermissionHandler((details) => isChatbotOrigin(details?.origin));
}
}
function createWindow() {
const iconPath = path.join(__dirname, "build", process.platform === "win32" ? "icon.ico" : "icon.png");
const winOpts = {
width: WINDOW_WIDTH,
height: WINDOW_HEIGHT,
minWidth: 360,
minHeight: 520,
title: "한국도로공사 AI 챗봇",
backgroundColor: "#ffffff",
autoHideMenuBar: true,
show: false,
webPreferences: {
preload: path.join(__dirname, "preload.js"),
contextIsolation: true,
nodeIntegration: false,
sandbox: false,
webSecurity: true,
},
};
if (fs.existsSync(iconPath)) {
winOpts.icon = iconPath;
}
const win = new BrowserWindow(winOpts);
Menu.setApplicationMenu(null);
win.once("ready-to-show", () => {
win.show();
if (process.platform === "darwin" && app.dock) {
const dockIcon = path.join(__dirname, "build", "icon.png");
if (fs.existsSync(dockIcon)) {
try {
app.dock.setIcon(dockIcon);
} catch (e) {
log(`dock.setIcon failed: ${e}`);
}
}
app.dock.show();
}
});
win.webContents.on("did-fail-load", (_e, code, desc, url) => {
log(`did-fail-load code=${code} desc=${desc} url=${url}`);
// 네트워크 실패 시에도 빈 창으로 종료되지 않도록 안내 페이지
const html = `<!doctype html><meta charset="utf-8"/>
<body style="font:14px -apple-system,sans-serif;padding:40px;line-height:1.6">
<h2>연결 실패</h2>
<p>챗봇 서버에 연결하지 못했습니다.</p>
<p style="color:#666">${code}: ${desc}</p>
<p><a href="${DEFAULT_URL}">다시 시도</a></p>
</body>`;
win.loadURL(`data:text/html;charset=utf-8,${encodeURIComponent(html)}`);
});
win.webContents.on("render-process-gone", (_e, details) => {
log(`render-process-gone ${JSON.stringify(details)}`);
});
win.on("unresponsive", () => log("window unresponsive"));
log(`loadURL ${DEFAULT_URL}`);
win.loadURL(DEFAULT_URL).catch((err) => log(`loadURL error: ${err}`));
win.webContents.setWindowOpenHandler(({ url }) => {
shell.openExternal(url);
return { action: "deny" };
});
return win;
}
const gotLock = app.requestSingleInstanceLock();
if (!gotLock) {
app.quit();
} else {
app.on("second-instance", () => {
const wins = BrowserWindow.getAllWindows();
if (wins.length > 0) {
if (wins[0].isMinimized()) wins[0].restore();
wins[0].focus();
} else {
createWindow();
}
});
app.whenReady().then(() => {
app.setName("EX AI Chatbot");
log(`ready userData=${app.getPath("userData")}`);
allowMicrophoneForChatbot();
createWindow();
app.on("activate", () => {
if (BrowserWindow.getAllWindows().length === 0) {
createWindow();
}
});
});
app.on("window-all-closed", () => {
if (process.platform !== "darwin") {
app.quit();
}
});
process.on("uncaughtException", (err) => {
log(`uncaughtException ${err?.stack || err}`);
});
}
File diff suppressed because it is too large Load Diff
+72
View File
@@ -0,0 +1,72 @@
{
"name": "ex-ai-chatbot-desktop",
"version": "1.0.0",
"description": "한국도로공사 AI 챗봇 데스크톱 앱 (Electron 래퍼)",
"main": "main.js",
"author": "EX",
"license": "UNLICENSED",
"private": true,
"scripts": {
"start": "electron .",
"build:icons": "python3 scripts/build-icons.py",
"dist:mac:detach": "hdiutil detach '/Volumes/EX AI Chatbot' -force 2>/dev/null || hdiutil detach '/Volumes/EX AI Chatbot Intel' -force 2>/dev/null || hdiutil detach '/Volumes/EX AI Chatbot Silicon' -force 2>/dev/null || true",
"dist:mac:x64": "npm run dist:mac:detach && electron-builder --mac dir --x64",
"dist:mac:arm64": "npm run dist:mac:detach && electron-builder --mac dir --arm64",
"dist:mac": "npm run build:icons && npm run dist:mac:x64 && npm run dist:mac:arm64 && node scripts/finalize-mac.mjs",
"dist:win": "npm run build:icons && electron-builder --win --x64 --ia32 && node scripts/finalize-win.mjs",
"dist:all": "npm run dist:mac && npm run dist:win",
"publish-downloads": "node scripts/publish-downloads.mjs"
},
"build": {
"appId": "kr.co.ex.aichatbot",
"productName": "EX AI Chatbot",
"copyright": "Copyright © Korea Expressway Corporation",
"directories": {
"output": "dist",
"buildResources": "build"
},
"files": [
"main.js",
"preload.js",
"package.json",
"build/icon.png",
"build/icon.ico"
],
"mac": {
"category": "public.app-category.utilities",
"icon": "build/icon.icns",
"target": ["dir"],
"identity": null
},
"win": {
"icon": "build/icon.ico",
"target": [
{
"target": "nsis",
"arch": ["x64", "ia32"]
},
{
"target": "portable",
"arch": ["x64", "ia32"]
}
]
},
"nsis": {
"oneClick": false,
"allowToChangeInstallationDirectory": true,
"createDesktopShortcut": true,
"createStartMenuShortcut": true,
"shortcutName": "EX AI Chatbot",
"installerIcon": "build/icon.ico",
"uninstallerIcon": "build/icon.ico",
"artifactName": "raw-win-${arch}-Setup.${ext}"
},
"portable": {
"artifactName": "raw-win-${arch}-portable.${ext}"
}
},
"devDependencies": {
"electron": "^35.1.2",
"electron-builder": "^25.1.8"
}
}
@@ -0,0 +1,14 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.cs.allow-jit</key>
<true/>
<key>com.apple.security.cs.allow-unsigned-executable-memory</key>
<true/>
<key>com.apple.security.cs.disable-library-validation</key>
<true/>
<key>com.apple.security.cs.allow-dyld-environment-variables</key>
<true/>
</dict>
</plist>
@@ -0,0 +1,36 @@
EX AI Chatbot — Mac 설치 안내
================================
이 패키지는 Apple 공증(notarization)이 없는 사내/개발용 빌드입니다.
Chrome 등으로 받은 뒤 앱을 바로 열면
「손상되었기 때문에 열 수 없습니다」가 뜰 수 있습니다.
(포터블 zip의 「실행하기.command」는 이 문제를 자동으로 피합니다.)
────────────────────────────────
A. 설치 파일 (.dmg) — 반드시 아래 순서
────────────────────────────────
1. dmg를 연다
2. 「응용 프로그램에 설치하고 실행.command」를 더블클릭
→ Applications에 설치 + 검역 속성 제거 + 실행까지 한 번에 처리
3. (대안) 앱을 Applications로 드래그한 뒤, 아래 C. 터미널 명령을 실행
※ Applications의 앱을 더블클릭만 하면 macOS가 다시 차단할 수 있습니다.
처음 한 번은 반드시 「설치하고 실행.command」또는 터미널(xattr)을 사용하세요.
────────────────────────────────
B. 포터블 (.zip)
────────────────────────────────
1. zip 압축 해제
2. 「실행하기.command」를 더블클릭
3. 차단되면 아래 「터미널 설치」를 사용
────────────────────────────────
C. 터미널 설치 (손상됨 / 차단될 때)
────────────────────────────────
1. Spotlight(⌘+스페이스) → 「터미널」실행
2. 챗봇 설정 화면의 「Mac 설치 명령 복사」붙여넣기(⌘+V) 후 Enter
직접 입력 (Applications에 넣은 경우):
xattr -cr "/Applications/EX AI Chatbot.app" && open "/Applications/EX AI Chatbot.app"
Apple Silicon → mac-silicon / Intel → mac-intel 파일을 받으세요.
@@ -0,0 +1,18 @@
#!/bin/bash
# 주의: 웹에서 받은 .command 는 macOS가 같이 차단할 수 있습니다.
# 차단되면 「설치안내.txt」의 터미널 방법을 사용하세요.
set -euo pipefail
cd "$(dirname "$0")"
APP="EX AI Chatbot.app"
if [[ ! -d "${APP}" ]]; then
osascript -e 'display dialog "같은 폴더에 「EX AI Chatbot.app」이 없습니다.\n압축을 해제한 뒤, 차단되면 터미널 설치 방법을 사용하세요.\n(설치안내.txt 참고)" buttons {"확인"} default button 1 with title "EX AI Chatbot"' >/dev/null 2>&1 || true
exit 1
fi
# 자기 자신·앱 검역 제거 시도 (권한이 되면 성공)
xattr -cr . 2>/dev/null || true
xattr -cr "${APP}" 2>/dev/null || true
open "${APP}"
@@ -0,0 +1,25 @@
#!/bin/bash
# DMG에서 실행: Applications로 복사 + Gatekeeper 검역 제거 후 실행
# (미서명 빌드에서 「손상되었기 때문에 열 수 없습니다」 방지)
set -euo pipefail
cd "$(dirname "$0")"
APP_NAME="EX AI Chatbot.app"
SRC="$(pwd)/${APP_NAME}"
DEST="/Applications/${APP_NAME}"
if [[ ! -d "${SRC}" ]]; then
osascript -e 'display dialog "같은 디스크에 「EX AI Chatbot.app」이 없습니다.\ndmg 창에서 이 스크립트를 실행해 주세요." buttons {"확인"} default button 1 with title "EX AI Chatbot"' >/dev/null 2>&1 || true
exit 1
fi
# 기존 설치 교체
rm -rf "${DEST}"
cp -R "${SRC}" "${DEST}"
# 다운로드/복사로 붙은 quarantine 제거 (손상됨 팝업 핵심 해결)
xattr -cr "${DEST}" 2>/dev/null || true
open "${DEST}"
osascript -e 'display dialog "응용 프로그램에 설치하고 실행했습니다.\n다음에 실행할 때는 Launchpad 또는 응용 프로그램 폴더의 「EX AI Chatbot」을 사용하세요." buttons {"확인"} default button 1 with title "EX AI Chatbot"' >/dev/null 2>&1 || true
+6
View File
@@ -0,0 +1,6 @@
const { contextBridge } = require("electron");
contextBridge.exposeInMainWorld("exDesktop", {
isDesktop: true,
platform: process.platform,
});
@@ -0,0 +1,230 @@
#!/usr/bin/env python3
"""
공식 길통이+차로(ai-mascot.png)로 Electron 앱 아이콘 생성
- build/icon.png (1024)
- build/icon.icns (macOS)
- build/icon.ico (Windows)
"""
from __future__ import annotations
import plistlib
import shutil
import struct
import subprocess
import sys
import tempfile
import zlib
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
BUILD = ROOT / "build"
SRC_CANDIDATES = [
ROOT.parent / "chatbotApp-chatbot2.0-agent" / "web" / "public" / "images" / "ai-mascot.png",
ROOT.parent / "chatbotApp-chatbot2.0-agent" / "public-ai" / "images" / "ai-mascot.png",
]
# EX 브랜드 블루
BG = (36, 107, 235, 255)
WHITE = (255, 255, 255, 255)
def _venv_python(venv: Path) -> Path:
if sys.platform == "win32":
return venv / "Scripts" / "python.exe"
py = venv / "bin" / "python3"
return py if py.exists() else venv / "bin" / "python"
def _in_venv(venv: Path) -> bool:
"""Homebrew venv의 bin/python은 시스템 바이너리 symlink라 executable.resolve() 비교는 불가."""
try:
return Path(sys.prefix).resolve() == venv.resolve()
except OSError:
return False
def _ensure_pip(py: Path) -> None:
try:
subprocess.check_call([str(py), "-m", "pip", "--version"], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
except subprocess.CalledProcessError:
print("[build-icons] ensurepip으로 pip 부트스트랩…", flush=True)
subprocess.check_call([str(py), "-m", "ensurepip", "--upgrade"])
def _pip_install_pillow(py: Path) -> None:
_ensure_pip(py)
cmd = [str(py), "-m", "pip", "install", "Pillow", "-q"]
try:
subprocess.check_call(cmd)
except subprocess.CalledProcessError:
print("[build-icons] pip 재시도 (ensurepip 후)…", flush=True)
subprocess.check_call([str(py), "-m", "ensurepip", "--upgrade"])
subprocess.check_call(cmd)
def require_pillow():
try:
from PIL import Image, ImageDraw # noqa: F401
return
except ImportError:
pass
venv = ROOT / ".venv-icons"
py = _venv_python(venv)
if not py.exists():
print("[build-icons] venv 생성 중…", flush=True)
subprocess.check_call([sys.executable, "-m", "venv", str(venv)])
py = _venv_python(venv)
print("[build-icons] Pillow 설치 중…", flush=True)
_pip_install_pillow(py)
# 현재 프로세스에 Pillow가 없으면 venv로 재실행 (sys.prefix로 판별)
if not _in_venv(venv):
raise SystemExit(subprocess.call([str(py), str(Path(__file__).resolve()), *sys.argv[1:]]))
from PIL import Image, ImageDraw # noqa: F401
def rounded_rect_mask(size: int, radius: int):
from PIL import Image, ImageDraw
mask = Image.new("L", (size, size), 0)
draw = ImageDraw.Draw(mask)
draw.rounded_rectangle((0, 0, size - 1, size - 1), radius=radius, fill=255)
return mask
def make_icon_png(src: Path, out: Path, size: int = 1024) -> None:
from PIL import Image, ImageDraw, ImageFilter
mascot = Image.open(src).convert("RGBA")
# 흰 배경 제거에 가깝게: 거의 흰 픽셀을 투명 처리
pixels = mascot.load()
w, h = mascot.size
for y in range(h):
for x in range(w):
r, g, b, a = pixels[x, y]
if r > 245 and g > 245 and b > 245:
pixels[x, y] = (r, g, b, 0)
canvas = Image.new("RGBA", (size, size), (0, 0, 0, 0))
draw = ImageDraw.Draw(canvas)
# macOS 아이콘용 라운드 사각 (약 22% radius)
radius = int(size * 0.223)
draw.rounded_rectangle((0, 0, size - 1, size - 1), radius=radius, fill=BG)
# 안쪽 밝은 원형 배지
badge = Image.new("RGBA", (size, size), (0, 0, 0, 0))
bd = ImageDraw.Draw(badge)
pad = int(size * 0.08)
bd.ellipse((pad, pad, size - pad, size - pad), fill=(255, 255, 255, 235))
canvas = Image.alpha_composite(canvas, badge)
# 마스코트 배치 (여백 확보)
max_side = int(size * 0.78)
mw, mh = mascot.size
scale = min(max_side / mw, max_side / mh)
nw, nh = int(mw * scale), int(mh * scale)
mascot = mascot.resize((nw, nh), Image.Resampling.LANCZOS)
ox = (size - nw) // 2
oy = (size - nh) // 2 + int(size * 0.02)
canvas.alpha_composite(mascot, (ox, oy))
# 마스크로 모서리 정리
mask = rounded_rect_mask(size, radius)
out_img = Image.new("RGBA", (size, size), (0, 0, 0, 0))
out_img.paste(canvas, (0, 0), mask=mask)
out.parent.mkdir(parents=True, exist_ok=True)
out_img.save(out, "PNG")
print(f"[build-icons] ✅ {out} ({size}x{size})")
def build_icns(png_1024: Path, icns_path: Path) -> None:
sizes = [
(16, "icon_16x16.png"),
(32, "diana.k@example.org"),
(32, "icon_32x32.png"),
(64, "ivan.p@example.net"),
(128, "icon_128x128.png"),
(256, "wendy.h@example.net"),
(256, "icon_256x256.png"),
(512, "wendy.h@example.net"),
(512, "icon_512x512.png"),
(1024, "walt.e@example.net"),
]
iconset = BUILD / "icon.iconset"
if iconset.exists():
shutil.rmtree(iconset)
iconset.mkdir(parents=True)
from PIL import Image
base = Image.open(png_1024).convert("RGBA")
for px, name in sizes:
img = base.resize((px, px), Image.Resampling.LANCZOS)
img.save(iconset / name, "PNG")
if icns_path.exists():
icns_path.unlink()
subprocess.check_call(["iconutil", "-c", "icns", str(iconset), "-o", str(icns_path)])
shutil.rmtree(iconset)
print(f"[build-icons] ✅ {icns_path}")
def _png_chunk(tag: bytes, data: bytes) -> bytes:
return struct.pack(">I", len(data)) + tag + data + struct.pack(">I", zlib.crc32(tag + data) & 0xFFFFFFFF)
def rgba_to_png_bytes(img) -> bytes:
"""Pillow 이미지 → PNG bytes (ICO용)."""
import io
buf = io.BytesIO()
img.save(buf, format="PNG")
return buf.getvalue()
def build_ico(png_1024: Path, ico_path: Path) -> None:
"""여러 해상도 PNG를 담은 .ico 생성 (electron-builder는 256 필수)."""
from PIL import Image
sizes = [(16, 16), (24, 24), (32, 32), (48, 48), (64, 64), (128, 128), (256, 256)]
base = Image.open(png_1024).convert("RGBA")
# Pillow: 원본에서 sizes= 로 리사이즈한 멀티 엔트리 ICO 생성
# (append_images + 작은 첫 프레임은 16x16만 남는 버그 회피)
base.save(ico_path, format="ICO", sizes=sizes)
print(f"[build-icons] ✅ {ico_path}")
def main() -> int:
require_pillow()
src = next((p for p in SRC_CANDIDATES if p.exists()), None)
if not src:
print("[build-icons] ai-mascot.png 없음", file=sys.stderr)
return 1
BUILD.mkdir(parents=True, exist_ok=True)
png = BUILD / "icon.png"
make_icon_png(src, png, 1024)
# electron-builder 호환 별칭
shutil.copy2(png, BUILD / "icon-1024.png")
if sys.platform == "darwin":
try:
build_icns(png, BUILD / "icon.icns")
except Exception as e:
print(f"[build-icons] icns 실패 (png만 사용): {e}", file=sys.stderr)
else:
print("[build-icons] icns는 macOS에서만 생성합니다")
try:
build_ico(png, BUILD / "icon.ico")
except Exception as e:
print(f"[build-icons] ico 실패: {e}", file=sys.stderr)
return 1
print("[build-icons] 완료")
return 0
if __name__ == "__main__":
raise SystemExit(main())
@@ -0,0 +1,23 @@
/** 다운로드 산출물 파일명 (공백 포함 — URL 인코딩 필요) */
export const DOWNLOAD_ARTIFACTS = {
macIntelInstaller: "exAIChatbot 2.0-mac-intel.dmg",
macIntelPortable: "exAIChatbot 2.0-mac-intel.zip",
macSiliconInstaller: "exAIChatbot 2.0-mac-silicon.dmg",
macSiliconPortable: "exAIChatbot 2.0-mac-silicon.zip",
win32Installer: "exAIChatbot 2.0-win32-Setup.exe",
win32Portable: "exAIChatbot 2.0-win32-portable.exe",
win64Installer: "exAIChatbot 2.0-win64-Setup.exe",
win64Portable: "exAIChatbot 2.0-win64-portable.exe",
};
export const DOWNLOAD_ARTIFACT_LIST = Object.values(DOWNLOAD_ARTIFACTS);
/** 이전 배포 파일명 (정리용) */
export const OBSOLETE_DOWNLOAD_ARTIFACTS = [
"exAIChatbot 2.0-win32.zip",
"exAIChatbot 2.0-win64.zip",
"EX-AI-Chatbot-mac-universal.zip",
"EX-AI-Chatbot-Setup.exe",
"EX-AI-Chatbot-Setup.exe.blockmap",
"EX-AI-Chatbot-mac-universal.zip.blockmap",
];
@@ -0,0 +1,226 @@
#!/usr/bin/env node
/**
* Mac 산출물 정리
* - zip(포터블): .app + 실행하기.command
* - dmg(설치형): 동일 서명 .app + 「응용 프로그램에 설치하고 실행.command」
* (Gatekeeper 「손상됨」팝업 방지 — 설치 시 xattr -cr 수행)
*/
import { execFileSync } from "node:child_process";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { DOWNLOAD_ARTIFACTS } from "./download-artifacts.mjs";
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const distDir = path.join(root, "dist");
const appName = "EX AI Chatbot.app";
const packagingMac = path.join(root, "packaging", "mac");
const installCmdName = "응용 프로그램에 설치하고 실행.command";
const runCmdName = "실행하기.command";
const guideName = "설치안내.txt";
const TARGETS = [
{
appDirs: [path.join(distDir, "mac-x64", appName), path.join(distDir, "mac", appName)],
zipName: DOWNLOAD_ARTIFACTS.macIntelPortable,
dmgName: DOWNLOAD_ARTIFACTS.macIntelInstaller,
label: "intel (x64)",
},
{
appDirs: [path.join(distDir, "mac-arm64", appName)],
zipName: DOWNLOAD_ARTIFACTS.macSiliconPortable,
dmgName: DOWNLOAD_ARTIFACTS.macSiliconInstaller,
label: "silicon (arm64)",
},
];
function codesignApp(appPath, label) {
try {
execFileSync(
"codesign",
[
"--force",
"--deep",
"--sign",
"-",
"--entitlements",
path.join(packagingMac, "entitlements.plist"),
appPath,
],
{ stdio: "inherit" },
);
} catch (e) {
console.warn(`[finalize-mac] entitlements codesign 실패 (${label}), ad-hoc 재시도:`, e.message);
try {
execFileSync("codesign", ["--force", "--deep", "--sign", "-", appPath], {
stdio: "inherit",
});
} catch (e2) {
console.warn(`[finalize-mac] codesign 실패 (${label}):`, e2.message);
}
}
try {
execFileSync("xattr", ["-cr", appPath], { stdio: "ignore" });
} catch {
/* ignore */
}
}
function ensurePackagingFiles() {
for (const name of [runCmdName, installCmdName, guideName]) {
const p = path.join(packagingMac, name);
if (!fs.existsSync(p)) throw new Error(`packaging/mac 파일 없음: ${name}`);
}
}
function packZip(appPath, zipName, label) {
console.log(`[finalize-mac] zip ${label}: ${appPath}`);
const stage = fs.mkdtempSync(path.join(os.tmpdir(), "ex-ai-mac-zip-"));
try {
fs.cpSync(appPath, path.join(stage, appName), { recursive: true });
fs.copyFileSync(path.join(packagingMac, runCmdName), path.join(stage, runCmdName));
fs.copyFileSync(path.join(packagingMac, guideName), path.join(stage, guideName));
fs.chmodSync(path.join(stage, runCmdName), 0o755);
try {
execFileSync("xattr", ["-cr", stage], { stdio: "ignore" });
} catch {
/* ignore */
}
const zipPath = path.join(distDir, zipName);
if (fs.existsSync(zipPath)) fs.unlinkSync(zipPath);
execFileSync("zip", ["-r", "-y", zipPath, appName, runCmdName, guideName], {
cwd: stage,
stdio: "inherit",
});
const size = fs.statSync(zipPath).size;
console.log(`[finalize-mac] ✅ zip ${zipName} (${Math.round(size / 1024 / 1024)} MB)`);
return true;
} finally {
fs.rmSync(stage, { recursive: true, force: true });
}
}
function detachStaleVolumes() {
for (const vol of ["EX AI Chatbot", "EX AI Chatbot Intel", "EX AI Chatbot Silicon"]) {
try {
execFileSync("hdiutil", ["detach", `/Volumes/${vol}`, "-force"], { stdio: "ignore" });
} catch {
/* not mounted */
}
}
}
function packDmg(appPath, dmgName, label) {
console.log(`[finalize-mac] dmg ${label}: ${appPath}`);
const stage = fs.mkdtempSync(path.join(os.tmpdir(), "ex-ai-mac-dmg-"));
const rwDmg = path.join(os.tmpdir(), `ex-ai-mac-${Date.now()}-${process.pid}.dmg`);
// arch별 볼륨명 — 동시/잔여 마운트 충돌 방지
const volName = label.includes("silicon") ? "EX AI Chatbot Silicon" : "EX AI Chatbot Intel";
try {
fs.cpSync(appPath, path.join(stage, appName), { recursive: true });
fs.copyFileSync(path.join(packagingMac, installCmdName), path.join(stage, installCmdName));
fs.copyFileSync(path.join(packagingMac, guideName), path.join(stage, guideName));
fs.chmodSync(path.join(stage, installCmdName), 0o755);
// Applications 폴더 바로가기 (일반 dmg UX)
try {
fs.symlinkSync("/Applications", path.join(stage, "Applications"));
} catch {
/* ignore */
}
try {
execFileSync("xattr", ["-cr", stage], { stdio: "ignore" });
} catch {
/* ignore */
}
const outPath = path.join(distDir, dmgName);
if (fs.existsSync(outPath)) fs.unlinkSync(outPath);
if (fs.existsSync(rwDmg)) fs.unlinkSync(rwDmg);
detachStaleVolumes();
// 읽기 전용 압축 dmg 생성 (electron-builder DMG 미사용 — hdiutil 경합 회피)
// Finder/Spotlight가 잠깐 잡고 있으면 "리소스 사용 중"이 나므로 재시도한다.
const createArgs = [
"create",
"-volname",
volName,
"-srcfolder",
stage,
"-ov",
"-format",
"UDZO",
outPath,
];
let lastErr = null;
for (let attempt = 1; attempt <= 4; attempt += 1) {
try {
if (attempt > 1) {
console.warn(`[finalize-mac] hdiutil create 재시도 ${attempt}/4 (${label})`);
detachStaleVolumes();
try {
execFileSync("sleep", [String(1.5 * attempt)], { stdio: "ignore" });
} catch {
/* ignore */
}
}
execFileSync("hdiutil", createArgs, { stdio: "inherit" });
lastErr = null;
break;
} catch (e) {
lastErr = e;
const msg = String(e?.stderr || e?.message || e);
const busy = /리소스 사용 중|Resource busy|Device busy|hdiutil/i.test(msg) || e?.status === 1;
if (!busy || attempt === 4) break;
}
}
if (lastErr) throw lastErr;
const size = fs.statSync(outPath).size;
console.log(`[finalize-mac] ✅ dmg ${dmgName} (${Math.round(size / 1024 / 1024)} MB)`);
return true;
} finally {
fs.rmSync(stage, { recursive: true, force: true });
try {
if (fs.existsSync(rwDmg)) fs.unlinkSync(rwDmg);
} catch {
/* ignore */
}
}
}
if (!fs.existsSync(distDir)) {
console.error("[finalize-mac] dist 없음");
process.exit(1);
}
ensurePackagingFiles();
let done = 0;
for (const t of TARGETS) {
const appPath = t.appDirs.find((p) => fs.existsSync(p));
if (!appPath) {
console.warn(`[finalize-mac] skip ${t.label}: .app 없음`);
continue;
}
codesignApp(appPath, t.label);
if (packDmg(appPath, t.dmgName, t.label)) done += 1;
if (packZip(appPath, t.zipName, t.label)) done += 1;
}
// 빌더 raw zip/dmg 잔여 정리
for (const raw of fs.readdirSync(distDir)) {
if (/^raw-mac-/.test(raw) && (raw.endsWith(".zip") || raw.endsWith(".dmg"))) {
fs.unlinkSync(path.join(distDir, raw));
console.log(`[finalize-mac] removed ${raw}`);
}
}
if (done === 0) {
console.error("[finalize-mac] 패키징할 Mac 산출물이 없습니다.");
process.exit(1);
}
@@ -0,0 +1,69 @@
#!/usr/bin/env node
/**
* Windows 산출물 정리
* - Setup.exe (NSIS 설치형)
* - portable.exe (포터블)
*/
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { DOWNLOAD_ARTIFACTS } from "./download-artifacts.mjs";
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const distDir = path.join(root, "dist");
const MAP = [
{
patterns: [/^raw-win-x64-Setup\.exe$/i],
out: DOWNLOAD_ARTIFACTS.win64Installer,
},
{
patterns: [/^raw-win-x64-portable\.exe$/i],
out: DOWNLOAD_ARTIFACTS.win64Portable,
},
{
patterns: [/^raw-win-ia32-Setup\.exe$/i, /^raw-win-i386-Setup\.exe$/i],
out: DOWNLOAD_ARTIFACTS.win32Installer,
},
{
patterns: [/^raw-win-ia32-portable\.exe$/i, /^raw-win-i386-portable\.exe$/i],
out: DOWNLOAD_ARTIFACTS.win32Portable,
},
];
if (!fs.existsSync(distDir)) {
console.error("[finalize-win] dist 없음");
process.exit(1);
}
const files = fs.readdirSync(distDir);
let done = 0;
for (const rule of MAP) {
const srcName = files.find((f) => rule.patterns.some((re) => re.test(f)));
if (!srcName) {
console.warn(`[finalize-win] skip (원본 없음): ${rule.out}`);
continue;
}
const src = path.join(distDir, srcName);
const dest = path.join(distDir, rule.out);
if (src !== dest) {
if (fs.existsSync(dest)) fs.unlinkSync(dest);
fs.renameSync(src, dest);
}
const size = fs.statSync(dest).size;
console.log(`[finalize-win] ✅ ${rule.out} (${Math.round(size / 1024 / 1024)} MB) ← ${srcName}`);
done += 1;
}
for (const f of fs.readdirSync(distDir)) {
if (/^raw-win-/.test(f)) {
fs.unlinkSync(path.join(distDir, f));
console.log(`[finalize-win] removed ${f}`);
}
}
if (done === 0) {
console.error("[finalize-win] Windows 설치/포터블 산출물을 찾지 못했습니다.");
process.exit(1);
}
@@ -0,0 +1,76 @@
#!/usr/bin/env node
/**
* electron-builder 산출물 → chatbotApp public-ai/downloads/ 복사
*/
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import {
DOWNLOAD_ARTIFACTS,
DOWNLOAD_ARTIFACT_LIST,
OBSOLETE_DOWNLOAD_ARTIFACTS,
} from "./download-artifacts.mjs";
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const distDir = path.join(root, "dist");
const downloadsDir = path.resolve(
root,
"../chatbotApp-chatbot2.0-agent/public-ai/downloads",
);
const pkg = JSON.parse(fs.readFileSync(path.join(root, "package.json"), "utf8"));
if (!fs.existsSync(distDir)) {
console.error(`[publish-downloads] dist 없음: ${distDir}`);
console.error("먼저 npm run dist:mac 또는 dist:win 을 실행하세요.");
process.exit(1);
}
fs.mkdirSync(downloadsDir, { recursive: true });
for (const name of OBSOLETE_DOWNLOAD_ARTIFACTS) {
const p = path.join(downloadsDir, name);
if (fs.existsSync(p)) {
fs.unlinkSync(p);
console.log(`[publish-downloads] removed obsolete ${name}`);
}
}
const copied = [];
for (const name of DOWNLOAD_ARTIFACT_LIST) {
const src = path.join(distDir, name);
if (!fs.existsSync(src)) {
console.warn(`[publish-downloads] skip (없음): ${name}`);
continue;
}
const dest = path.join(downloadsDir, name);
fs.copyFileSync(src, dest);
const stat = fs.statSync(dest);
copied.push({
file: name,
size: stat.size,
mtime: stat.mtime.toISOString(),
});
console.log(`[publish-downloads] ${name} → ${dest}`);
}
const manifest = {
version: pkg.version,
updatedAt: new Date().toISOString(),
files: { ...DOWNLOAD_ARTIFACTS },
artifacts: copied,
note:
"미서명 빌드 — Mac: dmg(설치) / zip(포터블·실행하기.command). Windows: Setup.exe(설치) / portable.exe(포터블).",
};
fs.writeFileSync(
path.join(downloadsDir, "manifest.json"),
`${JSON.stringify(manifest, null, 2)}\n`,
"utf8",
);
if (copied.length === 0) {
console.error("[publish-downloads] 복사된 파일이 없습니다.");
process.exit(1);
}
console.log(`[publish-downloads] ✅ ${copied.length}개 → ${downloadsDir}`);
+8 -3
View File
@@ -48,16 +48,21 @@ Gitea(`https://github.exdev.co.kr`)에서 **`exAichatbot_agent`** 저장소 생
## 로컬 Mac → Gitea push → 서버 배포
개발 Mac에서 **한 번에** GitHub 최신 반영 · Gitea push · 서버 빌드·배포:
개발 Mac에서 **한 번에** Gitea 최신 반영 · 로컬 변경 commit/push · 서버 빌드·배포:
```bash
./start-all-server.sh
# = 로컬 빌드 → Gitea(exdev) push → 원격 배포 (공식 GitHub push 없음)
# = (push-to-gitea) Gitea fetch/merge → 로컬 commit → Gitea push → 원격 배포
# 공식 GitHub push 없음
./git_fetch_all.sh
# = 확인 후 공식 GitHub(KoreaExpressWayCorp) 반영 전용
# = 공식 GitHub(KoreaExpressWayCorp) submodule sync/commit/push 전용
```
`push-to-gitea.sh` 는 `git_fetch_all.sh` 와 같이 **원격(Gitea) 변경을 먼저 merge** 한 뒤 로컬을 올립니다.
충돌 시 스크립트가 멈추므로 수동 해결 후 재실행하세요.
(서버 work tree에만 있고 Gitea에 없는 수정은 포함되지 않습니다 — 배포는 Gitea 기준입니다.)
| 옵션 | 설명 |
|------|------|
| `--local` | 예전처럼 `local-dev/start-all.sh` 로컬 개발 모드 |
+93 -5
View File
@@ -1,9 +1,15 @@
#!/usr/bin/env bash
# 로컬 monorepo(현재 소스) → Gitea push (post-receive / 원격 배포 트리거)
# 로컬 monorepo → Gitea(exdev) 동기화 후 push (post-receive / 원격 배포 트리거)
#
# ./server-dev/push-to-gitea.sh
# ./server-dev/push-to-gitea.sh --dry-run
#
# 흐름 (git_fetch_all.sh 와 유사):
# 1) Gitea(main) fetch
# 2) 원격 변경을 현재 소스에 merge
# 3) 로컬 변경 commit
# 4) Gitea push
#
# 공식 GitHub(KoreaExpressWayCorp) 에는 push 하지 않음.
# 공식 GitHub 반영은 ./git_fetch_all.sh 전용.
set -euo pipefail
@@ -17,7 +23,7 @@ for arg in "$@"; do
case "$arg" in
--dry-run) DRY_RUN=true ;;
-h|--help)
sed -n '2,10p' "$0"
sed -n '2,16p' "$0"
exit 0
;;
*)
@@ -65,6 +71,72 @@ reset_submodule_noise "exAiChatBot-chatbot2.0-agent" \
reset_submodule_noise "chatbotApi-chatbot2.0-agent" gradlew
reset_submodule_noise "kakaoChatbotSkill-chatbot2.0-agent" gradlew
working_tree_dirty() {
! git diff --quiet || ! git diff --cached --quiet
}
# Gitea 원격 변경을 현재 브랜치에 반영 (commit 전에 수행)
sync_from_gitea() {
log "Gitea fetch ${GITEA_REMOTE}/${GIT_BRANCH}..."
if ! git fetch "${GITEA_REMOTE}" "${GIT_BRANCH}"; then
warn "Gitea fetch 실패 — 네트워크/원격 확인 필요. 로컬만으로 계속합니다."
return 0
fi
if ! git rev-parse "${GITEA_REMOTE}/${GIT_BRANCH}" >/dev/null 2>&1; then
log "원격 브랜치 없음 — 최초 push 대상"
return 0
fi
local local_rev remote_rev
local_rev="$(git rev-parse HEAD)"
remote_rev="$(git rev-parse "${GITEA_REMOTE}/${GIT_BRANCH}")"
if [[ "${local_rev}" == "${remote_rev}" ]]; then
log "Gitea 와 HEAD 동일 (${local_rev:0:7}) — merge 생략"
return 0
fi
if git merge-base --is-ancestor "${remote_rev}" "${local_rev}" 2>/dev/null; then
log "로컬이 Gitea 보다 앞섬 (${local_rev:0:7} → 원격 ${remote_rev:0:7}) — merge 생략"
return 0
fi
log "Gitea 변경 반영: merge ${GITEA_REMOTE}/${GIT_BRANCH} (${remote_rev:0:7})"
if $DRY_RUN; then
log "[dry-run] git merge --no-edit ${GITEA_REMOTE}/${GIT_BRANCH}"
git log --oneline "HEAD..${GITEA_REMOTE}/${GIT_BRANCH}" | head -20 || true
return 0
fi
local stashed=false
if working_tree_dirty; then
log "로컬 미커밋 변경 stash 후 merge..."
git stash push -u -m "push-to-gitea: pre-merge $(date '+%Y-%m-%d %H:%M:%S')"
stashed=true
fi
if ! git merge --no-edit "${GITEA_REMOTE}/${GIT_BRANCH}"; then
if $stashed; then
warn "merge 충돌 — stash 는 유지됨. 해결 후: git stash pop && ./server-dev/push-to-gitea.sh"
else
warn "merge 충돌 — 해결 후 ./server-dev/push-to-gitea.sh 재실행"
fi
fail "Gitea merge 실패 (충돌 수동 해결 필요)"
fi
if $stashed; then
log "stash 복원..."
if ! git stash pop; then
fail "stash pop 충돌 — git status 확인 후 해결·재실행"
fi
fi
log "✅ Gitea 변경 merge 완료"
}
sync_from_gitea
if $DRY_RUN; then
log "[dry-run] git add / commit / push 생략"
git status -sb
@@ -80,7 +152,8 @@ git add local-dev/ server-dev/ scripts/ git_fetch_all.sh 2>/dev/null || true
git add start-all-server.sh stop-all-server.sh 2>/dev/null || true
for path in chatbotApp-chatbot2.0-agent chatbotApi-chatbot2.0-agent \
exAiChatBot-chatbot2.0-agent kakaoChatbotSkill-chatbot2.0-agent; do
exAiChatBot-chatbot2.0-agent kakaoChatbotSkill-chatbot2.0-agent \
chatbotDesktopApp-chatbot2.0; do
[[ -d "${path}" ]] && git add "${path}" 2>/dev/null || true
done
@@ -95,7 +168,6 @@ else
log "monorepo 스테이징 변경 없음"
fi
git fetch "${GITEA_REMOTE}" "${GIT_BRANCH}" 2>/dev/null || true
local_rev="$(git rev-parse HEAD)"
remote_rev=""
if git rev-parse "${GITEA_REMOTE}/${GIT_BRANCH}" >/dev/null 2>&1; then
@@ -107,6 +179,22 @@ if [[ -n "${remote_rev}" && "${local_rev}" == "${remote_rev}" ]]; then
exit 0
fi
# push 직전 한 번 더 원격 확인 (동시 배포 대비)
git fetch "${GITEA_REMOTE}" "${GIT_BRANCH}" 2>/dev/null || true
if git rev-parse "${GITEA_REMOTE}/${GIT_BRANCH}" >/dev/null 2>&1; then
remote_rev="$(git rev-parse "${GITEA_REMOTE}/${GIT_BRANCH}")"
if [[ "${local_rev}" != "${remote_rev}" ]] \
&& ! git merge-base --is-ancestor "${remote_rev}" "${local_rev}" 2>/dev/null; then
log "push 직전 원격 추가 커밋 발견 — 재 merge..."
if ! git merge --no-edit "${GITEA_REMOTE}/${GIT_BRANCH}"; then
fail "push 직전 merge 실패 — 충돌 해결 후 재실행"
fi
local_rev="$(git rev-parse HEAD)"
fi
fi
log "Gitea push ${GITEA_REMOTE} ${GIT_BRANCH} (${local_rev:0:7})..."
git push "${GITEA_REMOTE}" "${GIT_BRANCH}"
if ! git push "${GITEA_REMOTE}" "${GIT_BRANCH}"; then
fail "Gitea push 실패 — 다른 사람이 먼저 push 했을 수 있음. 재실행: ./server-dev/push-to-gitea.sh"
fi
log "✅ Gitea push 완료 — post-receive 배포 트리거됨"
+2 -2
View File
@@ -11,7 +11,7 @@
# ./server-dev/start-all-server.sh --skip-oracle # TROAD Oracle 기동 생략
# ./server-dev/start-all-server.sh --local # 로컬 Mac 개발 모드 (local-dev 위임)
#
# 로컬 Mac 실행 시: 현재 소스 → 개발서버(Gitea/exdev)만 반영·배포.
# 로컬 Mac 실행 시: Gitea(exdev) 최신 merge → 현재 소스 commit/push → 원격 배포.
# 공식 GitHub(KoreaExpressWayCorp) push 는 하지 않음 — ./git_fetch_all.sh 전용.
set -euo pipefail
@@ -173,7 +173,7 @@ build_desktop_app() {
}
deploy_from_local_mac() {
log "로컬 Mac → Gitea(exdev) push → chatbot.exdev.co.kr 배포"
log "로컬 Mac → Gitea(exdev) sync(merge) → push → chatbot.exdev.co.kr 배포"
log "공식 GitHub push 생략 (필요 시 ./git_fetch_all.sh)"
build_chatbot_app_for_deploy
push_source_to_gitea