gencow 0.1.115 → 0.1.117

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 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
- return /\b(query|mutation|httpAction)\s*\(/.test(src);
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
- error("백엔드 배포 실패로 프론트엔드 배포를 건너뜁니다.");
2703
- process.exit(1);
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
- const backendData = await backendDeployRes.json();
2707
- const backendElapsed = ((Date.now() - backendStartTime) / 1000).toFixed(1);
2708
- success(`백엔드 빌드 완료! (${backendElapsed}s)`);
2723
+ // Health check 백엔드 앱 URL로 실제 응답 확인
2724
+ if (backendData.url) {
2725
+ await this._verifyAppReady(backendData.url, appId);
2726
+ }
2709
2727
 
2710
- // Health check — 백엔드 앱 URL로 실제 응답 확인
2711
- if (backendData.url) {
2712
- await this._verifyAppReady(backendData.url, appId);
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();
package/core/index.js CHANGED
@@ -1800,11 +1800,13 @@ function parseArgs(schema, args) {
1800
1800
  }
1801
1801
  }
1802
1802
  if (typeof schema === "object" && schema !== null) {
1803
+ const schemaKeys = Object.keys(schema);
1804
+ if (schemaKeys.length === 0) return args;
1803
1805
  if (typeof args !== "object" || args === null) {
1804
1806
  throw new GencowValidationError("Expected an object for arguments");
1805
1807
  }
1806
1808
  const result = {};
1807
- for (const key in schema) {
1809
+ for (const key of schemaKeys) {
1808
1810
  const validator = schema[key];
1809
1811
  if (validator && typeof validator.parse === "function") {
1810
1812
  try {