gencow 0.1.112 → 0.1.114
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/gencow.mjs +97 -32
- package/lib/__tests__/deploy-auditor.test.ts +91 -0
- package/lib/__tests__/readme-codegen.test.ts +3 -3
- package/lib/deploy-auditor.mjs +69 -0
- package/lib/readme-codegen.mjs +26 -23
- package/package.json +1 -1
- package/scripts/bundle-server.mjs +1 -1
- package/server/index.js +209 -31
- package/server/index.js.map +3 -3
- package/templates/ai-chat/README.md +7 -6
- package/templates/ai-chat/prompt.md +13 -12
- package/templates/default/README.md +10 -6
- package/templates/fullstack/README.md +6 -6
- package/templates/fullstack/prompt.md +15 -15
- package/templates/task-app/README.md +6 -6
- package/templates/task-app/prompt.md +15 -15
package/bin/gencow.mjs
CHANGED
|
@@ -29,15 +29,37 @@ const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
|
29
29
|
|
|
30
30
|
const CREDS_PATH = resolve(homedir(), ".gencow", "credentials.json");
|
|
31
31
|
|
|
32
|
+
// ─── Domain Helpers (Dev/Prod 환경 분리) ─────────────────
|
|
33
|
+
// platformUrl에서 기본 도메인을 추출하여 동적 URL 생성.
|
|
34
|
+
// DEV: https://gencow.dev → gencow.dev
|
|
35
|
+
// PROD: https://gencow.app → gencow.app
|
|
36
|
+
|
|
37
|
+
function getBaseDomain(platformUrl) {
|
|
38
|
+
try {
|
|
39
|
+
return new URL(platformUrl).hostname;
|
|
40
|
+
} catch {
|
|
41
|
+
return "gencow.app";
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function getAppUrl(appName, platformUrl) {
|
|
46
|
+
const domain = getBaseDomain(platformUrl);
|
|
47
|
+
return `https://${appName}.${domain}`;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function getWsUrl(appName, platformUrl) {
|
|
51
|
+
const domain = getBaseDomain(platformUrl);
|
|
52
|
+
return `wss://${appName}.${domain}/ws`;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function getDashboardUrl(appName, platformUrl) {
|
|
56
|
+
const domain = getBaseDomain(platformUrl);
|
|
57
|
+
return `https://${domain}/apps/${appName}`;
|
|
58
|
+
}
|
|
59
|
+
|
|
32
60
|
function loadCreds() {
|
|
33
61
|
try {
|
|
34
|
-
|
|
35
|
-
// gencow.dev → gencow.app 자동 마이그레이션 (301 리다이렉트 시 POST→GET 변환 방지)
|
|
36
|
-
if (creds?.platformUrl?.includes("gencow.dev")) {
|
|
37
|
-
creds.platformUrl = creds.platformUrl.replace("gencow.dev", "gencow.app");
|
|
38
|
-
try { writeFileSync(CREDS_PATH, JSON.stringify(creds, null, 2)); } catch { }
|
|
39
|
-
}
|
|
40
|
-
return creds;
|
|
62
|
+
return JSON.parse(readFileSync(CREDS_PATH, "utf8"));
|
|
41
63
|
} catch {
|
|
42
64
|
return null;
|
|
43
65
|
}
|
|
@@ -52,8 +74,8 @@ function requireCreds() {
|
|
|
52
74
|
// CI/CD 환경: 환경변수 토큰 우선 (Deploy Token 또는 CLI Token)
|
|
53
75
|
const envToken = process.env.GENCOW_TOKEN || process.env.GENCOW_DEPLOY_TOKEN;
|
|
54
76
|
if (envToken) {
|
|
55
|
-
const platformUrl = process.env.GENCOW_PLATFORM_URL || "https://gencow.app";
|
|
56
|
-
return { apiKey: envToken, platformUrl };
|
|
77
|
+
const platformUrl = process.env.GENCOW_PLATFORM_URL || "https://gencow.app"; // 기본값은 PROD
|
|
78
|
+
return { apiKey: envToken, platformUrl: validatePlatformUrl(platformUrl) };
|
|
57
79
|
}
|
|
58
80
|
|
|
59
81
|
// 로컬: ~/.gencow/credentials.json
|
|
@@ -68,9 +90,27 @@ function requireCreds() {
|
|
|
68
90
|
}
|
|
69
91
|
process.exit(1);
|
|
70
92
|
}
|
|
93
|
+
// 기존 credentials에도 platformUrl 검증 적용
|
|
94
|
+
if (creds.platformUrl) {
|
|
95
|
+
creds.platformUrl = validatePlatformUrl(creds.platformUrl);
|
|
96
|
+
}
|
|
71
97
|
return creds;
|
|
72
98
|
}
|
|
73
99
|
|
|
100
|
+
/** platformUrl 보안 검증 — https:// 스킴 강제 (localhost 제외) */
|
|
101
|
+
function validatePlatformUrl(url) {
|
|
102
|
+
if (!url) return "https://gencow.app";
|
|
103
|
+
// localhost는 개발용으로 http 허용
|
|
104
|
+
if (url.startsWith("http://localhost")) return url;
|
|
105
|
+
// 그 외는 반드시 https://
|
|
106
|
+
if (!url.startsWith("https://")) {
|
|
107
|
+
error(`platformUrl must use https:// (got: ${url})`);
|
|
108
|
+
info("시크릿이 평문으로 전송될 수 있습니다. GENCOW_PLATFORM_URL을 확인하세요.");
|
|
109
|
+
process.exit(1);
|
|
110
|
+
}
|
|
111
|
+
return url;
|
|
112
|
+
}
|
|
113
|
+
|
|
74
114
|
async function platformFetch(creds, path, opts = {}) {
|
|
75
115
|
const url = `${creds.platformUrl}${path}`;
|
|
76
116
|
const res = await fetch(url, {
|
|
@@ -1055,10 +1095,12 @@ ${hasPrompt ? `
|
|
|
1055
1095
|
// ── Cloud 모드 (기본) ──
|
|
1056
1096
|
const gencowJsonPath = resolve(process.cwd(), "gencow.json");
|
|
1057
1097
|
let appId = null;
|
|
1098
|
+
let seedPlatformUrl = null;
|
|
1058
1099
|
if (existsSync(gencowJsonPath)) {
|
|
1059
1100
|
try {
|
|
1060
1101
|
const gencowJson = JSON.parse(readFileSync(gencowJsonPath, "utf8"));
|
|
1061
1102
|
appId = gencowJson.appId || gencowJson.appName;
|
|
1103
|
+
seedPlatformUrl = gencowJson.platformUrl || null;
|
|
1062
1104
|
} catch { /* ignore parse error */ }
|
|
1063
1105
|
}
|
|
1064
1106
|
|
|
@@ -1073,11 +1115,12 @@ ${hasPrompt ? `
|
|
|
1073
1115
|
log(`\n${BOLD}${CYAN}Gencow DB Seed${RESET} ${DIM}(cloud: ${appId})${RESET}\n`);
|
|
1074
1116
|
info("Running seed.ts on cloud app...");
|
|
1075
1117
|
|
|
1076
|
-
const
|
|
1118
|
+
const seedAppBaseUrl = getAppUrl(appId, seedPlatformUrl || "https://gencow.app");
|
|
1119
|
+
const cloudUrl = `${seedAppBaseUrl}/_admin/seed`;
|
|
1077
1120
|
|
|
1078
1121
|
try {
|
|
1079
1122
|
// 앱이 실행 중인지 확인 (status 체크)
|
|
1080
|
-
const statusRes = await fetch(
|
|
1123
|
+
const statusRes = await fetch(`${seedAppBaseUrl}/_admin/status`, {
|
|
1081
1124
|
signal: AbortSignal.timeout(5000),
|
|
1082
1125
|
}).catch(() => null);
|
|
1083
1126
|
|
|
@@ -1110,7 +1153,7 @@ ${hasPrompt ? `
|
|
|
1110
1153
|
}
|
|
1111
1154
|
} catch (e) {
|
|
1112
1155
|
error(`Cloud app connection failed: ${e.message}`);
|
|
1113
|
-
info(`${DIM}앱이 실행 중인지 확인하세요:
|
|
1156
|
+
info(`${DIM}앱이 실행 중인지 확인하세요: ${seedAppBaseUrl}${RESET}`);
|
|
1114
1157
|
}
|
|
1115
1158
|
log("");
|
|
1116
1159
|
},
|
|
@@ -2115,6 +2158,18 @@ ${BOLD}Examples:${RESET}
|
|
|
2115
2158
|
const bundleSize = statSync(tmpBundle).size;
|
|
2116
2159
|
success(`번들 생성: ${(bundleSize / 1024).toFixed(1)} KB`);
|
|
2117
2160
|
|
|
2161
|
+
// 2-0. 번들 크기 경고 (Ph2: 리소스 미터링 — 차단 아님, 안내만)
|
|
2162
|
+
try {
|
|
2163
|
+
const { auditBundleSize, formatBundleSizeWarning } = await import("../lib/deploy-auditor.mjs");
|
|
2164
|
+
const sizeResult = auditBundleSize(bundleSize);
|
|
2165
|
+
const sizeMsg = formatBundleSizeWarning(sizeResult);
|
|
2166
|
+
if (sizeMsg) {
|
|
2167
|
+
log(`${YELLOW}${sizeMsg}${RESET}`);
|
|
2168
|
+
}
|
|
2169
|
+
} catch {
|
|
2170
|
+
// 번들 크기 검사 실패 시 무시 (배포 계속)
|
|
2171
|
+
}
|
|
2172
|
+
|
|
2118
2173
|
// 2. 앱이 없으면 생성 (appId가 없는 경우)
|
|
2119
2174
|
if (!appId) {
|
|
2120
2175
|
info("앱 생성 중 (ID 자동 생성)...");
|
|
@@ -2396,9 +2451,13 @@ ${BOLD}Examples:${RESET}
|
|
|
2396
2451
|
};
|
|
2397
2452
|
const tsFiles = scanTsFiles(gencowDir);
|
|
2398
2453
|
// query(, mutation(, httpAction( 호출이 하나라도 있으면 실질적 백엔드
|
|
2454
|
+
// ⚠️ 주석 내 코드 예시를 오탐하지 않도록 주석을 먼저 제거
|
|
2399
2455
|
const hasApiCalls = tsFiles.some(f => {
|
|
2400
2456
|
const src = readFileSync(f, "utf8");
|
|
2401
|
-
|
|
2457
|
+
const stripped = src
|
|
2458
|
+
.replace(/\/\/.*$/gm, "") // 한줄 주석 제거
|
|
2459
|
+
.replace(/\/\*[\s\S]*?\*\//g, ""); // 블록 주석 제거
|
|
2460
|
+
return /\b(query|mutation|httpAction)\s*\(/.test(stripped);
|
|
2402
2461
|
});
|
|
2403
2462
|
if (!hasApiCalls) {
|
|
2404
2463
|
isBackendEmpty = true;
|
|
@@ -2464,7 +2523,7 @@ ${BOLD}Examples:${RESET}
|
|
|
2464
2523
|
if (apiRefFiles.length > 5) log(` ${DIM}... 외 ${apiRefFiles.length - 5}개${RESET}`);
|
|
2465
2524
|
log("");
|
|
2466
2525
|
warn(`정적 호스팅에는 API 서버가 없어 404가 발생합니다.`);
|
|
2467
|
-
info(`💡 백엔드가 필요하다면: ${CYAN}VITE_API_URL=https://{backend}.
|
|
2526
|
+
info(`💡 백엔드가 필요하다면: ${CYAN}VITE_API_URL=https://{backend}.{도메인} npm run build${RESET} 후 배포`);
|
|
2468
2527
|
info(`💡 별도 백엔드를 사용한다면: ${CYAN}VITE_API_URL${RESET} 환경변수로 빌드 대상을 지정하세요.`);
|
|
2469
2528
|
log("");
|
|
2470
2529
|
|
|
@@ -2644,23 +2703,29 @@ ${BOLD}Examples:${RESET}
|
|
|
2644
2703
|
warn("서버 시작 실패 원인:");
|
|
2645
2704
|
for (const line of errData.crashLogs) log(` ${DIM}${line}${RESET}`);
|
|
2646
2705
|
}
|
|
2647
|
-
|
|
2648
|
-
|
|
2649
|
-
|
|
2706
|
+
if (targetDir) {
|
|
2707
|
+
// --static 모드: 백엔드 실패해도 프론트엔드 배포는 계속 진행
|
|
2708
|
+
warn("백엔드 배포 실패 — 정적 파일 배포는 계속 진행합니다.");
|
|
2709
|
+
log("");
|
|
2710
|
+
} else {
|
|
2711
|
+
error("백엔드 배포 실패로 프론트엔드 배포를 건너뜁니다.");
|
|
2712
|
+
process.exit(1);
|
|
2713
|
+
}
|
|
2714
|
+
} else {
|
|
2715
|
+
const backendData = await backendDeployRes.json();
|
|
2716
|
+
const backendElapsed = ((Date.now() - backendStartTime) / 1000).toFixed(1);
|
|
2717
|
+
success(`백엔드 빌드 완료! (${backendElapsed}s)`);
|
|
2650
2718
|
|
|
2651
|
-
|
|
2652
|
-
|
|
2653
|
-
|
|
2719
|
+
// Health check — 백엔드 앱 URL로 실제 응답 확인
|
|
2720
|
+
if (backendData.url) {
|
|
2721
|
+
await this._verifyAppReady(backendData.url, appId);
|
|
2722
|
+
}
|
|
2654
2723
|
|
|
2655
|
-
|
|
2656
|
-
|
|
2657
|
-
|
|
2724
|
+
info(`URL: ${backendData.url}`);
|
|
2725
|
+
info(`Hash: ${backendData.bundleHash}`);
|
|
2726
|
+
updateEnvLocalUrl(backendData.url);
|
|
2727
|
+
log("");
|
|
2658
2728
|
}
|
|
2659
|
-
|
|
2660
|
-
info(`URL: ${backendData.url}`);
|
|
2661
|
-
info(`Hash: ${backendData.bundleHash}`);
|
|
2662
|
-
updateEnvLocalUrl(backendData.url);
|
|
2663
|
-
log("");
|
|
2664
2729
|
log(` ${BOLD}── 프론트엔드 배포 ──────────────────${RESET}\n`);
|
|
2665
2730
|
}
|
|
2666
2731
|
|
|
@@ -3937,7 +4002,7 @@ process.exit(0);
|
|
|
3937
4002
|
info(`${DIM}gencow.json 생성됨 (appId: ${appName})${RESET}`);
|
|
3938
4003
|
|
|
3939
4004
|
// .env에 VITE_API_URL 자동 설정
|
|
3940
|
-
updateEnvLocalUrl(
|
|
4005
|
+
updateEnvLocalUrl(getAppUrl(appName, creds.platformUrl));
|
|
3941
4006
|
|
|
3942
4007
|
// 프로비저닝 대기
|
|
3943
4008
|
info("프로비저닝 대기 중...");
|
|
@@ -3951,7 +4016,7 @@ process.exit(0);
|
|
|
3951
4016
|
process.exit(1);
|
|
3952
4017
|
}
|
|
3953
4018
|
|
|
3954
|
-
const appUrl =
|
|
4019
|
+
const appUrl = getAppUrl(appName, creds.platformUrl);
|
|
3955
4020
|
|
|
3956
4021
|
// ── 배포 함수 (번들 크기 + 소요 시간 표시) ────────────
|
|
3957
4022
|
async function deploy(reason = "initial") {
|
|
@@ -4073,7 +4138,7 @@ process.exit(0);
|
|
|
4073
4138
|
|
|
4074
4139
|
async function connectLogStream() {
|
|
4075
4140
|
const { WebSocket: WS } = await import("ws");
|
|
4076
|
-
const wsUrl =
|
|
4141
|
+
const wsUrl = getWsUrl(appName, creds.platformUrl);
|
|
4077
4142
|
|
|
4078
4143
|
try {
|
|
4079
4144
|
logWs = new WS(wsUrl);
|
|
@@ -4150,7 +4215,7 @@ ${BOLD}${CYAN}🚀 Gencow Cloud Dev${RESET}
|
|
|
4150
4215
|
|
|
4151
4216
|
${GREEN}▸${RESET} App: ${BOLD}${appName}${RESET}
|
|
4152
4217
|
${GREEN}▸${RESET} URL: ${DIM}${appUrl}${RESET}
|
|
4153
|
-
${GREEN}▸${RESET} Dashboard: ${DIM}
|
|
4218
|
+
${GREEN}▸${RESET} Dashboard: ${DIM}${getDashboardUrl(appName, creds.platformUrl)}${RESET}
|
|
4154
4219
|
${GREEN}▸${RESET} Watching: ${DIM}${functionsDir}/ (${watchedFiles.join(", ") || "*.ts"})${RESET}
|
|
4155
4220
|
${GREEN}▸${RESET} Mode: ${DIM}Cloud (PostgreSQL)${RESET}
|
|
4156
4221
|
|
|
@@ -11,6 +11,8 @@ import {
|
|
|
11
11
|
isNodeBuiltin,
|
|
12
12
|
formatAuditError,
|
|
13
13
|
getPlatformPackageList,
|
|
14
|
+
auditBundleSize,
|
|
15
|
+
formatBundleSizeWarning,
|
|
14
16
|
} from "../deploy-auditor.mjs";
|
|
15
17
|
|
|
16
18
|
describe("isPlatformPackage", () => {
|
|
@@ -125,3 +127,92 @@ describe("getPlatformPackageList", () => {
|
|
|
125
127
|
expect(list).toEqual(sorted);
|
|
126
128
|
});
|
|
127
129
|
});
|
|
130
|
+
|
|
131
|
+
describe("auditBundleSize", () => {
|
|
132
|
+
const MB = 1024 * 1024;
|
|
133
|
+
|
|
134
|
+
it("50MB 이하 → 경고 없음 (기본 임계치)", () => {
|
|
135
|
+
const result = auditBundleSize(30 * MB);
|
|
136
|
+
expect(result.warned).toBe(false);
|
|
137
|
+
expect(result.bundleSizeMB).toBe(30);
|
|
138
|
+
expect(result.thresholdMB).toBe(50);
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it("50MB 초과 → 경고 발생 (기본 임계치)", () => {
|
|
142
|
+
const result = auditBundleSize(60 * MB);
|
|
143
|
+
expect(result.warned).toBe(true);
|
|
144
|
+
expect(result.bundleSizeMB).toBe(60);
|
|
145
|
+
expect(result.thresholdMB).toBe(50);
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it("정확히 50MB → 경고 없음 (초과가 아닌 이상)", () => {
|
|
149
|
+
const result = auditBundleSize(50 * MB);
|
|
150
|
+
expect(result.warned).toBe(false);
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it("50MB + 1byte → 경고 발생", () => {
|
|
154
|
+
const result = auditBundleSize(50 * MB + 1);
|
|
155
|
+
expect(result.warned).toBe(true);
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
it("커스텀 임계치 100MB → 60MB는 경고 없음", () => {
|
|
159
|
+
const result = auditBundleSize(60 * MB, 100);
|
|
160
|
+
expect(result.warned).toBe(false);
|
|
161
|
+
expect(result.thresholdMB).toBe(100);
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
it("커스텀 임계치 30MB → 35MB는 경고", () => {
|
|
165
|
+
const result = auditBundleSize(35 * MB, 30);
|
|
166
|
+
expect(result.warned).toBe(true);
|
|
167
|
+
expect(result.thresholdMB).toBe(30);
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
it("0 bytes → 경고 없음", () => {
|
|
171
|
+
const result = auditBundleSize(0);
|
|
172
|
+
expect(result.warned).toBe(false);
|
|
173
|
+
expect(result.bundleSizeMB).toBe(0);
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
it("소수점 첫째 자리까지 반올림", () => {
|
|
177
|
+
const result = auditBundleSize(52.35 * MB);
|
|
178
|
+
// 52.35 → 52.4 (반올림)
|
|
179
|
+
expect(result.bundleSizeMB).toBeCloseTo(52.4, 0);
|
|
180
|
+
});
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
describe("formatBundleSizeWarning", () => {
|
|
184
|
+
it("경고 없으면 빈 문자열 반환", () => {
|
|
185
|
+
const msg = formatBundleSizeWarning({ warned: false, bundleSizeMB: 20, thresholdMB: 50 });
|
|
186
|
+
expect(msg).toBe("");
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
it("경고 있으면 번들 크기 정보 포함", () => {
|
|
190
|
+
const msg = formatBundleSizeWarning({ warned: true, bundleSizeMB: 75, thresholdMB: 50 });
|
|
191
|
+
expect(msg).toContain("75");
|
|
192
|
+
expect(msg).toContain("50");
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
it("메모리 사용 경고 메시지 포함", () => {
|
|
196
|
+
const msg = formatBundleSizeWarning({ warned: true, bundleSizeMB: 60, thresholdMB: 50 });
|
|
197
|
+
expect(msg).toContain("메모리");
|
|
198
|
+
expect(msg).toContain("크레딧");
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
it("업그레이드 안내 포함", () => {
|
|
202
|
+
const msg = formatBundleSizeWarning({ warned: true, bundleSizeMB: 60, thresholdMB: 50 });
|
|
203
|
+
expect(msg).toContain("Pro");
|
|
204
|
+
expect(msg).toContain("Scale");
|
|
205
|
+
expect(msg).toContain("업그레이드");
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
it("차단하지 않는다는 안내 포함", () => {
|
|
209
|
+
const msg = formatBundleSizeWarning({ warned: true, bundleSizeMB: 60, thresholdMB: 50 });
|
|
210
|
+
expect(msg).toContain("차단하지 않습니다");
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
it("권장사항 포함 (devDependencies, 대안 패키지)", () => {
|
|
214
|
+
const msg = formatBundleSizeWarning({ warned: true, bundleSizeMB: 60, thresholdMB: 50 });
|
|
215
|
+
expect(msg).toContain("devDependencies");
|
|
216
|
+
expect(msg).toContain("dayjs");
|
|
217
|
+
});
|
|
218
|
+
});
|
|
@@ -172,7 +172,7 @@ describe("buildAuthSection — 인증 섹션", () => {
|
|
|
172
172
|
const md = buildAuthSection();
|
|
173
173
|
expect(md).toContain("VITE_API_URL");
|
|
174
174
|
expect(md).toContain("프록시");
|
|
175
|
-
expect(md).toContain("
|
|
175
|
+
expect(md).toContain("createAuthClient(import.meta.env.VITE_API_URL)");
|
|
176
176
|
});
|
|
177
177
|
});
|
|
178
178
|
|
|
@@ -370,9 +370,9 @@ describe("buildFrontendSetup — 프론트엔드 초기 설정", () => {
|
|
|
370
370
|
expect(md).toContain("3단계");
|
|
371
371
|
});
|
|
372
372
|
|
|
373
|
-
it("
|
|
373
|
+
it("createAuthClient 설정 예제 포함", () => {
|
|
374
374
|
const md = buildFrontendSetup();
|
|
375
|
-
expect(md).toContain("
|
|
375
|
+
expect(md).toContain("createAuthClient");
|
|
376
376
|
expect(md).toContain("signIn, signUp, signOut, useAuth");
|
|
377
377
|
});
|
|
378
378
|
|
package/lib/deploy-auditor.mjs
CHANGED
|
@@ -216,3 +216,72 @@ export function formatAuditError(result) {
|
|
|
216
216
|
export function getPlatformPackageList() {
|
|
217
217
|
return [...PLATFORM_PACKAGES].sort();
|
|
218
218
|
}
|
|
219
|
+
|
|
220
|
+
// ── Bundle Size Audit ──────────────────────────────────────
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* 번들 크기 임계치 (MB) — 이 이상이면 메모리 경고.
|
|
224
|
+
* pricing_tiers.features.maxBundleSizeMB와 동기화.
|
|
225
|
+
* 기본값은 Hobby tier 기준 (50 MB).
|
|
226
|
+
*/
|
|
227
|
+
const DEFAULT_BUNDLE_SIZE_WARNING_MB = 50;
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* @typedef {Object} BundleSizeAuditResult
|
|
231
|
+
* @property {boolean} warned - 경고가 발생했는지
|
|
232
|
+
* @property {number} bundleSizeMB - 번들 크기 (MB)
|
|
233
|
+
* @property {number} thresholdMB - 경고 임계치 (MB)
|
|
234
|
+
*/
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* 번들(tar.gz) 크기를 검사하여 메모리 사용 경고 여부를 반환한다.
|
|
238
|
+
* 경고만 출력하며 배포를 차단하지 않는다.
|
|
239
|
+
*
|
|
240
|
+
* 큰 번들 = 많은 node_modules = 높은 RSS = 더 빠른 크레딧 소진.
|
|
241
|
+
* 사용자에게 리소스 소비를 인지시키고 업그레이드를 안내하는 목적.
|
|
242
|
+
*
|
|
243
|
+
* @param {number} bundleSizeBytes - 번들 파일 크기 (bytes)
|
|
244
|
+
* @param {number} [thresholdMB] - 경고 임계치 (MB). 기본값: 50
|
|
245
|
+
* @returns {BundleSizeAuditResult}
|
|
246
|
+
*/
|
|
247
|
+
export function auditBundleSize(bundleSizeBytes, thresholdMB) {
|
|
248
|
+
const threshold = thresholdMB || DEFAULT_BUNDLE_SIZE_WARNING_MB;
|
|
249
|
+
const bundleSizeMB = bundleSizeBytes / (1024 * 1024);
|
|
250
|
+
|
|
251
|
+
return {
|
|
252
|
+
warned: bundleSizeMB > threshold,
|
|
253
|
+
bundleSizeMB: Math.round(bundleSizeMB * 10) / 10,
|
|
254
|
+
thresholdMB: threshold,
|
|
255
|
+
};
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* 번들 크기 경고 메시지를 포맷한다.
|
|
260
|
+
* 차단하지 않고 안내만 제공 — 메모리 사용 + 업그레이드 유도.
|
|
261
|
+
*
|
|
262
|
+
* @param {BundleSizeAuditResult} result
|
|
263
|
+
* @returns {string} 포맷된 경고 메시지 (경고 없으면 빈 문자열)
|
|
264
|
+
*/
|
|
265
|
+
export function formatBundleSizeWarning(result) {
|
|
266
|
+
if (!result.warned) return "";
|
|
267
|
+
|
|
268
|
+
const lines = [
|
|
269
|
+
"",
|
|
270
|
+
` ⚠️ 번들 크기 경고: ${result.bundleSizeMB} MB (임계치: ${result.thresholdMB} MB)`,
|
|
271
|
+
"",
|
|
272
|
+
" 이 앱은 런타임 메모리를 많이 사용할 수 있습니다.",
|
|
273
|
+
" 큰 번들 = 많은 패키지 로딩 = 높은 RSS = 더 빠른 크레딧 소진",
|
|
274
|
+
"",
|
|
275
|
+
" 권장사항:",
|
|
276
|
+
" • 불필요한 devDependencies를 dependencies에서 제거하세요",
|
|
277
|
+
" • 무거운 패키지를 가벼운 대안으로 교체해보세요",
|
|
278
|
+
" (예: moment → dayjs, lodash → lodash-es)",
|
|
279
|
+
" • 메모리 한도가 부족하면 Pro/Scale 플랜으로 업그레이드하세요",
|
|
280
|
+
" (Hobby: 256MB, Pro: 512MB, Scale: 1024MB)",
|
|
281
|
+
"",
|
|
282
|
+
" 💡 이 경고는 배포를 차단하지 않습니다.",
|
|
283
|
+
"",
|
|
284
|
+
];
|
|
285
|
+
|
|
286
|
+
return lines.join("\n");
|
|
287
|
+
}
|
package/lib/readme-codegen.mjs
CHANGED
|
@@ -103,13 +103,13 @@ export function buildReactUsage(apiObj) {
|
|
|
103
103
|
if (fns.queries.length > 0) {
|
|
104
104
|
md += `// ${ns} 데이터 조회 (실시간 자동 갱신)\n`;
|
|
105
105
|
md += `// crud() list는 { data: ${capitalize(ns)}[], total: number } 반환\n`;
|
|
106
|
-
md += `const result = useQuery(api.${ns}.${fns.queries[0]});\n`;
|
|
106
|
+
md += `const { data: result } = useQuery(api.${ns}.${fns.queries[0]});\n`;
|
|
107
107
|
md += `result?.data.map(item => ...) // 데이터 배열 접근\n`;
|
|
108
108
|
md += `result?.total // 전체 건수 (페이지네이션용)\n`;
|
|
109
109
|
}
|
|
110
110
|
if (fns.mutations.length > 0) {
|
|
111
111
|
md += `// ${ns} 데이터 변경 (로딩/에러 자동 관리)\n`;
|
|
112
|
-
md += `const
|
|
112
|
+
md += `const { mutate: ${fns.mutations[0]}${capitalize(ns)}, isPending } = useMutation(api.${ns}.${fns.mutations[0]});\n`;
|
|
113
113
|
}
|
|
114
114
|
md += `\n`;
|
|
115
115
|
}
|
|
@@ -122,7 +122,7 @@ export function buildReactUsage(apiObj) {
|
|
|
122
122
|
md += `fetch("/api/query", { body: JSON.stringify({ name: "tasks.create", args: {...} }) })\n`;
|
|
123
123
|
md += `apiPost("tasks/create", { ... }) // ❌ 래퍼도 만들지 마세요\n\n`;
|
|
124
124
|
md += `// ✅ useMutation을 사용하세요 — 실시간 동기화 + 로딩 상태 자동 관리\n`;
|
|
125
|
-
md += `const
|
|
125
|
+
md += `const { mutate: create } = useMutation(api.tasks.create);\n`;
|
|
126
126
|
md += `await create({ title: "새 태스크" });\n`;
|
|
127
127
|
md += `// → 서버가 WebSocket으로 useQuery를 자동 갱신 — fetchTasks() 같은 수동 리페치 불필요!\n`;
|
|
128
128
|
md += `\`\`\`\n\n`;
|
|
@@ -132,15 +132,15 @@ export function buildReactUsage(apiObj) {
|
|
|
132
132
|
md += `선택된 항목이 없을 때 등 조건부로 쿼리를 건너뛰어야 할 때:\n\n`;
|
|
133
133
|
md += `\`\`\`typescript\n`;
|
|
134
134
|
md += `// 방법 A: "skip" 토큰 — 추천\n`;
|
|
135
|
-
md += `const messages = useQuery(api.chat.getMessages,\n`;
|
|
135
|
+
md += `const { data: messages } = useQuery(api.chat.getMessages,\n`;
|
|
136
136
|
md += ` conversationId ? { conversationId } : "skip"\n`;
|
|
137
137
|
md += `);\n\n`;
|
|
138
138
|
md += `// 방법 B: enabled 옵션 (TanStack Query 스타일)\n`;
|
|
139
|
-
md += `const messages = useQuery(api.chat.getMessages,\n`;
|
|
139
|
+
md += `const { data: messages } = useQuery(api.chat.getMessages,\n`;
|
|
140
140
|
md += ` { conversationId },\n`;
|
|
141
141
|
md += ` { enabled: !!conversationId }\n`;
|
|
142
142
|
md += `);\n`;
|
|
143
|
-
md += `// → skip 상태에서는 API 호출 없이 undefined
|
|
143
|
+
md += `// → skip 상태에서는 API 호출 없이 data=undefined, isLoading=false\n`;
|
|
144
144
|
md += `\`\`\`\n\n`;
|
|
145
145
|
|
|
146
146
|
return md;
|
|
@@ -153,9 +153,9 @@ export function buildFrontendSetup() {
|
|
|
153
153
|
md += `### 1단계: Auth 클라이언트 생성\n\n`;
|
|
154
154
|
md += `\`\`\`typescript\n`;
|
|
155
155
|
md += `// src/lib/auth.ts\n`;
|
|
156
|
-
md += `import {
|
|
156
|
+
md += `import { createAuthClient } from "@gencow/react";\n\n`;
|
|
157
157
|
md += `// VITE_API_URL은 .env에서 자동 읽음 (gencow init 시 자동 설정)\n`;
|
|
158
|
-
md += `export const { signIn, signUp, signOut, useAuth } =
|
|
158
|
+
md += `export const { signIn, signUp, signOut, useAuth } = createAuthClient();\n`;
|
|
159
159
|
md += `\`\`\`\n\n`;
|
|
160
160
|
md += `### 2단계: GencowProvider 설정\n\n`;
|
|
161
161
|
md += `\`\`\`tsx\n`;
|
|
@@ -176,8 +176,8 @@ export function buildFrontendSetup() {
|
|
|
176
176
|
md += `\`\`\`typescript\n`;
|
|
177
177
|
md += `import { api } from "@/gencow/api"; // gencow dev가 자동 생성\n`;
|
|
178
178
|
md += `import { useQuery, useMutation } from "@gencow/react";\n\n`;
|
|
179
|
-
md += `const tasks = useQuery(api.tasks.list);
|
|
180
|
-
md += `const
|
|
179
|
+
md += `const { data: tasks } = useQuery(api.tasks.list); // 실시간 구독\n`;
|
|
180
|
+
md += `const { mutate: create } = useMutation(api.tasks.create); // 데이터 변경\n`;
|
|
181
181
|
md += `\`\`\`\n\n`;
|
|
182
182
|
md += `### ❌ 흔한 실수\n\n`;
|
|
183
183
|
md += `\`\`\`typescript\n`;
|
|
@@ -197,9 +197,9 @@ export function buildFrontendSetup() {
|
|
|
197
197
|
md += `// GencowProvider에 token={null} 전달 시:\n`;
|
|
198
198
|
md += `<GencowProvider baseUrl={apiUrl} token={null}>\n\n`;
|
|
199
199
|
md += `// ❌ 데이터가 표시되지 않음 (token=null → 자동 skip)\n`;
|
|
200
|
-
md += `const data = useQuery(api.tasks.list);\n\n`;
|
|
200
|
+
md += `const { data } = useQuery(api.tasks.list);\n\n`;
|
|
201
201
|
md += `// ✅ { public: true } 옵션 추가 → 인증 없이 호출\n`;
|
|
202
|
-
md += `const data = useQuery(api.tasks.list, {}, { public: true });\n\n`;
|
|
202
|
+
md += `const { data } = useQuery(api.tasks.list, {}, { public: true });\n\n`;
|
|
203
203
|
md += `// ✅ 서버 사이드에서도 public: true 필요\n`;
|
|
204
204
|
md += `// gencow/tasks.ts\n`;
|
|
205
205
|
md += `export const list = query("tasks.list", {\n`;
|
|
@@ -228,9 +228,9 @@ export function buildAuthSection() {
|
|
|
228
228
|
md += `const BACKEND_URL = process.env.VITE_API_URL || "http://localhost:5456";\n`;
|
|
229
229
|
md += `// proxy: { "/api": { target: BACKEND_URL }, "/ws": { target: BACKEND_URL, ws: true } }\n`;
|
|
230
230
|
md += `\`\`\`\n\n`;
|
|
231
|
-
md += `\`
|
|
231
|
+
md += `\`createAuthClient()\` 사용 시 반드시 \`VITE_API_URL\` 환경변수를 전달하세요:\n\n`;
|
|
232
232
|
md += `\`\`\`typescript\n`;
|
|
233
|
-
md += `const { signIn, useAuth } =
|
|
233
|
+
md += `const { signIn, useAuth } = createAuthClient(import.meta.env.VITE_API_URL);\n`;
|
|
234
234
|
md += `\`\`\`\n\n`;
|
|
235
235
|
md += `> ⚠️ \`VITE_API_URL\`은 \`.env\`에 설정하세요. \`gencow init\` / \`gencow deploy\` 시 자동으로 설정됩니다.\n\n`;
|
|
236
236
|
return md;
|
|
@@ -288,9 +288,12 @@ export function buildAiPrompt(apiObj, namespaces) {
|
|
|
288
288
|
for (const m of fns.mutations) md += ` - api.${ns}.${m} (mutation)\n`;
|
|
289
289
|
}
|
|
290
290
|
md += `\n`;
|
|
291
|
-
md += `React Hook
|
|
292
|
-
md += ` useQuery(api.namespace.fnName) // 데이터
|
|
293
|
-
md += ` useMutation(api.namespace.fnName)
|
|
291
|
+
md += `React Hook 사용법 (TanStack Query 호환):\n`;
|
|
292
|
+
md += ` const { data, isLoading, error } = useQuery(api.namespace.fnName) // 데이터 구독\n`;
|
|
293
|
+
md += ` const { mutate, isPending, error } = useMutation(api.namespace.fnName) // 데이터 변경\n`;
|
|
294
|
+
md += ` // ⚠️ useQuery는 { data, isLoading, error } 객체를 반환해. data가 undefined이면 로딩 중.\n`;
|
|
295
|
+
md += ` // ⚠️ useMutation은 { mutate, mutateAsync, isPending, error } 객체를 반환해.\n`;
|
|
296
|
+
md += ` // TanStack Query처럼 튜플이 아닌 객체야! const [fn] = useMutation(...) 은 에러!\n`;
|
|
294
297
|
md += `\n`;
|
|
295
298
|
md += `⚠️ 중요 규칙:\n`;
|
|
296
299
|
md += `- 반드시 useQuery와 useMutation을 사용해서 데이터와 연결해줘.\n`;
|
|
@@ -332,7 +335,7 @@ export function buildAiPrompt(apiObj, namespaces) {
|
|
|
332
335
|
md += `- import { ai } from "./ai"; + ai.chat()을 사용해서 AI를 호출하고, OpenAI SDK를 직접 설치하지 마.\n`;
|
|
333
336
|
md += `배포 규칙:\n`;
|
|
334
337
|
md += `- 백엔드: \`npx gencow deploy\` (gencow/ 폴더만 배포됨. 프론트엔드는 포함 안 됨)\n`;
|
|
335
|
-
md += `- 풀스택: VITE_API_URL=https://{앱ID}.
|
|
338
|
+
md += `- 풀스택: VITE_API_URL=https://{앱ID}.{도메인} npm run build 후 \`npx gencow deploy --static dist/\`\n`;
|
|
336
339
|
md += ` → 백엔드가 감지되면 자동으로 백엔드 먼저 배포 후 프론트엔드 배포\n`;
|
|
337
340
|
md += `- 프론트엔드만 배포: \`npx gencow deploy --static --no-backend dist/\`\n`;
|
|
338
341
|
md += `- 환경변수는 \`npx gencow env set KEY=VALUE\`로 클라우드에 설정해. 즉시 반영 (재시작 불필요).\n`;
|
|
@@ -351,7 +354,7 @@ export function buildAiPrompt(apiObj, namespaces) {
|
|
|
351
354
|
md += ` import { crud } from "@gencow/core";\n`;
|
|
352
355
|
md += ` export const { get, list, create, update, remove } = crud(tasks);\n`;
|
|
353
356
|
md += `- ⚠️ crud().list 반환값은 { data: T[], total: number } 객체야. 배열이 아님!\n`;
|
|
354
|
-
md += ` 프론트: const result = useQuery(api.tasks.list); result?.data.map(t => ...) ← .data 필수\n`;
|
|
357
|
+
md += ` 프론트: const { data: result } = useQuery(api.tasks.list); result?.data.map(t => ...) ← .data 필수\n`;
|
|
355
358
|
md += ` result?.total → 전체 건수 (페이지네이션용)\n`;
|
|
356
359
|
md += `- crud() list는 페이지네이션, 검색, 정렬, 필터링을 내장 지원해:\n`;
|
|
357
360
|
md += ` useQuery(api.tasks.list, { page: 2, limit: 20 }) // 페이지네이션\n`;
|
|
@@ -472,7 +475,7 @@ export function buildDeploySection() {
|
|
|
472
475
|
md += `npx gencow env set DATABASE_URL=postgres://... # 클라우드에 설정\n`;
|
|
473
476
|
md += `npx gencow env list # 클라우드 환경변수 목록\n`;
|
|
474
477
|
md += `\`\`\`\n\n`;
|
|
475
|
-
md += `> 배포 후 앱은 \`https://{앱이름}.
|
|
478
|
+
md += `> 배포 후 앱은 \`https://{앱이름}.{도메인}\`에서 접근 가능\n\n`;
|
|
476
479
|
md += `### 환경변수 관리\n\n`;
|
|
477
480
|
md += `| 명령어 | 설명 |\n`;
|
|
478
481
|
md += `| :--- | :--- |\n`;
|
|
@@ -504,7 +507,7 @@ export function buildDeploySection() {
|
|
|
504
507
|
md += `\`\`\`bash\n`;
|
|
505
508
|
md += `# 1. 백엔드 URL을 환경변수로 빌드\n`;
|
|
506
509
|
md += `cd frontend\n`;
|
|
507
|
-
md += `VITE_API_URL=https://{앱ID}.
|
|
510
|
+
md += `VITE_API_URL=https://{앱ID}.{도메인} npm run build\n\n`;
|
|
508
511
|
md += `# 2. --static 배포 — 백엔드가 감지되면 자동으로 백엔드 먼저 배포 후 프론트엔드 배포\n`;
|
|
509
512
|
md += `gencow deploy --static dist/\n`;
|
|
510
513
|
md += `\`\`\`\n\n`;
|
|
@@ -532,7 +535,7 @@ export function buildDeploySection() {
|
|
|
532
535
|
md += `> 📦 langfuse, axios, cheerio 등 추가 패키지도 \`npm install\` 후 \`gencow deploy\`하면 자동 설치됩니다.\n`;
|
|
533
536
|
md += `> ⛔ \`child_process\`, \`vm\`, \`os\`, \`cluster\`, \`worker_threads\` 모듈은 보안상 차단됩니다.\n\n`;
|
|
534
537
|
md += `### CORS 설정\n\n`;
|
|
535
|
-
md += `- \`*.
|
|
538
|
+
md += `- \`*.{BASE_DOMAIN}\` 서브도메인 간 요청은 **자동 허용**됩니다.\n`;
|
|
536
539
|
md += `- 커스텀 도메인에서 API를 호출하려면 환경변수를 설정하세요:\n\n`;
|
|
537
540
|
md += `\`\`\`bash\n`;
|
|
538
541
|
md += `gencow env set CORS_ORIGINS=https://myapp.com,https://www.myapp.com # 클라우드에 설정\n`;
|
|
@@ -594,7 +597,7 @@ export function buildDevTips() {
|
|
|
594
597
|
md += ` - ⚠️ drizzle.config.ts 없으면 generate 실패 경고 출력 (deploy는 계속 진행, 하지만 migrations/ 없으면 플랫폼이 스키마 스킵)\n`;
|
|
595
598
|
md += `- MCP 서버를 사용하면 AI가 이 구조를 자동으로 인식합니다.\n`;
|
|
596
599
|
md += `- 로컬 개발: \`gencow dev\` — 로컬 서버 시작\n`;
|
|
597
|
-
md += `- 배포된 앱: \`https://{앱ID}.
|
|
600
|
+
md += `- 배포된 앱: \`https://{앱ID}.{도메인}\` — .env의 VITE_API_URL에 자동 설정됨\n`;
|
|
598
601
|
md += `- Self-fetch: \`process.env.GENCOW_INTERNAL_URL\` — cron/mutation에서 다른 함수 호출 시 사용 (자동 설정)\n`;
|
|
599
602
|
md += `- .env 파일은 로컬 전용. 클라우드에는 \`gencow env push\`로 올리세요.\n`;
|
|
600
603
|
return md;
|
package/package.json
CHANGED
|
@@ -117,7 +117,7 @@ console.log(`✅ Core bundled for codegen: ${(await import("fs")).statSync(resol
|
|
|
117
117
|
// ── Dashboard 정적 파일 복사 ──────────────────────────
|
|
118
118
|
// apps/dashboard/ 의 Vite 빌드 결과 (dist/) → packages/cli/dashboard/
|
|
119
119
|
// npm publish 시 함께 배포되어 로컬 개발 시 /_dashboard/ 대시보드 제공
|
|
120
|
-
// (클라우드에서는 gencow.
|
|
120
|
+
// (클라우드에서는 gencow.app/ 에서 Cloud Dashboard 사용, /_dashboard/ 비활성화)
|
|
121
121
|
//
|
|
122
122
|
const dashboardSrc = resolve(cliRoot, "../../apps/dashboard/dist");
|
|
123
123
|
const dashboardDest = resolve(cliRoot, "dashboard");
|