[G1-Sync] Manual knowledge update
This commit is contained in:
@@ -0,0 +1,12 @@
|
||||
# 💻 코다리 스킬
|
||||
|
||||
_재사용 가능한 패턴 모음. memory.md는 모든 활동의 로그(append-only firehose),
|
||||
이 폴더는 **검증된 패턴만 골라낸 것**입니다. 각 `*.md` 파일은 다음 호출 시
|
||||
코다리의 system prompt에 자동 주입됩니다._
|
||||
|
||||
## 어떻게 채우나요?
|
||||
- 텔레그램에서 `/skill` (직전 산출물 자동 승격)
|
||||
- VS Code 명령 팔레트: `Connect AI: 방금 산출물 → 스킬로 저장`
|
||||
- 직접 이 폴더에 `<주제>.md` 파일을 만들어도 됩니다 (`# 제목` + 본문)
|
||||
|
||||
`README.md` 자체는 system prompt에 주입되지 않습니다.
|
||||
@@ -0,0 +1,27 @@
|
||||
<!-- version: lint_test_v1 -->
|
||||
# 🧪 lint_test — 자가 검증 + 결과 inject
|
||||
|
||||
코다리가 코드를 만든 직후 호출 → 결과가 다음 LLM 컨텍스트로 inject → 실패 시 자동 재시도.
|
||||
|
||||
## 동작
|
||||
1. `package.json` 의 `scripts.{typecheck, lint, test, build}` 자동 감지·실행
|
||||
2. scripts 없으면 직접:
|
||||
- `.ts/.tsx` 있고 `tsconfig.json` 있으면 → `npx tsc --noEmit`
|
||||
- `.py` 파일 있으면 → `python -m py_compile <각 파일>` (최대 30개)
|
||||
3. 마크다운 리포트 — 각 검사 통과/실패 + 실패 시 마지막 15줄
|
||||
|
||||
## 설정
|
||||
- `PROJECT_PATH`: 비우면 web_init 마지막 결과
|
||||
- `STRICT`: `true` 면 첫 실패에서 중단. 기본 `false` (전부 시도)
|
||||
|
||||
## 코다리 권장 흐름
|
||||
```
|
||||
1. <create_file 또는 edit_file>
|
||||
2. <run_command>python3 .../lint_test.py</run_command>
|
||||
3. 결과를 다음 답변 컨텍스트로 자동 받음
|
||||
4. 실패면 그 에러 보고 자동 수정 시도
|
||||
```
|
||||
|
||||
## 한계
|
||||
- `eslint --fix` 같은 자동 수정은 별도 — 도구가 단지 보고만 함
|
||||
- 단위 테스트 미통과 시 코드 수정 책임은 코다리에게
|
||||
@@ -0,0 +1,137 @@
|
||||
#!/usr/bin/env python3
|
||||
# version: lint_test_v1
|
||||
"""프로젝트 자가 검증 — 타입체크·테스트·린트 자동 실행 + 결과 요약.
|
||||
|
||||
코다리가 코드를 만든 직후 이 도구를 호출하면:
|
||||
1. package.json 의 scripts 자동 감지 (test/lint/typecheck/build)
|
||||
2. 또는 .ts/.tsx 파일 있으면 npx tsc --noEmit
|
||||
3. .py 파일 있으면 python -m py_compile <각 파일>
|
||||
4. 결과 마크다운 리포트
|
||||
|
||||
config:
|
||||
PROJECT_PATH — 검증할 프로젝트 (비우면 web_init 마지막 결과)
|
||||
STRICT — 'true' 면 첫 실패에서 멈춤. 기본 false (모두 시도)
|
||||
"""
|
||||
import os, sys, json, subprocess, glob
|
||||
|
||||
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
CONFIG = os.path.join(HERE, "lint_test.json")
|
||||
WEB_INIT_CFG = os.path.join(HERE, "web_init.json")
|
||||
|
||||
|
||||
def _log(msg, kind="info"):
|
||||
prefix = {"info": "🧪", "ok": "✅", "warn": "⚠️ ", "err": "❌", "step": "▸"}.get(kind, "•")
|
||||
print(f"{prefix} {msg}", file=sys.stderr, flush=True)
|
||||
|
||||
|
||||
def _load(p):
|
||||
if not os.path.exists(p):
|
||||
return {}
|
||||
try:
|
||||
with open(p, "r", encoding="utf-8") as f:
|
||||
return json.load(f)
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
|
||||
def _run(cmd, cwd, timeout=180):
|
||||
_log(f"$ {cmd}", "step")
|
||||
try:
|
||||
r = subprocess.run(cmd, shell=True, cwd=cwd, capture_output=True, text=True, timeout=timeout)
|
||||
return r.returncode, (r.stdout or "") + "\n" + (r.stderr or "")
|
||||
except subprocess.TimeoutExpired:
|
||||
return -1, f"⏱ Timeout ({timeout}s)"
|
||||
except Exception as e:
|
||||
return -2, str(e)
|
||||
|
||||
|
||||
def main():
|
||||
cfg = _load(CONFIG)
|
||||
init_cfg = _load(WEB_INIT_CFG)
|
||||
project = (cfg.get("PROJECT_PATH") or "").strip()
|
||||
if not project:
|
||||
project = (init_cfg.get("LAST_PROJECT") or "").strip()
|
||||
if not project:
|
||||
_log("PROJECT_PATH 비어있고 web_init 기록도 없음", "err")
|
||||
sys.exit(1)
|
||||
project = os.path.expanduser(project)
|
||||
if not os.path.isdir(project):
|
||||
_log(f"폴더 없음: {project}", "err")
|
||||
sys.exit(1)
|
||||
strict = str(cfg.get("STRICT", "")).lower() in ("true", "1", "yes")
|
||||
_log(f"검증 대상: {project}", "info")
|
||||
|
||||
results = [] # (label, code, output)
|
||||
|
||||
# 1) package.json scripts 자동 감지
|
||||
pkg_path = os.path.join(project, "package.json")
|
||||
if os.path.exists(pkg_path):
|
||||
try:
|
||||
with open(pkg_path, "r", encoding="utf-8") as f:
|
||||
pkg = json.load(f)
|
||||
scripts = pkg.get("scripts", {})
|
||||
for key in ["typecheck", "lint", "test", "build"]:
|
||||
if key in scripts:
|
||||
code, out = _run(f"npm run {key}", cwd=project, timeout=300)
|
||||
results.append((f"npm run {key}", code, out))
|
||||
if strict and code != 0:
|
||||
break
|
||||
except Exception as e:
|
||||
_log(f"package.json 파싱 실패: {e}", "warn")
|
||||
|
||||
# 2) scripts 없으면 직접 tsc/py_compile
|
||||
if not results:
|
||||
# TS/TSX
|
||||
ts_files = glob.glob(os.path.join(project, "**/*.ts"), recursive=True) + \
|
||||
glob.glob(os.path.join(project, "**/*.tsx"), recursive=True)
|
||||
ts_files = [f for f in ts_files if "node_modules" not in f and "dist" not in f]
|
||||
if ts_files:
|
||||
tsconfig = os.path.join(project, "tsconfig.json")
|
||||
if os.path.exists(tsconfig):
|
||||
code, out = _run("npx tsc --noEmit", cwd=project, timeout=180)
|
||||
results.append(("npx tsc --noEmit", code, out))
|
||||
# Python
|
||||
py_files = glob.glob(os.path.join(project, "**/*.py"), recursive=True)
|
||||
py_files = [f for f in py_files if "venv" not in f and ".venv" not in f and "__pycache__" not in f]
|
||||
if py_files:
|
||||
errs = []
|
||||
for pf in py_files[:30]: # 30개 cap
|
||||
code, out = _run(f"python3 -m py_compile {json.dumps(pf)}", cwd=project, timeout=10)
|
||||
if code != 0:
|
||||
errs.append((pf, out.strip()[:120]))
|
||||
if errs:
|
||||
results.append((f"py_compile ({len(errs)}/{len(py_files)} 실패)", 1, "\n".join(f"{f}: {e}" for f, e in errs[:10])))
|
||||
else:
|
||||
results.append((f"py_compile {len(py_files)} files", 0, "All OK"))
|
||||
|
||||
# 결과 리포트
|
||||
print()
|
||||
print(f"# 🧪 검증 결과 — {os.path.basename(project)}")
|
||||
print()
|
||||
if not results:
|
||||
print("⚠️ 실행할 검증 없음 (package.json scripts 없고 .ts/.py 파일도 없음)")
|
||||
return
|
||||
passed = sum(1 for _, c, _ in results if c == 0)
|
||||
print(f"**{passed}/{len(results)} 통과**\n")
|
||||
for label, code, out in results:
|
||||
icon = "✅" if code == 0 else "❌"
|
||||
print(f"## {icon} {label}")
|
||||
if code == 0:
|
||||
print(f"성공 (exit code 0)")
|
||||
else:
|
||||
print(f"실패 (exit code {code})")
|
||||
print()
|
||||
print("```")
|
||||
for line in out.strip().split("\n")[-15:]:
|
||||
print(line)
|
||||
print("```")
|
||||
print()
|
||||
if passed == len(results):
|
||||
print("> 🎉 모든 검증 통과. 안전하게 다음 단계로.")
|
||||
else:
|
||||
print(f"> ⚠️ {len(results) - passed}개 실패 — 위 출력 보고 수정 필요.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,69 @@
|
||||
<!-- version: pack_apply_v1 -->
|
||||
# 📋 pack_apply — 키트 한 명령으로 적용
|
||||
|
||||
두뇌에 주입된 템플릿 팩을 사용자 프로젝트에 자동 적용. 파일 복사 + 의존성 설치 + App.tsx 자동 업데이트.
|
||||
|
||||
## 사용
|
||||
설정 (pack_apply.json):
|
||||
- `KIT_NAME`: 'landing-kit' / 'portfolio-kit' / 'dashboard-kit' / 'mobile-kit'
|
||||
- `PROJECT_PATH`: 적용할 사용자 프로젝트 (비우면 web_init 결과 자동)
|
||||
|
||||
실행:
|
||||
```
|
||||
python3 pack_apply.py
|
||||
```
|
||||
|
||||
## 동작 (3단계)
|
||||
|
||||
1. **파일 복사**: 키트의 `files/` 폴더를 manifest의 `apply.copy_to` 경로로 (예: `src/components/`)
|
||||
2. **의존성 자동 설치**: manifest의 `apply.post_install` 명령 순차 실행
|
||||
- 예: `npm install lucide-react`
|
||||
- Expo: `npx expo install @react-navigation/native ...`
|
||||
3. **App.tsx 자동 업데이트**: manifest의 `apply.app_imports` + `app_body` 로 import + JSX 본문 추가
|
||||
|
||||
## 키트별 동작
|
||||
|
||||
### landing-kit (vite-react)
|
||||
- 복사: 6개 컴포넌트 → src/components/
|
||||
- 설치: lucide-react
|
||||
- App.tsx: Hero·Features·Pricing·FAQ·CTA·Footer 자동 배치
|
||||
|
||||
### portfolio-kit (vite-react)
|
||||
- 복사: 5개 컴포넌트
|
||||
- 설치: lucide-react
|
||||
- App.tsx: Nav·About·Work·Skills·Contact 자동 배치
|
||||
|
||||
### dashboard-kit (vite-react)
|
||||
- 복사: 5개 컴포넌트
|
||||
- 설치: lucide-react
|
||||
- App.tsx: `<DashboardLayout />` 한 줄로 풀스크린 대시보드
|
||||
|
||||
### mobile-kit (Expo)
|
||||
- 복사: App.tsx + screens/ 3개
|
||||
- 설치: @react-navigation/native + bottom-tabs + screens + safe-area-context
|
||||
- App.tsx: 기존 덮어쓰기 (Bottom Tab Navigator)
|
||||
|
||||
## 코다리 사용 예시
|
||||
|
||||
```
|
||||
사용자: "다이어트 SaaS 랜딩 만들어줘"
|
||||
|
||||
코다리:
|
||||
1. web_init (TEMPLATE=vite-react, PROJECT_NAME=diet-saas)
|
||||
2. pack_apply (KIT_NAME=landing-kit) ← 새 도구
|
||||
3. edit_file 로 텍스트만 다이어트 SaaS 카피로 교체
|
||||
4. web_preview (자동 dev server + 브라우저)
|
||||
|
||||
→ 5분 안에 완성 + 모바일·데스크탑 반응형 + Tailwind 컨벤션 일관
|
||||
```
|
||||
|
||||
## 안전장치
|
||||
- `KIT_NAME` 없거나 잘못되면 종료 + 사용 가능 키트 안내
|
||||
- 두뇌 폴더 자동 탐색 (~/.connect-ai-brain 또는 fallback 경로)
|
||||
- 파일 복사는 덮어쓰기 (사용자가 수정한 거 있으면 백업 권장)
|
||||
- 의존성 설치 실패해도 계속 진행 (warn만, 사용자 수동 가능)
|
||||
- App.tsx 패턴 매칭 실패 시 수동 안내
|
||||
|
||||
## 한계
|
||||
- App.tsx 자동 업데이트는 best-effort (단순 패턴 매칭). 복잡한 기존 App.tsx 는 수동 권장.
|
||||
- 키트가 React 외 (Vue·Svelte 등)에 적용되면 App.tsx 패턴 안 맞음 — 키트가 진짜 React 인지 manifest.base 로 검증 필요.
|
||||
@@ -0,0 +1,485 @@
|
||||
#!/usr/bin/env python3
|
||||
# version: pack_apply_v7
|
||||
"""두뇌의 템플릿 팩을 사용자 프로젝트에 한 번에 적용.
|
||||
|
||||
흐름:
|
||||
1. KIT_NAME — 두뇌의 40_템플릿/developer/<KIT_NAME>/ 폴더
|
||||
2. PROJECT_PATH — 적용할 사용자 프로젝트 (비우면 web_init 결과 자동)
|
||||
3. manifest.json 의 apply.{copy_to, post_install, app_imports, app_body} 사용:
|
||||
- files/* → PROJECT_PATH/copy_to/ (예: src/components/)
|
||||
- post_install: npm install / npx expo install 자동 실행
|
||||
- app_imports: App.tsx 또는 App.tsx 에 import 추가 + JSX 본문 자동
|
||||
4. 결과 출력 — 다음 단계 안내 (npm run dev 등)
|
||||
|
||||
이 도구가 코다리에게 주는 슈퍼파워:
|
||||
- 매뉴얼 cp + npm install 호출 안 해도 됨
|
||||
- 한 명령으로 "키트 적용 완료"
|
||||
- 의존성 누락 없음 (manifest 가 진실 소스)
|
||||
"""
|
||||
import os, sys, json, subprocess, shutil
|
||||
|
||||
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
CONFIG = os.path.join(HERE, "pack_apply.json")
|
||||
WEB_INIT_CFG = os.path.join(HERE, "web_init.json")
|
||||
|
||||
|
||||
def _log(msg, kind="info"):
|
||||
prefix = {"info": "📋", "ok": "✅", "warn": "⚠️ ", "err": "❌", "step": "▸"}.get(kind, "•")
|
||||
print(f"{prefix} {msg}", file=sys.stderr, flush=True)
|
||||
|
||||
|
||||
def _load(p):
|
||||
if not os.path.exists(p):
|
||||
return {}
|
||||
try:
|
||||
with open(p, "r", encoding="utf-8") as f:
|
||||
return json.load(f)
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
|
||||
def _run(cmd, cwd):
|
||||
_log(f"$ {cmd}", "step")
|
||||
r = subprocess.run(cmd, shell=True, cwd=cwd, capture_output=True, text=True, timeout=600)
|
||||
if r.returncode != 0:
|
||||
for line in (r.stderr or "").splitlines()[-8:]:
|
||||
_log(line, "warn")
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def _load_operator_credentials(brain_root):
|
||||
"""v7: 운영자(1인 기업)의 자격증명을 두뇌에서 로드. pack_apply 가 키트 HTML/JS
|
||||
의 placeholder 를 운영자 키로 자동 교체.
|
||||
지원 placeholder:
|
||||
__GEMINI_API_KEY__ → Gemini API 키
|
||||
__GEMINI_TEXT_MODEL__ → 텍스트 모델명
|
||||
__GEMINI_IMAGE_MODEL__ → 이미지 모델명
|
||||
__PAYPAL_CLIENT_ID__ → PayPal Live/Sandbox Client ID
|
||||
자격증명은 외부 연결 패널 (Connect AI) 에서 입력. 키트 사용자(고객) 는
|
||||
이 키를 볼 일이 없음 — 운영자가 빌드 시점에 박힘. """
|
||||
creds = {
|
||||
"__GEMINI_API_KEY__": "",
|
||||
"__GEMINI_TEXT_MODEL__": "gemini-3.1-flash-lite-preview",
|
||||
"__GEMINI_IMAGE_MODEL__": "gemini-3.1-flash-image-preview",
|
||||
"__PAYPAL_CLIENT_ID__": "",
|
||||
}
|
||||
business_tools = os.path.join(brain_root, "_company", "_agents", "business", "tools")
|
||||
# Gemini
|
||||
try:
|
||||
gp = os.path.join(business_tools, "gemini_account.json")
|
||||
if os.path.exists(gp):
|
||||
with open(gp, "r", encoding="utf-8") as f:
|
||||
g = json.load(f)
|
||||
if g.get("API_KEY"): creds["__GEMINI_API_KEY__"] = g["API_KEY"]
|
||||
if g.get("TEXT_MODEL"): creds["__GEMINI_TEXT_MODEL__"] = g["TEXT_MODEL"]
|
||||
if g.get("IMAGE_MODEL"): creds["__GEMINI_IMAGE_MODEL__"] = g["IMAGE_MODEL"]
|
||||
except Exception:
|
||||
pass
|
||||
# PayPal
|
||||
try:
|
||||
pp = os.path.join(business_tools, "paypal_revenue.json")
|
||||
if os.path.exists(pp):
|
||||
with open(pp, "r", encoding="utf-8") as f:
|
||||
p = json.load(f)
|
||||
if p.get("CLIENT_ID"): creds["__PAYPAL_CLIENT_ID__"] = p["CLIENT_ID"]
|
||||
except Exception:
|
||||
pass
|
||||
return creds
|
||||
|
||||
|
||||
def _inject_credentials(file_path, creds):
|
||||
"""v7: 텍스트 파일 안의 placeholder 를 운영자 자격증명으로 교체.
|
||||
바이너리·이미지 파일은 skip. UTF-8 못 읽으면 skip. """
|
||||
try:
|
||||
with open(file_path, "r", encoding="utf-8") as f:
|
||||
content = f.read()
|
||||
except (UnicodeDecodeError, IsADirectoryError):
|
||||
return False
|
||||
except Exception:
|
||||
return False
|
||||
replaced = False
|
||||
for placeholder, value in creds.items():
|
||||
if placeholder in content and value:
|
||||
content = content.replace(placeholder, value)
|
||||
replaced = True
|
||||
if replaced:
|
||||
try:
|
||||
with open(file_path, "w", encoding="utf-8") as f:
|
||||
f.write(content)
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
return False
|
||||
|
||||
|
||||
def _copy_tree(src_dir, dst_dir, creds=None):
|
||||
"""v2: 기존 파일이 있으면 .backup 자동 생성 (사용자 코드 보호).
|
||||
백업이 이미 있으면 덮어쓰지 않음 (멱등성).
|
||||
v7: creds 가 주어지면 복사 후 각 파일에서 placeholder 교체.
|
||||
v7.1: 자격증명 누락 placeholder 가 남으면 경고 (운영자 입력 유도)."""
|
||||
os.makedirs(dst_dir, exist_ok=True)
|
||||
copied = 0
|
||||
backed_up = []
|
||||
injected = 0
|
||||
missing_placeholders = {} # placeholder -> count
|
||||
for root, _dirs, files in os.walk(src_dir):
|
||||
rel = os.path.relpath(root, src_dir)
|
||||
target = os.path.join(dst_dir, rel) if rel != "." else dst_dir
|
||||
os.makedirs(target, exist_ok=True)
|
||||
for f in files:
|
||||
dst_path = os.path.join(target, f)
|
||||
if os.path.exists(dst_path):
|
||||
bk = dst_path + ".backup"
|
||||
if not os.path.exists(bk):
|
||||
try:
|
||||
shutil.copy2(dst_path, bk)
|
||||
backed_up.append(os.path.relpath(dst_path, dst_dir))
|
||||
except Exception:
|
||||
pass
|
||||
shutil.copy2(os.path.join(root, f), dst_path)
|
||||
copied += 1
|
||||
# v7: 자격증명 placeholder 자동 inline
|
||||
if creds and any(creds.values()):
|
||||
if _inject_credentials(dst_path, creds):
|
||||
injected += 1
|
||||
# v7.1: 남은 placeholder 스캔 (빈 자격증명 감지)
|
||||
if creds:
|
||||
try:
|
||||
with open(dst_path, "r", encoding="utf-8") as fh:
|
||||
body = fh.read()
|
||||
for ph, val in creds.items():
|
||||
if not val and ph in body:
|
||||
missing_placeholders[ph] = missing_placeholders.get(ph, 0) + 1
|
||||
except Exception:
|
||||
pass
|
||||
if backed_up:
|
||||
_log(f"기존 파일 {len(backed_up)}개 .backup 보존: {', '.join(backed_up[:3])}{' …' if len(backed_up) > 3 else ''}", "info")
|
||||
if injected:
|
||||
_log(f"🔐 운영자 자격증명 {injected}개 파일에 자동 inline (Gemini/PayPal placeholder 교체)", "ok")
|
||||
if missing_placeholders:
|
||||
guide = {
|
||||
"__GEMINI_API_KEY__": "Connect AI → 외부 연결 → ✨ Google Gemini → API Key 입력",
|
||||
"__PAYPAL_CLIENT_ID__": "Connect AI → 외부 연결 → 💰 PayPal → Client ID 입력",
|
||||
}
|
||||
_log("⚠️ 운영자 자격증명 누락 — 키트는 복사됐지만 실제 호출은 안 됨:", "warn")
|
||||
for ph in sorted(missing_placeholders):
|
||||
_log(f" • {ph} → {guide.get(ph, '외부 연결 패널에서 입력 필요')}", "warn")
|
||||
_log(" ↳ 키 입력 후 키트 다시 적용하면 자동 inline 됩니다.", "warn")
|
||||
return copied
|
||||
|
||||
|
||||
def _find_app_file(project_path):
|
||||
"""vite/next 모두 커버. src/App.tsx 우선, 없으면 App.tsx (expo)."""
|
||||
for cand in ["src/App.tsx", "App.tsx", "src/app/page.tsx", "app/page.tsx"]:
|
||||
p = os.path.join(project_path, cand)
|
||||
if os.path.exists(p):
|
||||
return p
|
||||
return None
|
||||
|
||||
|
||||
def _update_app_tsx(app_path, imports, body):
|
||||
"""App.tsx 를 깨끗하게 새로 작성. 원본은 .backup 으로 보존.
|
||||
v2: regex 부분 매칭으로 옛 JSX 가 남던 사고 → 전체 덮어쓰기 + 백업 방식으로 변경."""
|
||||
try:
|
||||
with open(app_path, "r", encoding="utf-8") as f:
|
||||
original = f.read()
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
# 이미 키트 적용됐으면 skip
|
||||
if all(f"from './components/{n}'" in original for n in imports):
|
||||
return False
|
||||
|
||||
# 백업 — 사용자가 손댄 거 잃지 않게
|
||||
try:
|
||||
backup_path = app_path + ".backup"
|
||||
if not os.path.exists(backup_path):
|
||||
with open(backup_path, "w", encoding="utf-8") as f:
|
||||
f.write(original)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 새 App.tsx — 깨끗한 최소 버전
|
||||
import_lines = "\n".join([f"import {n} from './components/{n}'" for n in imports])
|
||||
new_content = f"""{import_lines}
|
||||
|
||||
export default function App() {{
|
||||
return (
|
||||
<main className="min-h-screen bg-white text-gray-900">
|
||||
{body}
|
||||
</main>
|
||||
);
|
||||
}}
|
||||
"""
|
||||
try:
|
||||
with open(app_path, "w", encoding="utf-8") as f:
|
||||
f.write(new_content)
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def _find_brain_root():
|
||||
"""두뇌 폴더 자동 탐색 (한국어 폴더명 포함).
|
||||
|
||||
v4: BRAIN_ROOT 환경변수가 가장 강함 (Connect AI 익스텐션이 직접 지정).
|
||||
이전엔 ~/.connect-ai-brain 가 빈 폴더로 존재만 해도 우선 매칭돼서
|
||||
실제 사용자 두뇌(~/Downloads/지식메모리) 의 키트를 못 찾던 사고 차단.
|
||||
"""
|
||||
env = os.environ.get("BRAIN_ROOT", "").strip()
|
||||
if env:
|
||||
ep = os.path.expanduser(env)
|
||||
if os.path.exists(ep):
|
||||
return ep
|
||||
cands = [
|
||||
os.path.expanduser("~/Downloads/지식메모리"),
|
||||
os.path.expanduser("~/.connect-ai-brain"),
|
||||
os.path.expanduser("~/.connect-ai-brain-imported"),
|
||||
]
|
||||
for c in cands:
|
||||
if os.path.exists(c):
|
||||
return c
|
||||
return cands[0] # 첫 번째 fallback
|
||||
|
||||
|
||||
def _list_kits(brain_root):
|
||||
"""developer 카테고리의 모든 키트와 manifest 반환."""
|
||||
tdir = os.path.join(brain_root, "40_템플릿", "developer")
|
||||
if not os.path.exists(tdir):
|
||||
return []
|
||||
kits = []
|
||||
for name in os.listdir(tdir):
|
||||
d = os.path.join(tdir, name)
|
||||
if not os.path.isdir(d):
|
||||
continue
|
||||
mp = os.path.join(d, "manifest.json")
|
||||
if not os.path.exists(mp):
|
||||
continue
|
||||
try:
|
||||
with open(mp, "r", encoding="utf-8") as f:
|
||||
manifest = json.load(f)
|
||||
kits.append({"name": name, "manifest": manifest})
|
||||
except Exception:
|
||||
pass
|
||||
return kits
|
||||
|
||||
|
||||
def _score_kit(manifest, intent_text):
|
||||
"""매니페스트 vs 사용자 의도(intent_text) 매칭 점수.
|
||||
keywords + name + description 단어 매칭. 한국어·영어 모두."""
|
||||
if not intent_text:
|
||||
return 0
|
||||
haystack = " ".join([
|
||||
manifest.get("name", ""),
|
||||
manifest.get("description", ""),
|
||||
" ".join(manifest.get("keywords") or []),
|
||||
manifest.get("category", ""),
|
||||
]).lower()
|
||||
intent_lc = intent_text.lower()
|
||||
score = 0
|
||||
# keywords 직접 매칭 (높은 가중치)
|
||||
for kw in (manifest.get("keywords") or []):
|
||||
if kw.lower() in intent_lc:
|
||||
score += 10
|
||||
# name 자체가 의도에 있으면 (예: "landing-kit" → "landing")
|
||||
for token in manifest.get("name", "").split():
|
||||
if len(token) >= 3 and token.lower() in intent_lc:
|
||||
score += 5
|
||||
# 카테고리
|
||||
if (manifest.get("category", "").lower() or "") in intent_lc:
|
||||
score += 3
|
||||
return score
|
||||
|
||||
|
||||
def _autodetect_kit(brain_root, intent_text):
|
||||
"""사용자 의도에서 가장 적합한 키트 자동 추론. (kit_name, score, alternatives) 반환."""
|
||||
kits = _list_kits(brain_root)
|
||||
if not kits:
|
||||
return None, 0, []
|
||||
scored = [(k["name"], _score_kit(k["manifest"], intent_text), k["manifest"].get("description", "")) for k in kits]
|
||||
scored.sort(key=lambda x: -x[1])
|
||||
if scored[0][1] == 0:
|
||||
# 매치 0 — fallback: 가장 일반적인 landing-kit
|
||||
for k in kits:
|
||||
if k["name"] == "landing-kit":
|
||||
return "landing-kit", 0, scored[:3]
|
||||
return kits[0]["name"], 0, scored[:3]
|
||||
return scored[0][0], scored[0][1], scored[:3]
|
||||
|
||||
|
||||
def _parse_cli_args():
|
||||
"""v4: 로컬 LLM 이 CLI 인자로 호출하는 패턴도 지원.
|
||||
`--kit landing-kit --user-intent "..." --project /path` 또는
|
||||
환경변수 KIT_NAME / USER_INTENT / PROJECT_PATH."""
|
||||
out = {}
|
||||
args = sys.argv[1:]
|
||||
i = 0
|
||||
aliases = {
|
||||
"--kit": "KIT_NAME", "--kit-name": "KIT_NAME",
|
||||
"--user-intent": "USER_INTENT", "--intent": "USER_INTENT",
|
||||
"--project": "PROJECT_PATH", "--project-path": "PROJECT_PATH",
|
||||
"--brain-root": "BRAIN_ROOT", "--brain": "BRAIN_ROOT",
|
||||
}
|
||||
while i < len(args):
|
||||
a = args[i]
|
||||
if a in aliases and i + 1 < len(args):
|
||||
out[aliases[a]] = args[i + 1]
|
||||
i += 2
|
||||
elif "=" in a and a.startswith("--"):
|
||||
k, v = a[2:].split("=", 1)
|
||||
key = aliases.get("--" + k)
|
||||
if key:
|
||||
out[key] = v
|
||||
i += 1
|
||||
else:
|
||||
i += 1
|
||||
for k in ("KIT_NAME", "USER_INTENT", "PROJECT_PATH", "BRAIN_ROOT"):
|
||||
if k in os.environ and os.environ[k].strip():
|
||||
out.setdefault(k, os.environ[k])
|
||||
return out
|
||||
|
||||
|
||||
def main():
|
||||
cfg = _load(CONFIG)
|
||||
init_cfg = _load(WEB_INIT_CFG)
|
||||
|
||||
cli = _parse_cli_args()
|
||||
for k, v in cli.items():
|
||||
if v and str(v).strip():
|
||||
cfg[k] = v
|
||||
|
||||
kit_name = (cfg.get("KIT_NAME") or "").strip()
|
||||
user_intent = (cfg.get("USER_INTENT") or "").strip()
|
||||
|
||||
# v5: CLI --brain-root 가 있으면 env 처럼 작동시켜 _find_brain_root 우선순위 활용
|
||||
cli_brain = cli.get("BRAIN_ROOT", "").strip() if cli else ""
|
||||
if cli_brain:
|
||||
os.environ["BRAIN_ROOT"] = cli_brain
|
||||
# 두뇌 폴더 찾기 (자동 추론에도 필요)
|
||||
brain_root = _find_brain_root()
|
||||
|
||||
# v3: KIT_NAME 비어있고 USER_INTENT 있으면 자동 매칭
|
||||
selection_card = ""
|
||||
if not kit_name and user_intent:
|
||||
detected, score, alts = _autodetect_kit(brain_root, user_intent)
|
||||
if detected:
|
||||
kit_name = detected
|
||||
_log(f"자동 추론 → '{kit_name}' (매칭 점수 {score})", "info")
|
||||
if score == 0:
|
||||
_log(" ⚠️ 사용자 의도와 명확한 매칭 없음. 가장 일반적인 키트로 fallback.", "warn")
|
||||
# 시각 카드 (stdout에 마크다운 — 채팅창에 렌더링됨)
|
||||
card_lines = [
|
||||
"",
|
||||
"## 🎯 키트 자동 선택",
|
||||
"",
|
||||
f"> 사용자 의도: _\"{user_intent}\"_",
|
||||
"",
|
||||
"| 순위 | 키트 | 매칭 점수 | 비고 |",
|
||||
"|---|---|---|---|",
|
||||
]
|
||||
for i, (n, s, desc) in enumerate(alts):
|
||||
marker = "**⭐ 선택**" if n == kit_name else ""
|
||||
d_short = (desc[:50] + "…") if len(desc) > 50 else desc
|
||||
card_lines.append(f"| {i+1} | `{n}` | **{s}** | {marker} {d_short} |")
|
||||
if score == 0:
|
||||
card_lines.append("")
|
||||
card_lines.append("⚠️ _명확한 매칭 없음 — fallback으로 가장 일반적인 키트 선택._")
|
||||
card_lines.append("")
|
||||
card_lines.append("> 💡 다른 키트로 바꾸려면 `pack_apply` 를 `KIT_NAME=<원하는 키트>` 로 다시 실행.")
|
||||
card_lines.append("")
|
||||
selection_card = "\n".join(card_lines)
|
||||
|
||||
if not kit_name:
|
||||
kits = _list_kits(brain_root)
|
||||
avail = ", ".join([f"'{k['name']}'" for k in kits]) or "(두뇌에 키트 없음 — EZER 에서 먼저 주입)"
|
||||
_log(f"KIT_NAME 비어있고 USER_INTENT 도 없음.", "err")
|
||||
_log(f" 방법 1: KIT_NAME 명시 → {avail}", "info")
|
||||
_log(f" 방법 2: USER_INTENT 에 '다이어트 SaaS 랜딩' 같은 자연어 입력 → 자동 추론", "info")
|
||||
sys.exit(1)
|
||||
|
||||
project = (cfg.get("PROJECT_PATH") or "").strip()
|
||||
if not project:
|
||||
project = (init_cfg.get("LAST_PROJECT") or "").strip()
|
||||
if not project:
|
||||
_log("PROJECT_PATH 비어있고 web_init 기록도 없음", "err")
|
||||
sys.exit(1)
|
||||
project = os.path.expanduser(project)
|
||||
if not os.path.isdir(project):
|
||||
_log(f"프로젝트 폴더 없음: {project}", "err")
|
||||
sys.exit(1)
|
||||
|
||||
kit_dir = os.path.join(brain_root, "40_템플릿", "developer", kit_name)
|
||||
if not os.path.exists(kit_dir):
|
||||
_log(f"키트 없음: {kit_dir}", "err")
|
||||
_log(f"먼저 EZER Pack Vault 에서 '{kit_name}' 주입하세요.", "info")
|
||||
sys.exit(1)
|
||||
|
||||
manifest_path = os.path.join(kit_dir, "manifest.json")
|
||||
if not os.path.exists(manifest_path):
|
||||
_log(f"manifest 없음: {manifest_path}", "err")
|
||||
sys.exit(1)
|
||||
with open(manifest_path, "r", encoding="utf-8") as f:
|
||||
manifest = json.load(f)
|
||||
|
||||
apply = manifest.get("apply", {})
|
||||
copy_to = apply.get("copy_to", "src/components/")
|
||||
post_install = apply.get("post_install", [])
|
||||
app_imports = apply.get("app_imports", [])
|
||||
app_body = apply.get("app_body", "")
|
||||
|
||||
_log(f"키트: {manifest.get('name', kit_name)} → {project}", "info")
|
||||
_log(f"기반: {manifest.get('base', '?')}", "info")
|
||||
|
||||
# v7: 운영자 자격증명 로드 (Gemini/PayPal placeholder 자동 inline)
|
||||
creds = _load_operator_credentials(brain_root)
|
||||
|
||||
# 1) 파일 복사 (+ placeholder 교체)
|
||||
src_files = os.path.join(kit_dir, "files")
|
||||
dst_files = os.path.join(project, copy_to.lstrip("./"))
|
||||
if not os.path.exists(src_files):
|
||||
_log("키트의 files/ 폴더 없음 — 파일 복사 스킵", "warn")
|
||||
else:
|
||||
n = _copy_tree(src_files, dst_files, creds=creds)
|
||||
_log(f"{n}개 파일 복사 → {dst_files}", "ok")
|
||||
|
||||
# 2) 의존성 자동 설치
|
||||
if post_install:
|
||||
_log(f"의존성 {len(post_install)}개 설치 중...", "info")
|
||||
for cmd in post_install:
|
||||
ok = _run(cmd, cwd=project)
|
||||
if not ok:
|
||||
_log(f"부가 명령 실패: {cmd} — 계속 진행", "warn")
|
||||
|
||||
# 3) App.tsx 자동 업데이트 (best-effort)
|
||||
if app_imports:
|
||||
app_file = _find_app_file(project)
|
||||
if app_file:
|
||||
changed = _update_app_tsx(app_file, app_imports,
|
||||
app_body or "\n".join([f"<{n} />" for n in app_imports]))
|
||||
if changed:
|
||||
_log(f"App.tsx 자동 업데이트: {app_file}", "ok")
|
||||
else:
|
||||
_log(f"App.tsx 이미 정정됨 또는 패턴 매칭 실패 — 수동 확인: {app_file}", "warn")
|
||||
else:
|
||||
_log("App.tsx 못 찾음 — 수동으로 import + JSX 추가 필요", "warn")
|
||||
|
||||
# 결과 — stdout 으로 마크다운 (채팅창 렌더링)
|
||||
if selection_card:
|
||||
print(selection_card)
|
||||
print()
|
||||
print(f"## ✅ 적용 완료: `{manifest.get('name', kit_name)}`")
|
||||
print()
|
||||
print(f"- **위치**: `{project}`")
|
||||
print(f"- **기반**: {manifest.get('base', '?')}")
|
||||
if "expo" in (manifest.get("base", "").lower()):
|
||||
print(f"- **실행**: `cd {project} && npm start` → 폰에 Expo Go 깔고 QR 스캔")
|
||||
else:
|
||||
print(f"- **실행**: `cd {project} && npm run dev` → http://localhost:5173")
|
||||
print()
|
||||
_log(f"적용 완료: {kit_name}", "ok")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,37 @@
|
||||
<!-- version: pwa_setup_v1 -->
|
||||
# 💻 PWA 자동 셋업 — 웹사이트 → 모바일 앱처럼
|
||||
|
||||
기존 웹 프로젝트를 PWA(Progressive Web App)로 변환. 사용자가 폰에서 "홈 화면에 추가" 누르면 풀스크린 앱처럼 작동.
|
||||
|
||||
## 자동 생성 파일
|
||||
- `public/manifest.json` — 앱 메타 (이름·아이콘·테마색)
|
||||
- `public/icon-192.svg` + `icon-512.svg` — 이모지 기반 라운드 아이콘
|
||||
- `public/sw.js` — 서비스 워커 (오프라인 캐싱)
|
||||
- `index.html`에 자동 주입: meta·link·script
|
||||
|
||||
## 설정
|
||||
- `PROJECT_PATH`: 비우면 web_init이 마지막에 만든 프로젝트 자동 사용
|
||||
- `APP_NAME`: 앱 이름 (홈화면 라벨)
|
||||
- `APP_SHORT_NAME`: 12자 이하 짧은 이름
|
||||
- `THEME_COLOR`: 상단 바 색 (예: `#667eea`)
|
||||
- `BACKGROUND_COLOR`: 스플래시 배경 (예: `#ffffff`)
|
||||
- `ICON_EMOJI`: 아이콘에 쓸 이모지 (예: `📚`)
|
||||
|
||||
## 사용 흐름
|
||||
```
|
||||
1. web_init으로 사이트 만듦 (vite-react·astro 등)
|
||||
2. pwa_setup 실행 → manifest·아이콘·sw 생성
|
||||
3. 배포 (Vercel·Netlify) 또는 로컬 dev server
|
||||
4. 폰 브라우저로 URL 접속
|
||||
5. iOS Safari: 공유 → 홈 화면에 추가
|
||||
Android Chrome: ⋮ → 홈 화면에 추가
|
||||
6. 홈 화면 아이콘 클릭 → 풀스크린 앱
|
||||
```
|
||||
|
||||
## Next.js 사용자
|
||||
Next.js 13+ App Router 는 `app/layout.tsx`의 `export const metadata` 에 PWA 정보를 넣어야 함. 도구가 자동 감지하면 안내 메시지 표시.
|
||||
|
||||
## 한계
|
||||
- 진짜 네이티브 기능 (푸시 알림·블루투스·카메라) 은 PWA로 부분 지원
|
||||
- 복잡한 모바일 앱은 Expo 권장
|
||||
- 아이콘은 SVG로 생성 (PNG 변환 필요시 ImageMagick 또는 사용자 디자인)
|
||||
@@ -0,0 +1,243 @@
|
||||
#!/usr/bin/env python3
|
||||
# version: pwa_setup_v1
|
||||
"""웹사이트를 PWA(모바일 앱처럼)로 변환.
|
||||
|
||||
config:
|
||||
PROJECT_PATH — 대상 폴더 (web_init 결과 자동 사용)
|
||||
APP_NAME — 앱 이름 (홈화면에 표시)
|
||||
APP_SHORT_NAME — 짧은 이름 (12자 이하)
|
||||
THEME_COLOR — 상단 바 색 (예: #667eea)
|
||||
BACKGROUND_COLOR — 스플래시 배경
|
||||
ICON_EMOJI — 아이콘 자동 생성에 쓸 이모지 (예: 📚)
|
||||
|
||||
생성 파일:
|
||||
public/manifest.json — PWA 메타
|
||||
public/sw.js — 서비스 워커 (오프라인)
|
||||
public/icon-192.png + icon-512.png — 자동 생성 (이모지 기반)
|
||||
index.html (또는 public/index.html) — meta·link·script 자동 주입
|
||||
|
||||
설치 방법 (사용자):
|
||||
사파리·크롬에서 사이트 접속 → "홈 화면에 추가" → 풀스크린 앱 작동
|
||||
"""
|
||||
import os, sys, json, base64, re
|
||||
|
||||
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
CONFIG = os.path.join(HERE, "pwa_setup.json")
|
||||
WEB_INIT_CONFIG = os.path.join(HERE, "web_init.json")
|
||||
|
||||
|
||||
def _log(msg, kind="info"):
|
||||
prefix = {"info": "💻", "ok": "✅", "warn": "⚠️ ", "err": "❌", "step": "▸"}.get(kind, "•")
|
||||
print(f"{prefix} {msg}", file=sys.stderr, flush=True)
|
||||
|
||||
|
||||
def _load(p):
|
||||
if os.path.exists(p):
|
||||
try:
|
||||
with open(p, "r", encoding="utf-8") as f:
|
||||
return json.load(f)
|
||||
except Exception:
|
||||
pass
|
||||
return {}
|
||||
|
||||
|
||||
def _generate_icon_svg(emoji, bg_color, size=512):
|
||||
"""이모지 기반 SVG 아이콘 생성 (라운드 코너 + 그라데이션)."""
|
||||
return f'''<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="{size}" height="{size}" viewBox="0 0 {size} {size}">
|
||||
<defs>
|
||||
<linearGradient id="g" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stop-color="{bg_color}" stop-opacity="1"/>
|
||||
<stop offset="100%" stop-color="{bg_color}" stop-opacity="0.7"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect width="{size}" height="{size}" rx="{size//8}" ry="{size//8}" fill="url(#g)"/>
|
||||
<text x="50%" y="50%" font-size="{int(size*0.55)}" text-anchor="middle" dominant-baseline="central" font-family="-apple-system,BlinkMacSystemFont,Segoe UI,sans-serif">{emoji}</text>
|
||||
</svg>
|
||||
'''
|
||||
|
||||
|
||||
def _find_html(project_path):
|
||||
"""프로젝트의 메인 HTML 찾기 (index.html, public/index.html, app/layout.tsx 등)."""
|
||||
candidates = [
|
||||
os.path.join(project_path, "index.html"),
|
||||
os.path.join(project_path, "public", "index.html"),
|
||||
os.path.join(project_path, "public", "manifest.json"), # 이미 있으면 표시
|
||||
]
|
||||
for c in candidates:
|
||||
if os.path.exists(c):
|
||||
return c
|
||||
return None
|
||||
|
||||
|
||||
def _find_public_dir(project_path):
|
||||
"""public 디렉토리 찾기 또는 만들기."""
|
||||
public = os.path.join(project_path, "public")
|
||||
if os.path.exists(public):
|
||||
return public
|
||||
# Vite는 public/, Next.js도 public/
|
||||
os.makedirs(public, exist_ok=True)
|
||||
return public
|
||||
|
||||
|
||||
def main():
|
||||
cfg = _load(CONFIG)
|
||||
init_cfg = _load(WEB_INIT_CONFIG)
|
||||
|
||||
project_path = (cfg.get("PROJECT_PATH") or "").strip()
|
||||
if not project_path:
|
||||
project_path = (init_cfg.get("LAST_PROJECT") or "").strip()
|
||||
if not project_path:
|
||||
_log("PROJECT_PATH가 비어있고 web_init 기록도 없음", "err")
|
||||
sys.exit(1)
|
||||
|
||||
project_path = os.path.expanduser(project_path)
|
||||
if not os.path.isdir(project_path):
|
||||
_log(f"폴더 없음: {project_path}", "err")
|
||||
sys.exit(1)
|
||||
|
||||
app_name = (cfg.get("APP_NAME") or "").strip() or os.path.basename(project_path)
|
||||
short_name = (cfg.get("APP_SHORT_NAME") or "").strip() or app_name[:12]
|
||||
theme = (cfg.get("THEME_COLOR") or "").strip() or "#667eea"
|
||||
bg = (cfg.get("BACKGROUND_COLOR") or "").strip() or "#ffffff"
|
||||
icon_emoji = (cfg.get("ICON_EMOJI") or "").strip() or "✦"
|
||||
|
||||
_log(f"PWA 셋업 시작 → {project_path}", "info")
|
||||
|
||||
public = _find_public_dir(project_path)
|
||||
|
||||
# 1. manifest.json
|
||||
manifest = {
|
||||
"name": app_name,
|
||||
"short_name": short_name,
|
||||
"description": f"{app_name} — Connect AI로 만들어진 PWA",
|
||||
"start_url": "/",
|
||||
"display": "standalone",
|
||||
"orientation": "portrait",
|
||||
"theme_color": theme,
|
||||
"background_color": bg,
|
||||
"icons": [
|
||||
{"src": "/icon-192.svg", "sizes": "192x192", "type": "image/svg+xml", "purpose": "any maskable"},
|
||||
{"src": "/icon-512.svg", "sizes": "512x512", "type": "image/svg+xml", "purpose": "any maskable"},
|
||||
],
|
||||
}
|
||||
manifest_path = os.path.join(public, "manifest.json")
|
||||
with open(manifest_path, "w", encoding="utf-8") as f:
|
||||
json.dump(manifest, f, indent=2, ensure_ascii=False)
|
||||
_log(f"manifest.json 생성: {manifest_path}", "ok")
|
||||
|
||||
# 2. 아이콘 (SVG로 — 모든 기기에서 잘 보임 + 작은 사이즈)
|
||||
for size in (192, 512):
|
||||
icon_path = os.path.join(public, f"icon-{size}.svg")
|
||||
with open(icon_path, "w", encoding="utf-8") as f:
|
||||
f.write(_generate_icon_svg(icon_emoji, theme, size))
|
||||
_log(f"icon-{size}.svg 생성", "ok")
|
||||
|
||||
# 3. service worker
|
||||
sw_path = os.path.join(public, "sw.js")
|
||||
sw_content = f'''// Connect AI PWA Service Worker
|
||||
// version: pwa_v1 — auto-generated
|
||||
const CACHE = "{short_name}-v1";
|
||||
const ASSETS = ["/", "/manifest.json", "/icon-192.svg", "/icon-512.svg"];
|
||||
|
||||
self.addEventListener("install", e => {{
|
||||
e.waitUntil(caches.open(CACHE).then(c => c.addAll(ASSETS).catch(()=>{{/* offline OK */}})));
|
||||
self.skipWaiting();
|
||||
}});
|
||||
self.addEventListener("activate", e => {{
|
||||
e.waitUntil(caches.keys().then(keys =>
|
||||
Promise.all(keys.filter(k => k !== CACHE).map(k => caches.delete(k)))
|
||||
));
|
||||
self.clients.claim();
|
||||
}});
|
||||
self.addEventListener("fetch", e => {{
|
||||
const req = e.request;
|
||||
if (req.method !== "GET") return;
|
||||
e.respondWith(
|
||||
caches.match(req).then(hit => hit || fetch(req).then(res => {{
|
||||
const copy = res.clone();
|
||||
caches.open(CACHE).then(c => c.put(req, copy)).catch(()=>{{/* ignore */}});
|
||||
return res;
|
||||
}}).catch(() => caches.match("/") || new Response("offline", {{ status: 503 }})))
|
||||
);
|
||||
}});
|
||||
'''
|
||||
with open(sw_path, "w", encoding="utf-8") as f:
|
||||
f.write(sw_content)
|
||||
_log(f"sw.js 생성: {sw_path}", "ok")
|
||||
|
||||
# 4. HTML에 meta + link + script 주입
|
||||
# 후보: index.html, public/index.html, app/layout.tsx (Next.js)
|
||||
html_candidates = [
|
||||
os.path.join(project_path, "index.html"),
|
||||
os.path.join(project_path, "public", "index.html"),
|
||||
]
|
||||
html_file = None
|
||||
for c in html_candidates:
|
||||
if os.path.exists(c):
|
||||
html_file = c
|
||||
break
|
||||
|
||||
pwa_head = f'''
|
||||
<!-- PWA: 자동 생성 — pwa_setup.py -->
|
||||
<meta name="theme-color" content="{theme}">
|
||||
<link rel="manifest" href="/manifest.json">
|
||||
<link rel="icon" type="image/svg+xml" href="/icon-512.svg">
|
||||
<link rel="apple-touch-icon" href="/icon-512.svg">
|
||||
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||
<meta name="apple-mobile-web-app-title" content="{short_name}">
|
||||
<meta name="mobile-web-app-capable" content="yes">'''
|
||||
pwa_script = '''
|
||||
<script>
|
||||
if ("serviceWorker" in navigator) {
|
||||
window.addEventListener("load", () => {
|
||||
navigator.serviceWorker.register("/sw.js").catch(()=>{/* ignore */});
|
||||
});
|
||||
}
|
||||
</script>'''
|
||||
|
||||
if html_file:
|
||||
with open(html_file, "r", encoding="utf-8") as f:
|
||||
html = f.read()
|
||||
if "manifest.json" in html:
|
||||
_log(f"HTML 이미 PWA 메타 있음. 스킵: {html_file}", "warn")
|
||||
else:
|
||||
# </head> 직전에 head 삽입
|
||||
new_html = re.sub(r"</head>", pwa_head + "\n </head>", html, count=1, flags=re.IGNORECASE)
|
||||
# </body> 직전에 script 삽입
|
||||
new_html = re.sub(r"</body>", pwa_script + "\n </body>", new_html, count=1, flags=re.IGNORECASE)
|
||||
with open(html_file, "w", encoding="utf-8") as f:
|
||||
f.write(new_html)
|
||||
_log(f"HTML 메타·script 주입: {html_file}", "ok")
|
||||
else:
|
||||
# Next.js app/layout.tsx 안내만
|
||||
next_layout = os.path.join(project_path, "src", "app", "layout.tsx")
|
||||
next_layout_alt = os.path.join(project_path, "app", "layout.tsx")
|
||||
layout = next_layout if os.path.exists(next_layout) else (next_layout_alt if os.path.exists(next_layout_alt) else None)
|
||||
if layout:
|
||||
_log(f"Next.js 감지 — {layout} 의 metadata에 manifest 추가하세요", "warn")
|
||||
_log(f" export const metadata = {{ ... manifest: '/manifest.json' }}", "info")
|
||||
else:
|
||||
_log("HTML 파일을 찾지 못함. PWA 메타·script 수동 추가 필요.", "warn")
|
||||
_log(f"head: {pwa_head.strip()}", "info")
|
||||
_log(f"body: {pwa_script.strip()}", "info")
|
||||
|
||||
# 결과 저장
|
||||
cfg["LAST_PROJECT"] = project_path
|
||||
cfg["LAST_APP_NAME"] = app_name
|
||||
cfg["LAST_THEME"] = theme
|
||||
with open(CONFIG, "w", encoding="utf-8") as f:
|
||||
json.dump(cfg, f, indent=2, ensure_ascii=False)
|
||||
|
||||
print()
|
||||
_log(f"PWA 셋업 완료: {app_name}", "ok")
|
||||
_log("테스트:", "info")
|
||||
_log(" 1. dev server 또는 배포된 URL을 모바일 브라우저로 열기", "info")
|
||||
_log(" 2. iOS Safari: 공유 → 홈 화면에 추가", "info")
|
||||
_log(" 3. Android Chrome: 우측 ⋮ → 홈 화면에 추가", "info")
|
||||
_log(" 4. 풀스크린·아이콘·오프라인 작동 확인", "info")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,42 @@
|
||||
<!-- version: web_init_v1 -->
|
||||
# 💻 웹·모바일 프로젝트 자동 시작
|
||||
|
||||
5개 템플릿 중 골라서 한 번에 프로젝트 폴더 + 의존성 설치 + 첫 실행 가능한 상태로.
|
||||
|
||||
## 템플릿
|
||||
|
||||
| 템플릿 | 용도 | 의존성 | 첫 실행 |
|
||||
|---|---|---|---|
|
||||
| **vite-react** ⭐ 추천 | SPA·대시보드·SaaS UI | Node·npm | `npm run dev` → :5173 |
|
||||
| **nextjs** | full-stack·SEO·서버 컴포넌트 | Node·npm | `npm run dev` → :3000 |
|
||||
| **astro** | 블로그·콘텐츠·랜딩 | Node·npm | `npm run dev` → :4321 |
|
||||
| **expo** | 진짜 모바일 앱 (iOS/Android) | Node·npm·Expo Go | `npm start` → QR |
|
||||
| **vanilla** | 단순 HTML/CSS/JS | 없음 | `python3 -m http.server` |
|
||||
|
||||
## 사용법
|
||||
|
||||
설정 (web_init.json):
|
||||
- `TEMPLATE`: 위 5개 중 하나
|
||||
- `PROJECT_NAME`: 영문·숫자·하이픈 (예: `my-blog`)
|
||||
- `OUTPUT_DIR`: 비우면 `~/connect-ai-projects/`
|
||||
|
||||
실행:
|
||||
```
|
||||
python3 web_init.py
|
||||
```
|
||||
|
||||
## 어떤 걸 골라야 하나
|
||||
|
||||
- **이걸로 시작:** vite-react (SPA·대시보드·내부 도구)
|
||||
- **블로그·기업 사이트:** astro
|
||||
- **풀스택 (DB·API):** nextjs
|
||||
- **모바일 앱:** expo (PWA로 충분하면 vite-react)
|
||||
- **HTML 한 페이지:** vanilla
|
||||
|
||||
## 다음 단계
|
||||
|
||||
셋업 후 코다리가:
|
||||
1. `web_preview` 도구로 dev server 실행
|
||||
2. 사용자 요구사항대로 컴포넌트 추가
|
||||
3. `pwa_setup` 으로 PWA 만들기 (모바일 앱처럼)
|
||||
4. Vercel/Netlify에 배포
|
||||
@@ -0,0 +1,307 @@
|
||||
#!/usr/bin/env python3
|
||||
# version: web_init_v3
|
||||
"""웹·모바일 프로젝트 자동 초기화 — 5개 템플릿 중 선택.
|
||||
|
||||
config:
|
||||
TEMPLATE — vite-react / nextjs / astro / expo / vanilla
|
||||
PROJECT_NAME — 프로젝트 폴더 이름 (영문·하이픈, 공백 X)
|
||||
OUTPUT_DIR — 어디에 만들지 (비우면 ~/connect-ai-projects/)
|
||||
|
||||
각 템플릿은 검증된 공식 명령어로 셋업. 5분 안에 dev server 띄울 수 있는 상태로.
|
||||
"""
|
||||
import os, sys, json, subprocess, shutil
|
||||
|
||||
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
CONFIG = os.path.join(HERE, "web_init.json")
|
||||
|
||||
|
||||
def _log(msg, kind="info"):
|
||||
prefix = {"info": "💻", "ok": "✅", "warn": "⚠️ ", "err": "❌", "step": "▸"}.get(kind, "•")
|
||||
print(f"{prefix} {msg}", file=sys.stderr, flush=True)
|
||||
|
||||
|
||||
def _load():
|
||||
if os.path.exists(CONFIG):
|
||||
try:
|
||||
with open(CONFIG, "r", encoding="utf-8") as f:
|
||||
return json.load(f)
|
||||
except Exception:
|
||||
pass
|
||||
return {}
|
||||
|
||||
|
||||
def _save(c):
|
||||
try:
|
||||
with open(CONFIG, "w", encoding="utf-8") as f:
|
||||
json.dump(c, f, indent=2, ensure_ascii=False)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def _check_cmd(cmd):
|
||||
"""Check if a CLI tool exists."""
|
||||
return shutil.which(cmd) is not None
|
||||
|
||||
|
||||
def _run(cmd, cwd=None, capture=True):
|
||||
"""Run shell command, stream stderr live but capture stdout for return."""
|
||||
_log(f"$ {cmd}", "step")
|
||||
if capture:
|
||||
r = subprocess.run(cmd, shell=True, cwd=cwd, capture_output=True, text=True, timeout=600)
|
||||
if r.stdout:
|
||||
for line in r.stdout.splitlines()[:20]:
|
||||
print(f" {line}")
|
||||
if r.stderr and r.returncode != 0:
|
||||
for line in r.stderr.splitlines()[-10:]:
|
||||
_log(line, "warn")
|
||||
return r.returncode == 0, r.stdout
|
||||
else:
|
||||
return subprocess.run(cmd, shell=True, cwd=cwd, timeout=600).returncode == 0, ""
|
||||
|
||||
|
||||
def _scaffold_vite_react(name, parent):
|
||||
"""Vite + React + TS + Tailwind v4 (Vite 플러그인 방식).
|
||||
v2: tailwindcss init 명령이 v4에서 제거됨 → @tailwindcss/vite 플러그인 사용 + 설정 파일 직접 쓰기.
|
||||
각 단계마다 (cmd, cwd, critical) — critical=False면 실패해도 프로젝트 살림."""
|
||||
target = os.path.join(parent, name)
|
||||
return [
|
||||
("create", f"npm create vite@latest {name} -- --template react-ts", parent, True),
|
||||
("install", "npm install", target, True),
|
||||
("tailwind-pkg", "npm install tailwindcss@^4 @tailwindcss/vite@^4", target, False),
|
||||
("tailwind-config", _write_vite_tailwind_config, target, False),
|
||||
]
|
||||
|
||||
|
||||
def _write_vite_tailwind_config(target):
|
||||
"""Tailwind v4 설정 파일 직접 작성 (init 명령 의존 없음)."""
|
||||
# vite.config.ts: 기본 파일에 tailwindcss 플러그인 추가
|
||||
vite_cfg = os.path.join(target, "vite.config.ts")
|
||||
if os.path.exists(vite_cfg):
|
||||
try:
|
||||
with open(vite_cfg, "r", encoding="utf-8") as f:
|
||||
content = f.read()
|
||||
if "tailwindcss" not in content:
|
||||
# import 추가
|
||||
content = "import tailwindcss from '@tailwindcss/vite'\n" + content
|
||||
# plugins: [react()] → plugins: [react(), tailwindcss()]
|
||||
content = content.replace("plugins: [react()]", "plugins: [react(), tailwindcss()]")
|
||||
with open(vite_cfg, "w", encoding="utf-8") as f:
|
||||
f.write(content)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# src/index.css: Tailwind v4 import 한 줄
|
||||
css_path = os.path.join(target, "src", "index.css")
|
||||
if os.path.exists(css_path):
|
||||
try:
|
||||
with open(css_path, "r", encoding="utf-8") as f:
|
||||
cur = f.read()
|
||||
if '@import "tailwindcss"' not in cur:
|
||||
with open(css_path, "w", encoding="utf-8") as f:
|
||||
f.write('@import "tailwindcss";\n\n' + cur)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return True
|
||||
|
||||
|
||||
TEMPLATES = {
|
||||
"vite-react": {
|
||||
"label": "⚡ Vite + React + TypeScript + Tailwind v4",
|
||||
"needs": ["node", "npm"],
|
||||
"scaffold": _scaffold_vite_react,
|
||||
"post": "Tailwind v4 (Vite 플러그인) + index.css 자동 설정",
|
||||
"dev_cmd": "npm run dev",
|
||||
},
|
||||
"nextjs": {
|
||||
"label": "▲ Next.js 14 (App Router) + TypeScript + Tailwind",
|
||||
"needs": ["node", "npm"],
|
||||
"scaffold": lambda name, parent: [
|
||||
("scaffold", f"npx create-next-app@latest {name} --typescript --tailwind --app --src-dir --import-alias '@/*' --no-eslint --use-npm --yes", parent, True),
|
||||
],
|
||||
"post": "App Router·Tailwind·src 디렉토리 셋업 완료",
|
||||
"dev_cmd": "npm run dev",
|
||||
},
|
||||
"astro": {
|
||||
"label": "🚀 Astro + Tailwind (정적·콘텐츠 사이트)",
|
||||
"needs": ["node", "npm"],
|
||||
"scaffold": lambda name, parent: [
|
||||
("scaffold", f"npm create astro@latest {name} -- --template minimal --typescript strict --install --git --yes", parent, True),
|
||||
("tailwind", f"npx astro add tailwind --yes", os.path.join(parent, name), False),
|
||||
],
|
||||
"post": "Astro + Tailwind",
|
||||
"dev_cmd": "npm run dev",
|
||||
},
|
||||
"expo": {
|
||||
"label": "📱 Expo (React Native · iOS/Android/Web 동시)",
|
||||
"needs": ["node", "npm"],
|
||||
"scaffold": lambda name, parent: [
|
||||
("scaffold", f"npx create-expo-app@latest {name} --template blank-typescript", parent, True),
|
||||
],
|
||||
"post": "Expo Go 앱(iOS/Android) 깔고 'npm start' 후 QR 스캔",
|
||||
"dev_cmd": "npm start",
|
||||
},
|
||||
"vanilla": {
|
||||
"label": "📄 Vanilla HTML + CSS + JS (프레임워크 없음)",
|
||||
"needs": [],
|
||||
"scaffold": "VANILLA", # 특수 케이스 — 직접 파일 생성
|
||||
"post": "단일 폴더 + index.html + style.css + script.js + README",
|
||||
"dev_cmd": "python3 -m http.server 8000",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _scaffold_vanilla(target_dir, name):
|
||||
"""프레임워크 없이 정적 사이트 시드."""
|
||||
os.makedirs(target_dir, exist_ok=True)
|
||||
files = {
|
||||
"index.html": f'''<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{name}</title>
|
||||
<link rel="stylesheet" href="style.css">
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<h1>{name}</h1>
|
||||
<p>Connect AI · 코다리가 만든 사이트</p>
|
||||
</header>
|
||||
<main>
|
||||
<p>여기에 콘텐츠를 추가하세요.</p>
|
||||
</main>
|
||||
<script src="script.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
''',
|
||||
"style.css": '''* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; line-height: 1.6; color: #1a1a1a; background: #fafafa; }
|
||||
header { padding: 60px 24px; text-align: center; background: linear-gradient(135deg, #667eea, #764ba2); color: white; }
|
||||
header h1 { font-size: 48px; font-weight: 800; margin-bottom: 8px; }
|
||||
main { max-width: 720px; margin: 40px auto; padding: 0 24px; }
|
||||
''',
|
||||
"script.js": '''// 코다리가 첨부한 스크립트
|
||||
console.log("✦ Connect AI 사이트 로드 완료");
|
||||
''',
|
||||
"README.md": f'''# {name}
|
||||
|
||||
Connect AI 코다리가 셋업한 정적 웹사이트.
|
||||
|
||||
## 미리보기
|
||||
```
|
||||
python3 -m http.server 8000
|
||||
```
|
||||
그 다음 브라우저에서 http://localhost:8000
|
||||
|
||||
## 구조
|
||||
- `index.html` — 메인 페이지
|
||||
- `style.css` — 스타일
|
||||
- `script.js` — JS
|
||||
|
||||
## 배포
|
||||
- Vercel: `npx vercel --prod`
|
||||
- Netlify: `npx netlify deploy --prod`
|
||||
- Cloudflare Pages: GitHub 연결
|
||||
''',
|
||||
}
|
||||
for filename, content in files.items():
|
||||
with open(os.path.join(target_dir, filename), "w", encoding="utf-8") as f:
|
||||
f.write(content)
|
||||
return True
|
||||
|
||||
|
||||
def main():
|
||||
cfg = _load()
|
||||
template = (cfg.get("TEMPLATE") or "").strip().lower() or "vite-react"
|
||||
name = (cfg.get("PROJECT_NAME") or "").strip() or "my-app"
|
||||
out_dir = (cfg.get("OUTPUT_DIR") or "").strip()
|
||||
|
||||
if template not in TEMPLATES:
|
||||
_log(f"알 수 없는 템플릿: {template}", "err")
|
||||
_log(f"사용 가능: {', '.join(TEMPLATES.keys())}", "info")
|
||||
sys.exit(1)
|
||||
|
||||
# 이름 검증
|
||||
import re
|
||||
if not re.match(r"^[a-z0-9][a-z0-9_-]*$", name):
|
||||
_log(f"프로젝트 이름은 소문자·숫자·하이픈·언더스코어만: {name}", "err")
|
||||
sys.exit(1)
|
||||
|
||||
# 출력 위치
|
||||
if not out_dir:
|
||||
out_dir = os.path.expanduser("~/connect-ai-projects")
|
||||
out_dir = os.path.expanduser(out_dir)
|
||||
os.makedirs(out_dir, exist_ok=True)
|
||||
|
||||
target = os.path.join(out_dir, name)
|
||||
if os.path.exists(target):
|
||||
_log(f"이미 존재: {target} — 다른 이름 쓰거나 폴더 지우세요", "err")
|
||||
sys.exit(1)
|
||||
|
||||
spec = TEMPLATES[template]
|
||||
_log(f"{spec['label']} 셋업 시작 → {target}", "info")
|
||||
|
||||
# 의존성 체크
|
||||
for cmd in spec.get("needs", []):
|
||||
if not _check_cmd(cmd):
|
||||
_log(f"`{cmd}` 명령이 없음. 먼저 Node.js를 설치하세요 (nodejs.org).", "err")
|
||||
sys.exit(1)
|
||||
|
||||
# 실행
|
||||
if spec["scaffold"] == "VANILLA":
|
||||
ok = _scaffold_vanilla(target, name)
|
||||
if not ok:
|
||||
_log("vanilla 셋업 실패", "err")
|
||||
sys.exit(1)
|
||||
else:
|
||||
steps = spec["scaffold"](name, out_dir)
|
||||
warnings = []
|
||||
for step in steps:
|
||||
# 4-tuple 형식: (label, cmd_or_func, cwd, critical)
|
||||
if len(step) != 4:
|
||||
_log(f"잘못된 step 형식 (4-tuple 필요): {step}", "err")
|
||||
sys.exit(1)
|
||||
label, action, cwd, critical = step
|
||||
if callable(action):
|
||||
# Python 함수 직접 호출 (설정 파일 쓰기 등)
|
||||
_log(f"[{label}] 설정 파일 작성 중...", "step")
|
||||
try:
|
||||
ok = bool(action(cwd))
|
||||
except Exception as e:
|
||||
_log(f"[{label}] 예외: {e}", "warn")
|
||||
ok = False
|
||||
else:
|
||||
ok, _ = _run(action, cwd=cwd)
|
||||
if not ok:
|
||||
if critical:
|
||||
_log(f"❌ 핵심 단계 실패: [{label}] — 중단합니다", "err")
|
||||
sys.exit(1)
|
||||
else:
|
||||
_log(f"⚠️ 부가 단계 실패: [{label}] — 계속 진행합니다", "warn")
|
||||
warnings.append(label)
|
||||
if warnings:
|
||||
_log(f"부가 단계 {len(warnings)}개 실패 ({', '.join(warnings)}). 프로젝트 자체는 작동합니다.", "warn")
|
||||
_log("Tailwind 등은 사용자가 수동 추가 가능. README 참고.", "info")
|
||||
|
||||
_log(f"셋업 완료: {target}", "ok")
|
||||
_log(f"다음 단계:", "info")
|
||||
_log(f" cd {target}", "info")
|
||||
_log(f" {spec['dev_cmd']}", "info")
|
||||
if spec.get("post"):
|
||||
_log(f" {spec['post']}", "info")
|
||||
|
||||
# 결과 저장
|
||||
cfg["LAST_PROJECT"] = target
|
||||
cfg["LAST_TEMPLATE"] = template
|
||||
cfg["LAST_DEV_CMD"] = spec["dev_cmd"]
|
||||
_save(cfg)
|
||||
|
||||
print()
|
||||
print(f"PROJECT_PATH={target}")
|
||||
print(f"DEV_CMD={spec['dev_cmd']}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,31 @@
|
||||
<!-- version: web_preview_v1 -->
|
||||
# 💻 웹 dev server 백그라운드 실행 + URL 안내
|
||||
|
||||
`npm run dev` 같은 dev server를 백그라운드로 띄우고 미리보기 URL을 자동 감지·반환.
|
||||
|
||||
## 동작
|
||||
1. PROJECT_PATH의 package.json scripts.dev 자동 감지
|
||||
2. 백그라운드 실행 (nohup·detached) + PID 파일 저장
|
||||
3. 첫 8초 동안 로그에서 `localhost:포트` URL 파싱
|
||||
4. AUTO_OPEN=true 면 브라우저 자동 열기
|
||||
|
||||
## 설정
|
||||
- `PROJECT_PATH`: 비우면 web_init이 마지막에 만든 프로젝트 자동 사용
|
||||
- `DEV_CMD`: 비우면 자동 감지 (`npm run dev` / `npm start`)
|
||||
- `AUTO_OPEN`: `true`면 미리보기 URL을 브라우저로 열기
|
||||
|
||||
## 종료
|
||||
- 같은 도구 재실행 → 이전 PID 자동 kill 후 새로 시작
|
||||
- 수동 종료: `kill <PID>` (PID는 출력에 표시)
|
||||
- macOS/Linux: `pkill -f "npm run dev"`
|
||||
|
||||
## 사용 예시
|
||||
```
|
||||
1. web_init으로 프로젝트 셋업 (예: nextjs, my-blog)
|
||||
2. web_preview 실행 → http://localhost:3000 자동 표시
|
||||
3. 코드 변경 → HMR로 즉시 반영 (브라우저)
|
||||
4. 작업 끝나면 kill 또는 도구 재실행
|
||||
```
|
||||
|
||||
## 한계
|
||||
- 진짜 라이브 미리보기 칩 (사이드바 안의 상태 인디케이터)은 별도 UI 작업 필요. 현재는 출력에 URL만 반환.
|
||||
@@ -0,0 +1,209 @@
|
||||
#!/usr/bin/env python3
|
||||
# version: web_preview_v1
|
||||
"""웹 프로젝트 dev server 시작 + URL 추출.
|
||||
|
||||
config:
|
||||
PROJECT_PATH — 프로젝트 폴더 (web_init이 만든 건 자동 감지)
|
||||
PORT — 비워두면 자동 (vite=5173, next=3000, astro=4321)
|
||||
AUTO_OPEN — 'true' 면 브라우저 자동 열기
|
||||
|
||||
특징:
|
||||
- package.json scripts.dev 자동 감지
|
||||
- 백그라운드 실행 (nohup) + PID 파일 저장
|
||||
- 첫 5초 동안 출력에서 localhost URL 파싱
|
||||
- 같은 프로젝트 재실행 시 이전 PID 자동 종료
|
||||
"""
|
||||
import os, sys, json, subprocess, time, signal, re
|
||||
|
||||
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
CONFIG = os.path.join(HERE, "web_preview.json")
|
||||
WEB_INIT_CONFIG = os.path.join(HERE, "web_init.json")
|
||||
|
||||
|
||||
def _log(msg, kind="info"):
|
||||
prefix = {"info": "💻", "ok": "✅", "warn": "⚠️ ", "err": "❌", "step": "▸"}.get(kind, "•")
|
||||
print(f"{prefix} {msg}", file=sys.stderr, flush=True)
|
||||
|
||||
|
||||
def _load(p):
|
||||
if os.path.exists(p):
|
||||
try:
|
||||
with open(p, "r", encoding="utf-8") as f:
|
||||
return json.load(f)
|
||||
except Exception:
|
||||
pass
|
||||
return {}
|
||||
|
||||
|
||||
def _save(p, c):
|
||||
try:
|
||||
with open(p, "w", encoding="utf-8") as f:
|
||||
json.dump(c, f, indent=2, ensure_ascii=False)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def _detect_dev_command(project_path):
|
||||
"""package.json의 scripts.dev 또는 start를 감지."""
|
||||
pkg = os.path.join(project_path, "package.json")
|
||||
if not os.path.exists(pkg):
|
||||
return None
|
||||
try:
|
||||
with open(pkg, "r", encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
scripts = data.get("scripts", {})
|
||||
for key in ["dev", "start", "develop", "serve"]:
|
||||
if key in scripts:
|
||||
return f"npm run {key}"
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
def _kill_old_pid(pid_file):
|
||||
"""이전 실행이 있으면 종료."""
|
||||
if not os.path.exists(pid_file):
|
||||
return
|
||||
try:
|
||||
with open(pid_file, "r") as f:
|
||||
pid = int(f.read().strip())
|
||||
try:
|
||||
os.kill(pid, signal.SIGTERM)
|
||||
time.sleep(0.5)
|
||||
os.kill(pid, signal.SIGKILL)
|
||||
except ProcessLookupError:
|
||||
pass
|
||||
except PermissionError:
|
||||
pass
|
||||
_log(f"이전 dev server 종료 (PID {pid})", "info")
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
os.remove(pid_file)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def main():
|
||||
cfg = _load(CONFIG)
|
||||
init_cfg = _load(WEB_INIT_CONFIG)
|
||||
|
||||
project_path = (cfg.get("PROJECT_PATH") or "").strip()
|
||||
if not project_path:
|
||||
# web_init 결과 자동 사용
|
||||
project_path = (init_cfg.get("LAST_PROJECT") or "").strip()
|
||||
if not project_path:
|
||||
_log("PROJECT_PATH가 비어있고 web_init 기록도 없음. 프로젝트 경로 지정하세요.", "err")
|
||||
sys.exit(1)
|
||||
|
||||
project_path = os.path.expanduser(project_path)
|
||||
if not os.path.isdir(project_path):
|
||||
_log(f"폴더 없음: {project_path}", "err")
|
||||
sys.exit(1)
|
||||
|
||||
# dev 명령 감지
|
||||
dev_cmd = (cfg.get("DEV_CMD") or "").strip()
|
||||
if not dev_cmd:
|
||||
dev_cmd = init_cfg.get("LAST_DEV_CMD", "")
|
||||
if not dev_cmd:
|
||||
dev_cmd = _detect_dev_command(project_path) or "npm run dev"
|
||||
|
||||
_log(f"프로젝트: {project_path}", "info")
|
||||
_log(f"명령: {dev_cmd}", "info")
|
||||
|
||||
# PID 파일
|
||||
pid_file = os.path.join(project_path, ".connect-ai-dev.pid")
|
||||
log_file = os.path.join(project_path, ".connect-ai-dev.log")
|
||||
_kill_old_pid(pid_file)
|
||||
|
||||
# 백그라운드 실행
|
||||
try:
|
||||
with open(log_file, "w") as logf:
|
||||
if sys.platform == "win32":
|
||||
proc = subprocess.Popen(
|
||||
dev_cmd, shell=True, cwd=project_path,
|
||||
stdout=logf, stderr=subprocess.STDOUT,
|
||||
creationflags=subprocess.DETACHED_PROCESS | subprocess.CREATE_NEW_PROCESS_GROUP,
|
||||
)
|
||||
else:
|
||||
proc = subprocess.Popen(
|
||||
dev_cmd, shell=True, cwd=project_path,
|
||||
stdout=logf, stderr=subprocess.STDOUT,
|
||||
start_new_session=True,
|
||||
)
|
||||
with open(pid_file, "w") as f:
|
||||
f.write(str(proc.pid))
|
||||
_log(f"dev server 시작됨 (PID {proc.pid})", "ok")
|
||||
except Exception as e:
|
||||
_log(f"실행 실패: {e}", "err")
|
||||
sys.exit(1)
|
||||
|
||||
# 첫 8초 동안 로그에서 URL 파싱
|
||||
url = None
|
||||
deadline = time.time() + 8
|
||||
while time.time() < deadline:
|
||||
time.sleep(0.5)
|
||||
try:
|
||||
with open(log_file, "r") as f:
|
||||
content = f.read()
|
||||
# 흔한 패턴: "Local: http://localhost:3000" / "ready - started server on http://localhost:3000"
|
||||
m = re.search(r"https?://(?:localhost|127\.0\.0\.1):\d+(?:/\S*)?", content)
|
||||
if m:
|
||||
url = m.group(0)
|
||||
break
|
||||
except Exception:
|
||||
pass
|
||||
if proc.poll() is not None:
|
||||
_log("dev server가 일찍 종료됐어요. 로그 확인:", "warn")
|
||||
try:
|
||||
with open(log_file, "r") as f:
|
||||
for line in f.read().splitlines()[-10:]:
|
||||
print(f" {line}")
|
||||
except Exception:
|
||||
pass
|
||||
sys.exit(1)
|
||||
|
||||
if url:
|
||||
_log(f"미리보기 URL: {url}", "ok")
|
||||
else:
|
||||
_log("URL을 자동 감지 못 함. 로그 확인:", "warn")
|
||||
try:
|
||||
with open(log_file, "r") as f:
|
||||
for line in f.read().splitlines()[-15:]:
|
||||
print(f" {line}")
|
||||
except Exception:
|
||||
pass
|
||||
url = "http://localhost:3000"
|
||||
|
||||
auto_open = str(cfg.get("AUTO_OPEN", "")).lower() in ("true", "1", "yes")
|
||||
if auto_open and url:
|
||||
try:
|
||||
if sys.platform == "darwin":
|
||||
subprocess.Popen(["open", url])
|
||||
elif sys.platform == "win32":
|
||||
subprocess.Popen(["cmd", "/c", "start", "", url], shell=False)
|
||||
else:
|
||||
subprocess.Popen(["xdg-open", url])
|
||||
_log("브라우저 열림", "ok")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 결과 저장
|
||||
cfg["LAST_PROJECT"] = project_path
|
||||
cfg["LAST_PID"] = proc.pid
|
||||
cfg["LAST_URL"] = url
|
||||
cfg["LAST_LOG"] = log_file
|
||||
_save(CONFIG, cfg)
|
||||
|
||||
print()
|
||||
print(f"PID={proc.pid}")
|
||||
print(f"URL={url}")
|
||||
print(f"LOG={log_file}")
|
||||
print()
|
||||
_log("dev server는 백그라운드에서 계속 실행됩니다.", "info")
|
||||
_log(f"종료: kill {proc.pid} (또는 같은 도구 재실행)", "info")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user