gencow 0.1.115 → 0.1.116
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 +320 -15
- package/dashboard/assets/index-Bur5ZNpv.js +372 -0
- package/dashboard/index.html +1 -1
- package/package.json +1 -1
- package/server/index.js +55 -14
- package/server/index.js.map +2 -2
- package/dashboard/assets/index-DDiSe7od.js +0 -372
package/bin/gencow.mjs
CHANGED
|
@@ -1718,6 +1718,10 @@ ${BOLD}BaaS commands (login required):${RESET}
|
|
|
1718
1718
|
${GREEN}env set K=V${RESET} Set cloud env var ${DIM}(hot-reload, no restart)${RESET}
|
|
1719
1719
|
${GREEN}env unset KEY${RESET} Remove cloud env var
|
|
1720
1720
|
${GREEN}env push${RESET} Push .env to cloud
|
|
1721
|
+
${GREEN}files upload${RESET} Upload files to app storage ${DIM}(--recursive for dirs)${RESET}
|
|
1722
|
+
${GREEN}files list${RESET} List uploaded files
|
|
1723
|
+
${GREEN}files delete${RESET} Delete uploaded file ${DIM}(--yes to skip confirm)${RESET}
|
|
1724
|
+
${GREEN}files url${RESET} Get serving URL for a file
|
|
1721
1725
|
${GREEN}domain set${RESET} Connect custom domain ${DIM}(myapp.com)${RESET}
|
|
1722
1726
|
${GREEN}domain status${RESET} Check domain DNS/TLS status
|
|
1723
1727
|
${GREEN}domain remove${RESET} Disconnect custom domain
|
|
@@ -2451,9 +2455,13 @@ ${BOLD}Examples:${RESET}
|
|
|
2451
2455
|
};
|
|
2452
2456
|
const tsFiles = scanTsFiles(gencowDir);
|
|
2453
2457
|
// query(, mutation(, httpAction( 호출이 하나라도 있으면 실질적 백엔드
|
|
2458
|
+
// ⚠️ 주석 내 코드 예시를 오탐하지 않도록 주석을 먼저 제거
|
|
2454
2459
|
const hasApiCalls = tsFiles.some(f => {
|
|
2455
2460
|
const src = readFileSync(f, "utf8");
|
|
2456
|
-
|
|
2461
|
+
const stripped = src
|
|
2462
|
+
.replace(/\/\/.*$/gm, "") // 한줄 주석 제거
|
|
2463
|
+
.replace(/\/\*[\s\S]*?\*\//g, ""); // 블록 주석 제거
|
|
2464
|
+
return /\b(query|mutation|httpAction)\s*\(/.test(stripped);
|
|
2457
2465
|
});
|
|
2458
2466
|
if (!hasApiCalls) {
|
|
2459
2467
|
isBackendEmpty = true;
|
|
@@ -2699,23 +2707,29 @@ ${BOLD}Examples:${RESET}
|
|
|
2699
2707
|
warn("서버 시작 실패 원인:");
|
|
2700
2708
|
for (const line of errData.crashLogs) log(` ${DIM}${line}${RESET}`);
|
|
2701
2709
|
}
|
|
2702
|
-
|
|
2703
|
-
|
|
2704
|
-
|
|
2710
|
+
if (targetDir) {
|
|
2711
|
+
// --static 모드: 백엔드 실패해도 프론트엔드 배포는 계속 진행
|
|
2712
|
+
warn("백엔드 배포 실패 — 정적 파일 배포는 계속 진행합니다.");
|
|
2713
|
+
log("");
|
|
2714
|
+
} else {
|
|
2715
|
+
error("백엔드 배포 실패로 프론트엔드 배포를 건너뜁니다.");
|
|
2716
|
+
process.exit(1);
|
|
2717
|
+
}
|
|
2718
|
+
} else {
|
|
2719
|
+
const backendData = await backendDeployRes.json();
|
|
2720
|
+
const backendElapsed = ((Date.now() - backendStartTime) / 1000).toFixed(1);
|
|
2721
|
+
success(`백엔드 빌드 완료! (${backendElapsed}s)`);
|
|
2705
2722
|
|
|
2706
|
-
|
|
2707
|
-
|
|
2708
|
-
|
|
2723
|
+
// Health check — 백엔드 앱 URL로 실제 응답 확인
|
|
2724
|
+
if (backendData.url) {
|
|
2725
|
+
await this._verifyAppReady(backendData.url, appId);
|
|
2726
|
+
}
|
|
2709
2727
|
|
|
2710
|
-
|
|
2711
|
-
|
|
2712
|
-
|
|
2728
|
+
info(`URL: ${backendData.url}`);
|
|
2729
|
+
info(`Hash: ${backendData.bundleHash}`);
|
|
2730
|
+
updateEnvLocalUrl(backendData.url);
|
|
2731
|
+
log("");
|
|
2713
2732
|
}
|
|
2714
|
-
|
|
2715
|
-
info(`URL: ${backendData.url}`);
|
|
2716
|
-
info(`Hash: ${backendData.bundleHash}`);
|
|
2717
|
-
updateEnvLocalUrl(backendData.url);
|
|
2718
|
-
log("");
|
|
2719
2733
|
log(` ${BOLD}── 프론트엔드 배포 ──────────────────${RESET}\n`);
|
|
2720
2734
|
}
|
|
2721
2735
|
|
|
@@ -2971,6 +2985,297 @@ ${BOLD}Examples:${RESET}
|
|
|
2971
2985
|
},
|
|
2972
2986
|
|
|
2973
2987
|
|
|
2988
|
+
// ── files ─────────────────────────────────────────────
|
|
2989
|
+
async files(...filesArgs) {
|
|
2990
|
+
const subCmd = filesArgs[0] || "help";
|
|
2991
|
+
const restArgs = filesArgs.slice(1);
|
|
2992
|
+
|
|
2993
|
+
// help는 인증/앱 정보 없이도 표시
|
|
2994
|
+
if (subCmd === "help" || subCmd === "--help" || subCmd === "-h") {
|
|
2995
|
+
log(`\n${BOLD}${CYAN}gencow files${RESET} — 파일 관리\n`);
|
|
2996
|
+
log(` ${CYAN}upload${RESET} <경로...> [--recursive|-r] 파일 업로드`);
|
|
2997
|
+
log(` ${CYAN}list${RESET} 파일 목록`);
|
|
2998
|
+
log(` ${CYAN}delete${RESET} <storage_id> [--yes|-y] 파일 삭제`);
|
|
2999
|
+
log(` ${CYAN}url${RESET} <storage_id> 서빙 URL 출력`);
|
|
3000
|
+
log(`\n ${DIM}옵션:${RESET}`);
|
|
3001
|
+
log(` ${DIM}--app, -a <앱이름> 대상 앱 지정 (기본: gencow.json)${RESET}\n`);
|
|
3002
|
+
return;
|
|
3003
|
+
}
|
|
3004
|
+
|
|
3005
|
+
const creds = requireCreds();
|
|
3006
|
+
|
|
3007
|
+
// 앱 ID 결정 + --app 플래그 위치 추적 (paths 필터링용)
|
|
3008
|
+
let appId = null;
|
|
3009
|
+
const flagIndices = new Set(); // --app와 그 값의 인덱스
|
|
3010
|
+
for (let i = 0; i < restArgs.length; i++) {
|
|
3011
|
+
if (restArgs[i] === "--app" || restArgs[i] === "-a") {
|
|
3012
|
+
flagIndices.add(i);
|
|
3013
|
+
flagIndices.add(i + 1);
|
|
3014
|
+
appId = restArgs[++i];
|
|
3015
|
+
}
|
|
3016
|
+
}
|
|
3017
|
+
|
|
3018
|
+
if (!appId) {
|
|
3019
|
+
const gencowJsonPath = resolve(process.cwd(), "gencow.json");
|
|
3020
|
+
if (existsSync(gencowJsonPath)) {
|
|
3021
|
+
const gencowJson = JSON.parse(readFileSync(gencowJsonPath, "utf8"));
|
|
3022
|
+
appId = gencowJson.appId || gencowJson.appName;
|
|
3023
|
+
}
|
|
3024
|
+
}
|
|
3025
|
+
|
|
3026
|
+
if (!appId) {
|
|
3027
|
+
error("앱 ID를 찾을 수 없습니다. gencow deploy를 먼저 실행하세요.");
|
|
3028
|
+
return;
|
|
3029
|
+
}
|
|
3030
|
+
|
|
3031
|
+
const MAX_FILE_SIZE = 50 * 1024 * 1024; // 50 MB
|
|
3032
|
+
|
|
3033
|
+
function fmtFileSize(bytes) {
|
|
3034
|
+
if (bytes < 1024) return `${bytes} B`;
|
|
3035
|
+
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
|
3036
|
+
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
|
3037
|
+
}
|
|
3038
|
+
|
|
3039
|
+
function fmtDate(d) {
|
|
3040
|
+
return new Date(d).toLocaleString("ko-KR", {
|
|
3041
|
+
year: "numeric", month: "2-digit", day: "2-digit",
|
|
3042
|
+
hour: "2-digit", minute: "2-digit",
|
|
3043
|
+
});
|
|
3044
|
+
}
|
|
3045
|
+
|
|
3046
|
+
/** 파일 서빙 URL 생성 */
|
|
3047
|
+
function getFileUrl(storageId) {
|
|
3048
|
+
return `${getAppUrl(appId, creds.platformUrl)}/api/storage/${storageId}`;
|
|
3049
|
+
}
|
|
3050
|
+
|
|
3051
|
+
/** 단일 파일 업로드 */
|
|
3052
|
+
async function uploadFile(filePath) {
|
|
3053
|
+
const fileName = basename(filePath);
|
|
3054
|
+
const stat = statSync(filePath);
|
|
3055
|
+
|
|
3056
|
+
// 로컬 크기 검증 (서버 왕복 방지)
|
|
3057
|
+
if (stat.size > MAX_FILE_SIZE) {
|
|
3058
|
+
error(`${fileName}: ${fmtFileSize(stat.size)} — 50MB 제한 초과`);
|
|
3059
|
+
return null;
|
|
3060
|
+
}
|
|
3061
|
+
|
|
3062
|
+
info(`${fileName} (${fmtFileSize(stat.size)}) 업로드 중...`);
|
|
3063
|
+
|
|
3064
|
+
const fileBuffer = readFileSync(filePath);
|
|
3065
|
+
const blob = new Blob([fileBuffer]);
|
|
3066
|
+
|
|
3067
|
+
const formData = new FormData();
|
|
3068
|
+
formData.append("file", blob, fileName);
|
|
3069
|
+
|
|
3070
|
+
try {
|
|
3071
|
+
const res = await fetch(
|
|
3072
|
+
`${creds.platformUrl}/platform/files/upload?appName=${appId}`,
|
|
3073
|
+
{
|
|
3074
|
+
method: "POST",
|
|
3075
|
+
headers: {
|
|
3076
|
+
"Authorization": `Bearer ${creds.apiKey}`,
|
|
3077
|
+
},
|
|
3078
|
+
body: formData,
|
|
3079
|
+
}
|
|
3080
|
+
);
|
|
3081
|
+
|
|
3082
|
+
const data = await res.json().catch(() => ({}));
|
|
3083
|
+
if (!res.ok) {
|
|
3084
|
+
error(`${fileName}: ${data.error || res.statusText}`);
|
|
3085
|
+
return null;
|
|
3086
|
+
}
|
|
3087
|
+
|
|
3088
|
+
success(`${fileName} → ${data.storageId}`);
|
|
3089
|
+
info(` URL: ${getFileUrl(data.storageId)}`);
|
|
3090
|
+
return data;
|
|
3091
|
+
} catch (err) {
|
|
3092
|
+
error(`${fileName}: ${err.message}`);
|
|
3093
|
+
return null;
|
|
3094
|
+
}
|
|
3095
|
+
}
|
|
3096
|
+
|
|
3097
|
+
/** 디렉토리 재귀 파일 수집 */
|
|
3098
|
+
function collectFiles(dirPath) {
|
|
3099
|
+
const results = [];
|
|
3100
|
+
const entries = readdirSync(dirPath, { withFileTypes: true });
|
|
3101
|
+
for (const entry of entries) {
|
|
3102
|
+
const full = resolve(dirPath, entry.name);
|
|
3103
|
+
if (entry.isDirectory()) {
|
|
3104
|
+
results.push(...collectFiles(full));
|
|
3105
|
+
} else if (entry.isFile()) {
|
|
3106
|
+
results.push(full);
|
|
3107
|
+
}
|
|
3108
|
+
}
|
|
3109
|
+
return results;
|
|
3110
|
+
}
|
|
3111
|
+
|
|
3112
|
+
switch (subCmd) {
|
|
3113
|
+
case "upload":
|
|
3114
|
+
case "up": {
|
|
3115
|
+
const recursive = restArgs.includes("--recursive") || restArgs.includes("-r");
|
|
3116
|
+
const paths = restArgs.filter((a, i) => !a.startsWith("-") && !flagIndices.has(i));
|
|
3117
|
+
|
|
3118
|
+
if (paths.length === 0) {
|
|
3119
|
+
error("사용법: gencow files upload <파일|폴더 경로...> [--recursive|-r]");
|
|
3120
|
+
return;
|
|
3121
|
+
}
|
|
3122
|
+
|
|
3123
|
+
let filesToUpload = [];
|
|
3124
|
+
for (const p of paths) {
|
|
3125
|
+
const resolved = resolve(process.cwd(), p);
|
|
3126
|
+
if (!existsSync(resolved)) {
|
|
3127
|
+
error(`파일을 찾을 수 없습니다: ${p}`);
|
|
3128
|
+
continue;
|
|
3129
|
+
}
|
|
3130
|
+
const stat = statSync(resolved);
|
|
3131
|
+
if (stat.isDirectory()) {
|
|
3132
|
+
if (!recursive) {
|
|
3133
|
+
error(`${p}는 디렉토리입니다. --recursive (-r) 옵션을 사용하세요.`);
|
|
3134
|
+
continue;
|
|
3135
|
+
}
|
|
3136
|
+
filesToUpload.push(...collectFiles(resolved));
|
|
3137
|
+
} else {
|
|
3138
|
+
filesToUpload.push(resolved);
|
|
3139
|
+
}
|
|
3140
|
+
}
|
|
3141
|
+
|
|
3142
|
+
if (filesToUpload.length === 0) {
|
|
3143
|
+
error("업로드할 파일이 없습니다.");
|
|
3144
|
+
return;
|
|
3145
|
+
}
|
|
3146
|
+
|
|
3147
|
+
log(`\n${BOLD}${CYAN}파일 업로드${RESET} — ${appId}\n`);
|
|
3148
|
+
info(`${filesToUpload.length}개 파일 업로드 시작...\n`);
|
|
3149
|
+
|
|
3150
|
+
let uploaded = 0;
|
|
3151
|
+
let failed = 0;
|
|
3152
|
+
for (const filePath of filesToUpload) {
|
|
3153
|
+
const result = await uploadFile(filePath);
|
|
3154
|
+
if (result) uploaded++;
|
|
3155
|
+
else failed++;
|
|
3156
|
+
}
|
|
3157
|
+
|
|
3158
|
+
log("");
|
|
3159
|
+
if (uploaded > 0) success(`${uploaded}개 파일 업로드 완료`);
|
|
3160
|
+
if (failed > 0) warn(`${failed}개 파일 실패`);
|
|
3161
|
+
break;
|
|
3162
|
+
}
|
|
3163
|
+
|
|
3164
|
+
case "list":
|
|
3165
|
+
case "ls": {
|
|
3166
|
+
log(`\n${BOLD}${CYAN}파일 목록${RESET} — ${appId}\n`);
|
|
3167
|
+
|
|
3168
|
+
const res = await fetch(
|
|
3169
|
+
`${creds.platformUrl}/platform/files/list`,
|
|
3170
|
+
{
|
|
3171
|
+
method: "POST",
|
|
3172
|
+
headers: {
|
|
3173
|
+
"Authorization": `Bearer ${creds.apiKey}`,
|
|
3174
|
+
"Content-Type": "application/json",
|
|
3175
|
+
},
|
|
3176
|
+
body: JSON.stringify({ appName: appId }),
|
|
3177
|
+
}
|
|
3178
|
+
);
|
|
3179
|
+
|
|
3180
|
+
const data = await res.json().catch(() => []);
|
|
3181
|
+
if (!res.ok) {
|
|
3182
|
+
error(data.error || "파일 목록 조회 실패");
|
|
3183
|
+
return;
|
|
3184
|
+
}
|
|
3185
|
+
|
|
3186
|
+
const files = Array.isArray(data) ? data : [];
|
|
3187
|
+
if (files.length === 0) {
|
|
3188
|
+
info("저장된 파일이 없습니다.");
|
|
3189
|
+
log("");
|
|
3190
|
+
return;
|
|
3191
|
+
}
|
|
3192
|
+
|
|
3193
|
+
// 테이블 형식 출력
|
|
3194
|
+
const idWidth = Math.max(12, ...files.map(f => (f.storage_id || "").length));
|
|
3195
|
+
const nameWidth = Math.max(8, ...files.map(f => (f.name || "").length).map(l => Math.min(l, 40)));
|
|
3196
|
+
|
|
3197
|
+
log(` ${DIM}${"ID".padEnd(idWidth)} ${"이름".padEnd(nameWidth)} ${"크기".padStart(10)} ${"소스".padEnd(10)} 업로드 시각${RESET}`);
|
|
3198
|
+
log(` ${DIM}${"─".repeat(idWidth)} ${"─".repeat(nameWidth)} ${"─".repeat(10)} ${"─".repeat(10)} ${"─".repeat(16)}${RESET}`);
|
|
3199
|
+
|
|
3200
|
+
for (const f of files) {
|
|
3201
|
+
const name = (f.name || "").length > 40 ? f.name.slice(0, 37) + "..." : (f.name || "");
|
|
3202
|
+
const size = fmtFileSize(Number(f.size) || 0).padStart(10);
|
|
3203
|
+
const source = (f.uploaded_by || "api").padEnd(10);
|
|
3204
|
+
const date = f.created_at ? fmtDate(f.created_at) : "-";
|
|
3205
|
+
log(` ${(f.storage_id || "").padEnd(idWidth)} ${name.padEnd(nameWidth)} ${size} ${source} ${date}`);
|
|
3206
|
+
}
|
|
3207
|
+
log(`\n ${DIM}총 ${files.length}개 파일${RESET}\n`);
|
|
3208
|
+
break;
|
|
3209
|
+
}
|
|
3210
|
+
|
|
3211
|
+
case "delete":
|
|
3212
|
+
case "rm": {
|
|
3213
|
+
const storageId = restArgs.find((a, i) => !a.startsWith("-") && !flagIndices.has(i));
|
|
3214
|
+
if (!storageId) {
|
|
3215
|
+
error("사용법: gencow files delete <storage_id>");
|
|
3216
|
+
return;
|
|
3217
|
+
}
|
|
3218
|
+
|
|
3219
|
+
// 확인 프롬프트
|
|
3220
|
+
const skipConfirm = restArgs.includes("--yes") || restArgs.includes("-y");
|
|
3221
|
+
if (!skipConfirm) {
|
|
3222
|
+
const { createInterface } = await import("readline");
|
|
3223
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
3224
|
+
const answer = await new Promise(resolve => {
|
|
3225
|
+
rl.question(`\n ${YELLOW}정말 삭제하시겠습니까?${RESET} ${DIM}${storageId}${RESET} (y/N): `, a => {
|
|
3226
|
+
rl.close();
|
|
3227
|
+
resolve(a.trim().toLowerCase());
|
|
3228
|
+
});
|
|
3229
|
+
});
|
|
3230
|
+
if (answer !== "y" && answer !== "yes") {
|
|
3231
|
+
info("삭제를 취소했습니다.");
|
|
3232
|
+
return;
|
|
3233
|
+
}
|
|
3234
|
+
}
|
|
3235
|
+
|
|
3236
|
+
const res = await fetch(
|
|
3237
|
+
`${creds.platformUrl}/platform/files/delete`,
|
|
3238
|
+
{
|
|
3239
|
+
method: "POST",
|
|
3240
|
+
headers: {
|
|
3241
|
+
"Authorization": `Bearer ${creds.apiKey}`,
|
|
3242
|
+
"Content-Type": "application/json",
|
|
3243
|
+
},
|
|
3244
|
+
body: JSON.stringify({ appName: appId, storageId }),
|
|
3245
|
+
}
|
|
3246
|
+
);
|
|
3247
|
+
|
|
3248
|
+
const data = await res.json().catch(() => ({}));
|
|
3249
|
+
if (res.ok && data.success) {
|
|
3250
|
+
success(`파일 삭제 완료: ${storageId}`);
|
|
3251
|
+
} else {
|
|
3252
|
+
error(`삭제 실패: ${data.error || res.statusText}`);
|
|
3253
|
+
}
|
|
3254
|
+
break;
|
|
3255
|
+
}
|
|
3256
|
+
|
|
3257
|
+
case "url": {
|
|
3258
|
+
const storageId = restArgs.find((a, i) => !a.startsWith("-") && !flagIndices.has(i));
|
|
3259
|
+
if (!storageId) {
|
|
3260
|
+
error("사용법: gencow files url <storage_id>");
|
|
3261
|
+
return;
|
|
3262
|
+
}
|
|
3263
|
+
log(getFileUrl(storageId));
|
|
3264
|
+
break;
|
|
3265
|
+
}
|
|
3266
|
+
|
|
3267
|
+
default:
|
|
3268
|
+
if (subCmd !== "help") error(`알 수 없는 하위 명령: ${subCmd}`);
|
|
3269
|
+
log(`\n${BOLD}${CYAN}gencow files${RESET} — 파일 관리\n`);
|
|
3270
|
+
log(` ${CYAN}upload${RESET} <경로...> [--recursive|-r] 파일 업로드`);
|
|
3271
|
+
log(` ${CYAN}list${RESET} 파일 목록`);
|
|
3272
|
+
log(` ${CYAN}delete${RESET} <storage_id> [--yes|-y] 파일 삭제`);
|
|
3273
|
+
log(` ${CYAN}url${RESET} <storage_id> 서빙 URL 출력`);
|
|
3274
|
+
log(`\n ${DIM}옵션:${RESET}`);
|
|
3275
|
+
log(` ${DIM}--app, -a <앱이름> 대상 앱 지정 (기본: gencow.json)${RESET}\n`);
|
|
3276
|
+
}
|
|
3277
|
+
},
|
|
3278
|
+
|
|
2974
3279
|
// ── backup ─────────────────────────────────────────────
|
|
2975
3280
|
async backup(...backupArgs) {
|
|
2976
3281
|
const creds = requireCreds();
|