[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,86 @@
|
||||
<!-- version: paypal_revenue_v1 -->
|
||||
# 💰 PayPal 매출 자동 분석
|
||||
|
||||
비즈니스 에이전트가 본인 PayPal 계정의 매출을 직접 분석. 일별/주별/월별 매출 + 통화별 + 환불 비율 + 최근 거래 마크다운 리포트.
|
||||
|
||||
## 한 번만 설정 — PayPal Developer App
|
||||
|
||||
### 1. PayPal Developer Dashboard
|
||||
- 접속: https://developer.paypal.com/dashboard/applications
|
||||
- 로그인 (PayPal Business 계정이 있어야 함)
|
||||
|
||||
### 2. 앱 생성
|
||||
- **Apps & Credentials** 메뉴
|
||||
- 처음 사용자 → 'Default Application' 이미 있음. 그거 써도 됨.
|
||||
- 새 앱 원하면 **Create App** 클릭
|
||||
- 앱 이름: "Connect AI Business Agent" 같은 식
|
||||
|
||||
### 3. 키 복사
|
||||
- 앱 상세 페이지에서:
|
||||
- **Client ID** 복사
|
||||
- **Client Secret** 복사 (show 클릭해서 보기)
|
||||
- 도구 설정에 붙여넣기
|
||||
|
||||
### 4. 권한 확인
|
||||
앱 상세 페이지 하단 **Features** 섹션에서:
|
||||
- ✅ **Transaction Search** 켜져있어야 함
|
||||
- 안 켜져있으면 토글 ON
|
||||
|
||||
## 모드
|
||||
|
||||
| MODE | 용도 | URL |
|
||||
|---|---|---|
|
||||
| **sandbox** | 테스트 (가짜 계정·가짜 돈) | api-m.sandbox.paypal.com |
|
||||
| **live** | 실제 운영 | api-m.paypal.com |
|
||||
|
||||
처음엔 **sandbox** 로 시작. 가짜 거래 만들어서 도구 동작 확인 후 live 전환.
|
||||
|
||||
샌드박스 거래 만들기: sandbox.paypal.com 에서 PayPal Developer 가 발급한 가짜 buyer/seller 계정으로 결제 시뮬레이션.
|
||||
|
||||
## 설정 (config)
|
||||
|
||||
| 키 | 의미 |
|
||||
|---|---|
|
||||
| `MODE` | `sandbox` 또는 `live` |
|
||||
| `CLIENT_ID` | PayPal 앱 Client ID |
|
||||
| `CLIENT_SECRET` | PayPal 앱 Client Secret (UI에서 password 필드로 가려짐) |
|
||||
| `LOOKBACK_DAYS` | 분석할 과거 일수 (기본 30) |
|
||||
| `CURRENCY` | 기본 통화 (USD/KRW/EUR). 비우면 모든 통화 표시 |
|
||||
|
||||
## 출력
|
||||
|
||||
마크다운 리포트:
|
||||
- 통화별 매출 표 (Gross, 환불, 수수료, 순매출, 거래수)
|
||||
- 기간별 매출 (오늘 · 지난 7일 · 지난 30일)
|
||||
- 평균/최대/최소 거래액
|
||||
- 최근 거래 10건 (일시·금액·종류)
|
||||
- 환불율 경고 (10% 초과 시)
|
||||
- 다음 액션 인사이트
|
||||
|
||||
## 사용 예시 (대화)
|
||||
|
||||
```
|
||||
사용자: "비즈니스 에이전트, 우리 회사 PayPal 매출 어때?"
|
||||
→ CEO → business 분배
|
||||
→ business: <run_command>cd "..." && python3 paypal_revenue.py</run_command>
|
||||
→ 결과 분석 + "이번 주가 평균 대비 +20% — 무엇이 잘됐는지 파악 필요" 같은 인사이트
|
||||
```
|
||||
|
||||
## 한계
|
||||
|
||||
- PayPal Transaction Search API: 최근 3년 데이터까지
|
||||
- 한 번 호출 = 최대 31일 × 500건 (자동 페이지네이션 처리)
|
||||
- Rate limit: 무료 계정 분당 60 요청 — 일반 사용엔 충분
|
||||
- 분쟁·세금·환율 변환은 안 함 (분석만)
|
||||
|
||||
## 보안
|
||||
|
||||
- `CLIENT_SECRET` 은 도구 설정 (password 필드) 에 저장. `.gitignore` 적용된 `_agents/business/tools/*.json` 에만 있음.
|
||||
- API 호출은 Connect AI 익스텐션이 로컬에서 직접 → 외부 서버 경유 없음.
|
||||
- token 메모리에만 존재, 디스크 저장 X.
|
||||
|
||||
## 다음 단계 (계획)
|
||||
|
||||
- Stripe·Toss 매출 통합 → 전체 결제 채널 한 리포트
|
||||
- 일별 추세 그래프 (matplotlib)
|
||||
- 월별 P&L 자동 생성 → `_company/_shared/pnl_<month>.md`
|
||||
@@ -0,0 +1,468 @@
|
||||
#!/usr/bin/env python3
|
||||
# version: paypal_revenue_v3
|
||||
"""PayPal 매출 자동 분석 — Connect AI 비즈니스 에이전트 전용.
|
||||
|
||||
흐름:
|
||||
1. CLIENT_ID + CLIENT_SECRET 으로 OAuth2 access token 발급
|
||||
2. Transaction Search API 호출 (LOOKBACK_DAYS 기간)
|
||||
3. 거래 파싱 → 매출·환불·평균액·통화별 집계
|
||||
4. 마크다운 리포트 stdout 출력
|
||||
|
||||
config (paypal_revenue.json):
|
||||
MODE — 'sandbox' (테스트) | 'live' (실제). 기본 sandbox
|
||||
CLIENT_ID — PayPal Developer Dashboard 에서 발급
|
||||
CLIENT_SECRET — 같은 곳, 비밀로 보관 (password 필드)
|
||||
LOOKBACK_DAYS — 분석할 과거 일수 (기본 30)
|
||||
CURRENCY — 기본 통화 (USD/KRW 등). 비우면 모든 통화 표시
|
||||
|
||||
발급: https://developer.paypal.com/dashboard/applications → Apps & Credentials
|
||||
샌드박스 테스트: sandbox.paypal.com 계정 무료 생성 가능
|
||||
"""
|
||||
import os, sys, json, base64, urllib.request, urllib.parse, urllib.error
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
CONFIG = os.path.join(HERE, "paypal_revenue.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 not os.path.exists(CONFIG):
|
||||
return {}
|
||||
try:
|
||||
with open(CONFIG, "r", encoding="utf-8") as f:
|
||||
return json.load(f)
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
|
||||
def _base_url(mode: str) -> str:
|
||||
return "https://api-m.paypal.com" if mode.lower() == "live" else "https://api-m.sandbox.paypal.com"
|
||||
|
||||
|
||||
def _has_reporting_scope(token_response: dict) -> bool:
|
||||
"""v2: OAuth 응답의 scope 필드에 Reporting (Transaction Search) 권한 있는지 검사.
|
||||
PayPal Dashboard 앱 설정 → Features → Transaction Search 체크 + Save 안 했으면 False.
|
||||
사용자에게 친절한 안내 띄우는 용도."""
|
||||
scopes = (token_response.get("scope") or "").split()
|
||||
return any("reporting" in s for s in scopes)
|
||||
|
||||
|
||||
def _get_access_token_full(base_url: str, client_id: str, client_secret: str) -> dict:
|
||||
"""v2: OAuth2 client_credentials grant — token + scope 둘 다 반환.
|
||||
scope 검사로 사용자 안내 (Transaction Search 권한 부재 사전 감지)."""
|
||||
auth = base64.b64encode(f"{client_id}:{client_secret}".encode()).decode()
|
||||
req = urllib.request.Request(
|
||||
f"{base_url}/v1/oauth2/token",
|
||||
data=b"grant_type=client_credentials",
|
||||
headers={
|
||||
"Authorization": f"Basic {auth}",
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
},
|
||||
method="POST",
|
||||
)
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=15) as r:
|
||||
return json.loads(r.read().decode())
|
||||
except urllib.error.HTTPError as e:
|
||||
err_body = e.read().decode(errors="ignore")[:200]
|
||||
raise RuntimeError(f"OAuth 실패 (HTTP {e.code}): {err_body}")
|
||||
except Exception as e:
|
||||
raise RuntimeError(f"OAuth 요청 실패: {e}")
|
||||
|
||||
|
||||
def _get_access_token(base_url: str, client_id: str, client_secret: str) -> str:
|
||||
"""레거시 호환 — token 만 반환."""
|
||||
return _get_access_token_full(base_url, client_id, client_secret)["access_token"]
|
||||
|
||||
|
||||
def _fetch_transactions(base_url: str, token: str, start: datetime, end: datetime, currency_filter: str = ""):
|
||||
"""Transaction Search API — 페이지네이션 자동 처리.
|
||||
공식 API 는 한 번에 최대 31일·500건 → 여러 페이지로 나눠 호출."""
|
||||
all_txs = []
|
||||
cur = start
|
||||
while cur < end:
|
||||
page_end = min(cur + timedelta(days=31), end)
|
||||
params = {
|
||||
# v3: PayPal Transaction Search 는 마이크로초 포함 ISO 형식 거부.
|
||||
# 초 단위까지만 + Z timezone 으로 강제. strftime 으로 안전 포맷.
|
||||
"start_date": cur.strftime("%Y-%m-%dT%H:%M:%SZ"),
|
||||
"end_date": page_end.strftime("%Y-%m-%dT%H:%M:%SZ"),
|
||||
"fields": "all",
|
||||
"page_size": "500",
|
||||
"page": "1",
|
||||
}
|
||||
if currency_filter:
|
||||
params["transaction_currency"] = currency_filter
|
||||
url = f"{base_url}/v1/reporting/transactions?" + urllib.parse.urlencode(params)
|
||||
req = urllib.request.Request(url, headers={"Authorization": f"Bearer {token}", "Content-Type": "application/json"})
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=20) as r:
|
||||
data = json.loads(r.read().decode())
|
||||
txs = data.get("transaction_details", [])
|
||||
all_txs.extend(txs)
|
||||
_log(f"{cur.date()} ~ {page_end.date()}: {len(txs)}건 수신", "step")
|
||||
total_pages = int(data.get("total_pages", 1))
|
||||
if total_pages > 1:
|
||||
for p in range(2, total_pages + 1):
|
||||
params["page"] = str(p)
|
||||
url2 = f"{base_url}/v1/reporting/transactions?" + urllib.parse.urlencode(params)
|
||||
req2 = urllib.request.Request(url2, headers={"Authorization": f"Bearer {token}"})
|
||||
with urllib.request.urlopen(req2, timeout=20) as r2:
|
||||
d2 = json.loads(r2.read().decode())
|
||||
all_txs.extend(d2.get("transaction_details", []))
|
||||
except urllib.error.HTTPError as e:
|
||||
body = e.read().decode(errors="ignore")[:300]
|
||||
_log(f"거래 조회 실패 ({cur.date()}~{page_end.date()}): HTTP {e.code} {body}", "warn")
|
||||
except Exception as e:
|
||||
_log(f"거래 조회 예외: {e}", "warn")
|
||||
cur = page_end
|
||||
return all_txs
|
||||
|
||||
|
||||
def _parse_project_from_subject(subject: str):
|
||||
"""v2: PayPal createOrder 의 description 에서 게임/프로젝트 + 아이템 추출.
|
||||
규약: "{Project Name} — {Item Name}" (em-dash 또는 -- 또는 :).
|
||||
예시:
|
||||
"Neon Survivor — Premium Pack" → ("neon-survivor", "Premium Pack")
|
||||
"Neon Survivor — Revive" → ("neon-survivor", "Revive")
|
||||
"Chick Game: Custom Skin" → ("chick-game", "Custom Skin")
|
||||
구분자 못 찾으면 전체를 프로젝트로 취급 + item = "(unspecified)".
|
||||
"""
|
||||
if not subject:
|
||||
return "(unknown)", "(unspecified)"
|
||||
s = subject.strip()
|
||||
for sep in [" — ", " -- ", " – ", ": "]:
|
||||
if sep in s:
|
||||
proj, item = s.split(sep, 1)
|
||||
slug = proj.strip().lower().replace(" ", "-")
|
||||
return slug or "(unknown)", item.strip() or "(unspecified)"
|
||||
slug = s.lower().replace(" ", "-")
|
||||
return slug or "(unknown)", "(unspecified)"
|
||||
|
||||
|
||||
def _summarize(txs, default_currency: str = ""):
|
||||
"""거래 리스트 → 마크다운 리포트."""
|
||||
now = datetime.now(timezone.utc)
|
||||
today_start = datetime(now.year, now.month, now.day, tzinfo=timezone.utc)
|
||||
week_start = today_start - timedelta(days=7)
|
||||
month_start = today_start - timedelta(days=30)
|
||||
|
||||
by_currency = {} # {USD: {"gross": float, "fees": float, "refunds": float, "count": int}}
|
||||
by_period = {"today": 0.0, "week": 0.0, "month": 0.0}
|
||||
by_project = {} # v2: {"neon-survivor": {"gross": float, "count": int, "currency": "USD",
|
||||
# "items": {"Premium Pack": {"gross": float, "count": int}}}}
|
||||
transactions_clean = [] # 정상 거래 (T0000 = 일반 결제)
|
||||
refunds = []
|
||||
|
||||
for t in txs:
|
||||
info = t.get("transaction_info", {})
|
||||
amount = info.get("transaction_amount", {})
|
||||
currency = amount.get("currency_code", "USD")
|
||||
value = float(amount.get("value", "0") or 0)
|
||||
status = info.get("transaction_status", "")
|
||||
event_code = info.get("transaction_event_code", "")
|
||||
ts_str = info.get("transaction_initiation_date", "")
|
||||
try:
|
||||
ts = datetime.fromisoformat(ts_str.replace("Z", "+00:00"))
|
||||
except Exception:
|
||||
ts = None
|
||||
|
||||
if currency not in by_currency:
|
||||
by_currency[currency] = {"gross": 0.0, "fees": 0.0, "refunds": 0.0, "count": 0}
|
||||
c = by_currency[currency]
|
||||
|
||||
is_refund = event_code.startswith("T1") or "REFUND" in event_code or value < 0
|
||||
if is_refund:
|
||||
c["refunds"] += abs(value)
|
||||
refunds.append((ts, value, currency))
|
||||
else:
|
||||
c["gross"] += value
|
||||
c["count"] += 1
|
||||
transactions_clean.append((ts, value, currency))
|
||||
if ts and currency == (default_currency or currency):
|
||||
if ts >= today_start:
|
||||
by_period["today"] += value
|
||||
if ts >= week_start:
|
||||
by_period["week"] += value
|
||||
if ts >= month_start:
|
||||
by_period["month"] += value
|
||||
# v2: 프로젝트별 그룹화 (정상 거래만 집계 — 환불은 별도 통계)
|
||||
subject = info.get("transaction_subject", "") or info.get("transaction_note", "")
|
||||
proj, item = _parse_project_from_subject(subject)
|
||||
if proj not in by_project:
|
||||
by_project[proj] = {"gross": 0.0, "count": 0, "currency": currency, "items": {}}
|
||||
p = by_project[proj]
|
||||
p["gross"] += value
|
||||
p["count"] += 1
|
||||
if item not in p["items"]:
|
||||
p["items"][item] = {"gross": 0.0, "count": 0}
|
||||
p["items"][item]["gross"] += value
|
||||
p["items"][item]["count"] += 1
|
||||
fee = float(info.get("fee_amount", {}).get("value", "0") or 0)
|
||||
c["fees"] += abs(fee)
|
||||
|
||||
# 마크다운 리포트
|
||||
lines = []
|
||||
lines.append(f"# 💰 PayPal 매출 분석")
|
||||
lines.append(f"_{now.isoformat(timespec='minutes')} · 최근 거래 {len(txs)}건_")
|
||||
lines.append("")
|
||||
|
||||
if not txs:
|
||||
lines.append("> ⚠️ 분석 기간에 거래가 없어요. PayPal Developer Dashboard 에서 모드(sandbox/live)·기간·계정을 확인하세요.")
|
||||
lines.append("")
|
||||
lines.append("**가능한 원인:**")
|
||||
lines.append("- 샌드박스 모드인데 실제 결제 데이터가 없음 → sandbox.paypal.com 에서 거래 시뮬레이션")
|
||||
lines.append("- API 권한 부족 → Developer Dashboard 에서 'Transaction Search' 권한 활성화")
|
||||
lines.append("- 너무 짧은 기간 → LOOKBACK_DAYS 늘려보기")
|
||||
return "\n".join(lines)
|
||||
|
||||
# 통화별 집계
|
||||
lines.append("## 📊 통화별 매출")
|
||||
lines.append("")
|
||||
lines.append("| 통화 | 매출 (Gross) | 환불 | 수수료 | 순매출 | 거래수 |")
|
||||
lines.append("|---|---|---|---|---|---|")
|
||||
for cur, d in sorted(by_currency.items()):
|
||||
net = d["gross"] - d["refunds"] - d["fees"]
|
||||
lines.append(f"| **{cur}** | {d['gross']:,.2f} | -{d['refunds']:,.2f} | -{d['fees']:,.2f} | **{net:,.2f}** | {d['count']}건 |")
|
||||
lines.append("")
|
||||
|
||||
# v2: 프로젝트(게임) 별 매출 — 카탈로그에 있는 게임들이 description 으로 자동 분류됨
|
||||
if by_project:
|
||||
lines.append("## 🎮 프로젝트별 매출")
|
||||
lines.append("")
|
||||
lines.append("| 프로젝트 | 거래 수 | 매출 | 통화 | 상위 아이템 |")
|
||||
lines.append("|---|---|---|---|---|")
|
||||
sorted_projects = sorted(by_project.items(), key=lambda x: -x[1]["gross"])
|
||||
for proj, p in sorted_projects:
|
||||
top_items = sorted(p["items"].items(), key=lambda x: -x[1]["gross"])[:2]
|
||||
top_str = ", ".join(f"{name} ({d['count']}건)" for name, d in top_items)
|
||||
lines.append(f"| **{proj}** | {p['count']}건 | {p['gross']:,.2f} | {p['currency']} | {top_str} |")
|
||||
lines.append("")
|
||||
# 상세 아이템 분해 (각 프로젝트별)
|
||||
for proj, p in sorted_projects:
|
||||
if len(p["items"]) <= 1:
|
||||
continue
|
||||
lines.append(f"### 🎯 {proj} 아이템 분해")
|
||||
lines.append("")
|
||||
lines.append("| 아이템 | 거래 수 | 매출 | ARPU |")
|
||||
lines.append("|---|---|---|---|")
|
||||
for name, d in sorted(p["items"].items(), key=lambda x: -x[1]["gross"]):
|
||||
arpu = d["gross"] / d["count"] if d["count"] > 0 else 0
|
||||
lines.append(f"| {name} | {d['count']}건 | {d['gross']:,.2f} | {arpu:,.2f} |")
|
||||
lines.append("")
|
||||
|
||||
# 기간별 (default_currency 기준)
|
||||
primary_cur = default_currency or (sorted(by_currency.items(), key=lambda x: -x[1]["gross"])[0][0] if by_currency else "USD")
|
||||
lines.append(f"## 📅 기간별 매출 ({primary_cur})")
|
||||
lines.append("")
|
||||
lines.append(f"- **오늘**: {by_period['today']:,.2f} {primary_cur}")
|
||||
lines.append(f"- **지난 7일**: {by_period['week']:,.2f} {primary_cur}")
|
||||
lines.append(f"- **지난 30일**: {by_period['month']:,.2f} {primary_cur}")
|
||||
lines.append("")
|
||||
# 평균 거래액
|
||||
if transactions_clean:
|
||||
primary_txs = [v for (_, v, c) in transactions_clean if c == primary_cur]
|
||||
if primary_txs:
|
||||
avg = sum(primary_txs) / len(primary_txs)
|
||||
lines.append(f"- 평균 거래액 ({primary_cur}): **{avg:,.2f}**")
|
||||
lines.append(f"- 최대 거래: {max(primary_txs):,.2f}")
|
||||
lines.append(f"- 최소 거래: {min(primary_txs):,.2f}")
|
||||
lines.append("")
|
||||
|
||||
# 최근 거래 10건
|
||||
lines.append("## 🕐 최근 거래 10건")
|
||||
lines.append("")
|
||||
lines.append("| 일시 | 금액 | 통화 | 종류 |")
|
||||
lines.append("|---|---|---|---|")
|
||||
sorted_txs = sorted(
|
||||
[(ts, v, c, "결제") for ts, v, c in transactions_clean] +
|
||||
[(ts, -v, c, "환불") for ts, v, c in refunds],
|
||||
key=lambda x: x[0] or datetime.min.replace(tzinfo=timezone.utc),
|
||||
reverse=True
|
||||
)[:10]
|
||||
for ts, v, c, kind in sorted_txs:
|
||||
ts_str = ts.strftime("%Y-%m-%d %H:%M") if ts else "?"
|
||||
sign = "+" if kind == "결제" else "-"
|
||||
lines.append(f"| {ts_str} | {sign}{abs(v):,.2f} | {c} | {kind} |")
|
||||
lines.append("")
|
||||
|
||||
# 환불 비율 경고
|
||||
total_count = sum(d["count"] for d in by_currency.values())
|
||||
if refunds and total_count > 0:
|
||||
refund_rate = len(refunds) / (total_count + len(refunds)) * 100
|
||||
if refund_rate > 10:
|
||||
lines.append(f"> 🚨 **환불율 경고**: {refund_rate:.1f}% — 평균(2~5%)보다 높음. 원인 분석 권장.")
|
||||
lines.append("")
|
||||
|
||||
# 인사이트
|
||||
lines.append("## 💡 다음 액션")
|
||||
if by_period['month'] > 0:
|
||||
weekly_avg = by_period['month'] / 4
|
||||
if by_period['week'] > weekly_avg * 1.2:
|
||||
lines.append(f"- 🚀 이번 주 매출({by_period['week']:,.0f})이 월 평균({weekly_avg:,.0f})보다 20%↑ — 무엇이 잘됐는지 파악")
|
||||
elif by_period['week'] < weekly_avg * 0.7:
|
||||
lines.append(f"- ⚠️ 이번 주 매출({by_period['week']:,.0f})이 월 평균({weekly_avg:,.0f})보다 30%↓ — 원인 점검")
|
||||
else:
|
||||
lines.append(f"- 📈 이번 주 매출({by_period['week']:,.0f})은 월 평균 추세 유지")
|
||||
if len(by_currency) > 1:
|
||||
lines.append(f"- 💱 {len(by_currency)}개 통화로 매출 발생 — 환율 변동 위험 분산 또는 헤지 검토")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _json_dump(txs, default_currency: str = ""):
|
||||
"""v2: OUTPUT=json 모드에서 호출. 마크다운 대신 watcher / 대시보드가 파싱하기
|
||||
쉬운 구조화 JSON 출력. 새 결제 감지 + 대시보드 그래프 양쪽에서 사용."""
|
||||
out = {
|
||||
"generated_at": datetime.now(timezone.utc).isoformat(timespec='seconds'),
|
||||
"currency_filter": default_currency,
|
||||
"totals": {"by_currency": {}, "by_period": {"today": 0.0, "week": 0.0, "month": 0.0}},
|
||||
"by_project": {},
|
||||
"by_day": {}, # {"2026-05-12": {"USD": {"gross": float, "count": int}}}
|
||||
"transactions": [], # 최근 100건만
|
||||
}
|
||||
now = datetime.now(timezone.utc)
|
||||
today_start = datetime(now.year, now.month, now.day, tzinfo=timezone.utc)
|
||||
week_start = today_start - timedelta(days=7)
|
||||
month_start = today_start - timedelta(days=30)
|
||||
|
||||
for t in txs:
|
||||
info = t.get("transaction_info", {})
|
||||
amount = info.get("transaction_amount", {})
|
||||
currency = amount.get("currency_code", "USD")
|
||||
value = float(amount.get("value", "0") or 0)
|
||||
event_code = info.get("transaction_event_code", "")
|
||||
tx_id = info.get("transaction_id", "")
|
||||
subject = info.get("transaction_subject", "") or info.get("transaction_note", "")
|
||||
ts_str = info.get("transaction_initiation_date", "")
|
||||
try:
|
||||
ts = datetime.fromisoformat(ts_str.replace("Z", "+00:00"))
|
||||
except Exception:
|
||||
ts = None
|
||||
is_refund = event_code.startswith("T1") or "REFUND" in event_code or value < 0
|
||||
|
||||
# totals
|
||||
cur_bucket = out["totals"]["by_currency"].setdefault(currency, {"gross": 0.0, "refunds": 0.0, "fees": 0.0, "count": 0})
|
||||
if is_refund:
|
||||
cur_bucket["refunds"] += abs(value)
|
||||
else:
|
||||
cur_bucket["gross"] += value
|
||||
cur_bucket["count"] += 1
|
||||
if ts and currency == (default_currency or currency):
|
||||
if ts >= today_start: out["totals"]["by_period"]["today"] += value
|
||||
if ts >= week_start: out["totals"]["by_period"]["week"] += value
|
||||
if ts >= month_start: out["totals"]["by_period"]["month"] += value
|
||||
cur_bucket["fees"] += abs(float(info.get("fee_amount", {}).get("value", "0") or 0))
|
||||
|
||||
# by_project
|
||||
if not is_refund:
|
||||
proj, item = _parse_project_from_subject(subject)
|
||||
p = out["by_project"].setdefault(proj, {"gross": 0.0, "count": 0, "currency": currency, "items": {}})
|
||||
p["gross"] += value
|
||||
p["count"] += 1
|
||||
it = p["items"].setdefault(item, {"gross": 0.0, "count": 0})
|
||||
it["gross"] += value
|
||||
it["count"] += 1
|
||||
|
||||
# by_day (last 30 days)
|
||||
if ts and ts >= month_start and not is_refund:
|
||||
day_key = ts.strftime("%Y-%m-%d")
|
||||
d = out["by_day"].setdefault(day_key, {})
|
||||
db = d.setdefault(currency, {"gross": 0.0, "count": 0})
|
||||
db["gross"] += value
|
||||
db["count"] += 1
|
||||
|
||||
# transactions (recent first, cap 100)
|
||||
out["transactions"].append({
|
||||
"id": tx_id,
|
||||
"ts": ts.isoformat() if ts else "",
|
||||
"ts_epoch": int(ts.timestamp()) if ts else 0,
|
||||
"value": value,
|
||||
"currency": currency,
|
||||
"subject": subject,
|
||||
"event_code": event_code,
|
||||
"is_refund": is_refund,
|
||||
})
|
||||
|
||||
out["transactions"].sort(key=lambda x: x["ts_epoch"], reverse=True)
|
||||
out["transactions"] = out["transactions"][:100]
|
||||
return out
|
||||
|
||||
|
||||
def main():
|
||||
cfg = _load()
|
||||
mode = (cfg.get("MODE") or "sandbox").strip().lower()
|
||||
client_id = (cfg.get("CLIENT_ID") or "").strip()
|
||||
client_secret = (cfg.get("CLIENT_SECRET") or "").strip()
|
||||
lookback = int(os.environ.get("LOOKBACK_DAYS", cfg.get("LOOKBACK_DAYS", 30)))
|
||||
currency = (cfg.get("CURRENCY") or "").strip().upper()
|
||||
output_mode = (os.environ.get("OUTPUT") or "markdown").strip().lower()
|
||||
|
||||
if not client_id or not client_secret:
|
||||
_log("CLIENT_ID 또는 CLIENT_SECRET 비어있음. PayPal Developer Dashboard 에서 발급:", "err")
|
||||
_log(" https://developer.paypal.com/dashboard/applications", "info")
|
||||
_log(" → Apps & Credentials → 본인 앱 → Client ID + Secret 복사", "info")
|
||||
sys.exit(1)
|
||||
|
||||
base = _base_url(mode)
|
||||
_log(f"PayPal {mode.upper()} 모드 · 최근 {lookback}일 분석", "info")
|
||||
|
||||
try:
|
||||
token_resp = _get_access_token_full(base, client_id, client_secret)
|
||||
token = token_resp["access_token"]
|
||||
_log("OAuth 인증 성공", "ok")
|
||||
except Exception as e:
|
||||
_log(f"OAuth 실패: {e}", "err")
|
||||
sys.exit(1)
|
||||
|
||||
# v2: scope 검사 → Reporting (Transaction Search) 권한 없으면 친절 안내 후 종료
|
||||
if not _has_reporting_scope(token_resp):
|
||||
_log("Transaction Search (Reporting) 권한이 토큰에 없음", "err")
|
||||
_log(" PayPal Developer Dashboard → 본인 앱 → Features → ", "info")
|
||||
_log(" ☑ Transaction search 체크 → Save Changes (반드시!)", "info")
|
||||
_log(" 변경 후 1~3분 대기 → 다시 시도", "info")
|
||||
_log("", "info")
|
||||
_log(" 💡 자주 놓치는 곳:", "info")
|
||||
_log(" - Default Application 사용 중이면 새 앱 만들기 (Features 잠금 가능)", "info")
|
||||
_log(" - 좌상단 Sandbox/Live 토글이 입력한 자격증명과 같은 환경인지", "info")
|
||||
_log(" - Live 환경은 PayPal 비즈니스 인증 + 별도 권한 신청 필요할 수 있음", "info")
|
||||
if output_mode == "json":
|
||||
print(json.dumps({
|
||||
"error": "reporting_scope_missing",
|
||||
"message": "OAuth 토큰에 Transaction Search 권한 없음",
|
||||
"scope": token_resp.get("scope", ""),
|
||||
"fix": "PayPal Dashboard 앱 Features 에서 Transaction search 체크 + Save"
|
||||
}, ensure_ascii=False, indent=2))
|
||||
else:
|
||||
print("# 💰 PayPal 매출 분석\n")
|
||||
print("> ❌ **Transaction Search 권한 없음** — PayPal Dashboard 에서 활성화 필요")
|
||||
print()
|
||||
print("**해결 단계:**")
|
||||
print("1. https://developer.paypal.com/dashboard/applications")
|
||||
print("2. 좌상단 Sandbox/Live 토글 확인 (현재 모드: `" + mode + "`)")
|
||||
print("3. 본인 앱 클릭")
|
||||
print("4. **Features** 섹션 → ☑ **Transaction search** 체크")
|
||||
print("5. 페이지 하단 **Save Changes** 클릭 (필수!)")
|
||||
print("6. 1~3분 대기 후 매출 대시보드 다시 새로고침")
|
||||
sys.exit(2)
|
||||
|
||||
end = datetime.now(timezone.utc)
|
||||
start = end - timedelta(days=lookback)
|
||||
txs = _fetch_transactions(base, token, start, end, currency)
|
||||
_log(f"총 {len(txs)}건 거래 수집", "ok")
|
||||
|
||||
if output_mode == "json":
|
||||
print(json.dumps(_json_dump(txs, currency), ensure_ascii=False, indent=2))
|
||||
else:
|
||||
report = _summarize(txs, currency)
|
||||
print(report)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,12 @@
|
||||
# 🧭 CEO 스킬
|
||||
|
||||
_재사용 가능한 패턴 모음. memory.md는 모든 활동의 로그(append-only firehose),
|
||||
이 폴더는 **검증된 패턴만 골라낸 것**입니다. 각 `*.md` 파일은 다음 호출 시
|
||||
CEO의 system prompt에 자동 주입됩니다._
|
||||
|
||||
## 어떻게 채우나요?
|
||||
- 텔레그램에서 `/skill` (직전 산출물 자동 승격)
|
||||
- VS Code 명령 팔레트: `Connect AI: 방금 산출물 → 스킬로 저장`
|
||||
- 직접 이 폴더에 `<주제>.md` 파일을 만들어도 됩니다 (`# 제목` + 본문)
|
||||
|
||||
`README.md` 자체는 system prompt에 주입되지 않습니다.
|
||||
@@ -0,0 +1,12 @@
|
||||
# 🎨 Designer 스킬
|
||||
|
||||
_재사용 가능한 패턴 모음. memory.md는 모든 활동의 로그(append-only firehose),
|
||||
이 폴더는 **검증된 패턴만 골라낸 것**입니다. 각 `*.md` 파일은 다음 호출 시
|
||||
Designer의 system prompt에 자동 주입됩니다._
|
||||
|
||||
## 어떻게 채우나요?
|
||||
- 텔레그램에서 `/skill` (직전 산출물 자동 승격)
|
||||
- VS Code 명령 팔레트: `Connect AI: 방금 산출물 → 스킬로 저장`
|
||||
- 직접 이 폴더에 `<주제>.md` 파일을 만들어도 됩니다 (`# 제목` + 본문)
|
||||
|
||||
`README.md` 자체는 system prompt에 주입되지 않습니다.
|
||||
@@ -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()
|
||||
@@ -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,12 @@
|
||||
# 📷 Instagram 스킬
|
||||
|
||||
_재사용 가능한 패턴 모음. memory.md는 모든 활동의 로그(append-only firehose),
|
||||
이 폴더는 **검증된 패턴만 골라낸 것**입니다. 각 `*.md` 파일은 다음 호출 시
|
||||
Instagram의 system prompt에 자동 주입됩니다._
|
||||
|
||||
## 어떻게 채우나요?
|
||||
- 텔레그램에서 `/skill` (직전 산출물 자동 승격)
|
||||
- VS Code 명령 팔레트: `Connect AI: 방금 산출물 → 스킬로 저장`
|
||||
- 직접 이 폴더에 `<주제>.md` 파일을 만들어도 됩니다 (`# 제목` + 본문)
|
||||
|
||||
`README.md` 자체는 system prompt에 주입되지 않습니다.
|
||||
@@ -0,0 +1,12 @@
|
||||
# 🔍 Researcher 스킬
|
||||
|
||||
_재사용 가능한 패턴 모음. memory.md는 모든 활동의 로그(append-only firehose),
|
||||
이 폴더는 **검증된 패턴만 골라낸 것**입니다. 각 `*.md` 파일은 다음 호출 시
|
||||
Researcher의 system prompt에 자동 주입됩니다._
|
||||
|
||||
## 어떻게 채우나요?
|
||||
- 텔레그램에서 `/skill` (직전 산출물 자동 승격)
|
||||
- VS Code 명령 팔레트: `Connect AI: 방금 산출물 → 스킬로 저장`
|
||||
- 직접 이 폴더에 `<주제>.md` 파일을 만들어도 됩니다 (`# 제목` + 본문)
|
||||
|
||||
`README.md` 자체는 system prompt에 주입되지 않습니다.
|
||||
@@ -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,12 @@
|
||||
# ✍️ Writer 스킬
|
||||
|
||||
_재사용 가능한 패턴 모음. memory.md는 모든 활동의 로그(append-only firehose),
|
||||
이 폴더는 **검증된 패턴만 골라낸 것**입니다. 각 `*.md` 파일은 다음 호출 시
|
||||
Writer의 system prompt에 자동 주입됩니다._
|
||||
|
||||
## 어떻게 채우나요?
|
||||
- 텔레그램에서 `/skill` (직전 산출물 자동 승격)
|
||||
- VS Code 명령 팔레트: `Connect AI: 방금 산출물 → 스킬로 저장`
|
||||
- 직접 이 폴더에 `<주제>.md` 파일을 만들어도 됩니다 (`# 제목` + 본문)
|
||||
|
||||
`README.md` 자체는 system prompt에 주입되지 않습니다.
|
||||
@@ -0,0 +1,12 @@
|
||||
# 📺 레오 스킬
|
||||
|
||||
_재사용 가능한 패턴 모음. memory.md는 모든 활동의 로그(append-only firehose),
|
||||
이 폴더는 **검증된 패턴만 골라낸 것**입니다. 각 `*.md` 파일은 다음 호출 시
|
||||
레오의 system prompt에 자동 주입됩니다._
|
||||
|
||||
## 어떻게 채우나요?
|
||||
- 텔레그램에서 `/skill` (직전 산출물 자동 승격)
|
||||
- VS Code 명령 팔레트: `Connect AI: 방금 산출물 → 스킬로 저장`
|
||||
- 직접 이 폴더에 `<주제>.md` 파일을 만들어도 됩니다 (`# 제목` + 본문)
|
||||
|
||||
`README.md` 자체는 system prompt에 주입되지 않습니다.
|
||||
Reference in New Issue
Block a user