gencow 0.1.118 → 0.1.120

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
@@ -303,7 +303,8 @@ function _ensureEsbuildConsistency() {
303
303
  }
304
304
 
305
305
  /**
306
- * 모노레포 루트의 .npmrc에 esbuild hoisting 차단 규칙을 추가.
306
+ * 모노레포 루트의 .pnpmrc에 esbuild hoisting 차단 규칙을 추가.
307
+ * (.npmrc가 아닌 .pnpmrc — hoist-pattern은 pnpm 전용이므로 npm 경고 방지)
307
308
  * 이미 존재하면 스킵.
308
309
  */
309
310
  function _patchNpmrcForEsbuild(cwd) {
@@ -312,8 +313,9 @@ function _patchNpmrcForEsbuild(cwd) {
312
313
  let dir = cwd;
313
314
  for (let i = 0; i < 10; i++) {
314
315
  if (existsSync(resolve(dir, "pnpm-workspace.yaml"))) {
315
- const npmrcPath = resolve(dir, ".npmrc");
316
- const content = existsSync(npmrcPath) ? readFileSync(npmrcPath, "utf8") : "";
316
+ // .pnpmrc 대상 (pnpm 전용 설정 → npm "Unknown project config" 경고 방지)
317
+ const pnpmrcPath = resolve(dir, ".pnpmrc");
318
+ const content = existsSync(pnpmrcPath) ? readFileSync(pnpmrcPath, "utf8") : "";
317
319
  if (!content.includes("hoist-pattern[]=!@esbuild/*")) {
318
320
  const patch = [
319
321
  "",
@@ -323,8 +325,8 @@ function _patchNpmrcForEsbuild(cwd) {
323
325
  "hoist-pattern[]=!esbuild",
324
326
  "",
325
327
  ].join("\n");
326
- appendFileSync(npmrcPath, patch);
327
- info(".npmrc에 esbuild hoisting 차단 규칙을 추가했습니다.");
328
+ appendFileSync(pnpmrcPath, patch);
329
+ info(".pnpmrc에 esbuild hoisting 차단 규칙을 추가했습니다.");
328
330
  info("다음 pnpm install 시 영구 적용됩니다.");
329
331
  }
330
332
  return;
@@ -333,7 +335,7 @@ function _patchNpmrcForEsbuild(cwd) {
333
335
  if (parent === dir) break;
334
336
  dir = parent;
335
337
  }
336
- } catch { /* .npmrc 수정 실패 — 무시 (즉시 우회로 이번 실행은 문제 없음) */ }
338
+ } catch { /* .pnpmrc 수정 실패 — 무시 (즉시 우회로 이번 실행은 문제 없음) */ }
337
339
  }
338
340
 
339
341
  function findServerRoot() {
@@ -1197,7 +1199,7 @@ ${hasPrompt ? `
1197
1199
 
1198
1200
  if (!appId) {
1199
1201
  error("Cloud app not found — gencow.json이 없거나 appId가 설정되지 않았습니다.");
1200
- info(`${DIM}먼저 gencow deploy 를 실행하세요.${RESET}`);
1202
+ info(`${DIM}먼저 gencow dev 를 실행하세요.${RESET}`);
1201
1203
  info(`${DIM}로컬 서버에 seed하려면: gencow db:seed --local${RESET}`);
1202
1204
  log("");
1203
1205
  return;
@@ -1218,7 +1220,7 @@ ${hasPrompt ? `
1218
1220
  if (!statusRes || !statusRes.ok) {
1219
1221
  error(`Cloud app "${appId}" is not running`);
1220
1222
  info(`${DIM}Dashboard → Apps → ${appId} → Resume 으로 앱을 시작하세요.${RESET}`);
1221
- info(`${DIM}또는: gencow deploy배포 후 시도하세요.${RESET}`);
1223
+ info(`${DIM}또는: gencow dev 생성 후 시도하세요.${RESET}`);
1222
1224
  log("");
1223
1225
  return;
1224
1226
  }
@@ -1236,7 +1238,7 @@ ${hasPrompt ? `
1236
1238
  log(` ${DIM}export default async function seed(ctx) {${RESET}`);
1237
1239
  log(` ${DIM} await ctx.db.insert(tasks).values([...]);${RESET}`);
1238
1240
  log(` ${DIM}};${RESET}\n`);
1239
- info(`${DIM}After creating seed.ts: gencow deploy && gencow db:seed${RESET}`);
1241
+ info(`${DIM}After creating seed.ts: gencow dev 실행 gencow db:seed${RESET}`);
1240
1242
  } else if (!res.ok) {
1241
1243
  error(`Cloud seed failed: ${data.error || "Unknown error"}`);
1242
1244
  } else {
@@ -1555,7 +1557,7 @@ ${hasPrompt ? `
1555
1557
 
1556
1558
  if (!appId) {
1557
1559
  error("Cloud app not found — gencow.json이 없거나 appId가 설정되지 않았습니다.");
1558
- info(`${DIM}먼저 gencow deploy 를 실행하세요.${RESET}`);
1560
+ info(`${DIM}먼저 gencow dev 를 실행하세요.${RESET}`);
1559
1561
  info(`${DIM}로컬 DB에 push하려면: gencow db:push --local${RESET}`);
1560
1562
  log("");
1561
1563
  return;
@@ -1564,7 +1566,7 @@ ${hasPrompt ? `
1564
1566
  // ── Cloud 모드: Platform API를 통해 Cloud DB에 schema push ──
1565
1567
  const creds = requireCreds();
1566
1568
  log(`\n${BOLD}${CYAN}Gencow DB Push${RESET} ${DIM}(cloud: ${appId})${RESET}\n`);
1567
- warn("명령어 실행 전 먼저 gencow deploy 를 통해 최신 코드가 배포되어 있어야 합니다.");
1569
+ warn("명령어 실행 전 먼저 gencow dev 를 통해 최신 코드가 배포되어 있어야 합니다.");
1568
1570
  log("");
1569
1571
  info("Pushing schema.ts → cloud database...");
1570
1572
 
@@ -1805,18 +1807,24 @@ ${BOLD}BaaS commands (login required):${RESET}
1805
1807
  ${GREEN}login${RESET} Login to Gencow Platform ${DIM}(browser → token)${RESET}
1806
1808
  ${GREEN}logout${RESET} Clear credentials
1807
1809
  ${GREEN}whoami${RESET} Show current user info
1808
- ${GREEN}deploy${RESET} Bundle gencow/ and deploy to platform
1809
- ${DIM}--static [dir] Deploy static files (dist/, out/, build/)${RESET}
1810
- ${DIM}--no-backend Skip backend auto-deploy in --static mode${RESET}
1810
+ ${GREEN}static [dir]${RESET} Deploy static files to dev ${DIM}(dist/, out/, build/)${RESET}
1811
+ ${DIM}--no-backend Skip backend auto-deploy${RESET}
1811
1812
  ${DIM}--force, -f Skip dependency audit${RESET}
1812
- ${GREEN}env list${RESET} List cloud env vars
1813
+ ${GREEN}deploy${RESET} Deploy to production ${DIM}(Pro+ only)${RESET}
1814
+ ${DIM}--static [dir] Deploy static files to production${RESET}
1815
+ ${DIM}--rollback Rollback to previous deployment${RESET}
1816
+ ${DIM}--force, -f Skip dependency audit${RESET}
1817
+ ${GREEN}env list${RESET} List cloud env vars ${DIM}(--prod for production)${RESET}
1813
1818
  ${GREEN}env set K=V${RESET} Set cloud env var ${DIM}(hot-reload, no restart)${RESET}
1814
1819
  ${GREEN}env unset KEY${RESET} Remove cloud env var
1815
- ${GREEN}env push${RESET} Push .env to cloud
1820
+ ${GREEN}env push${RESET} Push .env to cloud ${DIM}(--prod reads .env.production)${RESET}
1816
1821
  ${GREEN}files upload${RESET} Upload files to app storage ${DIM}(--recursive for dirs)${RESET}
1817
1822
  ${GREEN}files list${RESET} List uploaded files
1818
1823
  ${GREEN}files delete${RESET} Delete uploaded file ${DIM}(--yes to skip confirm)${RESET}
1819
1824
  ${GREEN}files url${RESET} Get serving URL for a file
1825
+ ${GREEN}config set${RESET} Set app config ${DIM}(image.maxWidth, image.quality)${RESET}
1826
+ ${GREEN}config get${RESET} Show current app config
1827
+ ${GREEN}config reset${RESET} Reset app config to tier defaults
1820
1828
  ${GREEN}domain set${RESET} Connect custom domain ${DIM}(myapp.com)${RESET}
1821
1829
  ${GREEN}domain status${RESET} Check domain DNS/TLS status
1822
1830
  ${GREEN}domain remove${RESET} Disconnect custom domain
@@ -1980,18 +1988,60 @@ ${BOLD}Examples:${RESET}
1980
1988
  }
1981
1989
  },
1982
1990
 
1983
- // ── deploy ────────────────────────────────────────────
1991
+ // ── static — dev 정적 파일 배포 (독립 명령어) ─────────
1992
+ async static(...staticArgs) {
1993
+ const creds = requireCreds();
1994
+
1995
+ let staticDir = null;
1996
+ let forceDeploy = false;
1997
+ let noBackend = false;
1998
+ let appId = null;
1999
+
2000
+ for (let i = 0; i < staticArgs.length; i++) {
2001
+ const a = staticArgs[i];
2002
+ if (a === "--force" || a === "-f") forceDeploy = true;
2003
+ else if (a === "--no-backend") noBackend = true;
2004
+ else if (a === "--app" || a === "-a") appId = staticArgs[++i];
2005
+ else if (!a.startsWith("-")) {
2006
+ staticDir = a;
2007
+ }
2008
+ }
2009
+
2010
+ // gencow.json에서 appId 로드
2011
+ const gencowJsonPath = resolve(process.cwd(), "gencow.json");
2012
+ if (!appId && existsSync(gencowJsonPath)) {
2013
+ const gencowJson = JSON.parse(readFileSync(gencowJsonPath, "utf8"));
2014
+ appId = gencowJson.appId || gencowJson.appName;
2015
+ }
2016
+
2017
+ // displayName
2018
+ let displayName = null;
2019
+ const configPath = resolve(process.cwd(), "gencow.config.ts");
2020
+ if (existsSync(configPath)) {
2021
+ const content = readFileSync(configPath, "utf8");
2022
+ const match = content.match(/name:\s*["']([^"']+)["']/);
2023
+ if (match) displayName = match[1];
2024
+ }
2025
+ if (!displayName) displayName = basename(process.cwd());
2026
+
2027
+ return await this._deployStatic(creds, appId, displayName, staticDir, gencowJsonPath, {
2028
+ forceDeploy, noBackend, envTarget: "dev",
2029
+ });
2030
+ },
2031
+
2032
+ // ── deploy — 프로덕션 배포 (Pro+ only) ───────────────
1984
2033
  async deploy(...deployArgs) {
1985
2034
  const creds = requireCreds();
1986
2035
 
1987
2036
  // gencow.json에서 앱 ID 확인 (자동 생성된 유니크 ID)
1988
2037
  let appId = null;
1989
2038
  let displayName = null;
1990
- let envTarget = "dev";
2039
+ let envTarget = "prod"; // v0.1.120: deploy = prod 기본값
1991
2040
  let staticDir = null; // --static 옵션
1992
2041
  let isStatic = false;
1993
2042
  let forceDeploy = false; // --force: skip dependency audit
1994
2043
  let noBackend = false; // --no-backend: skip backend auto-deploy in --static mode
2044
+ let isRollback = false; // --rollback: rollback to previous deploy
1995
2045
 
1996
2046
  // ── subcommand 분기 (logs / status) ───────────────────
1997
2047
  const knownSubcmds = new Set(["logs", "status"]);
@@ -2055,9 +2105,15 @@ ${BOLD}Examples:${RESET}
2055
2105
 
2056
2106
  for (let i = 0; i < deployArgs.length; i++) {
2057
2107
  const a = deployArgs[i];
2058
- if (a === "--prod") envTarget = "prod";
2108
+ if (a === "--prod") {
2109
+ // deprecated: deploy는 이미 prod 기본값
2110
+ warn(`${YELLOW}--prod 플래그는 더 이상 필요하지 않습니다.${RESET}`);
2111
+ info(`gencow deploy는 기본적으로 프로덕션에 배포합니다.`);
2112
+ envTarget = "prod";
2113
+ }
2059
2114
  else if (a === "--force" || a === "-f") forceDeploy = true;
2060
2115
  else if (a === "--no-backend") noBackend = true;
2116
+ else if (a === "--rollback") isRollback = true;
2061
2117
  else if (a === "--app" || a === "-a") appId = deployArgs[++i];
2062
2118
  else if (a === "--static") {
2063
2119
  isStatic = true;
@@ -2073,21 +2129,30 @@ ${BOLD}Examples:${RESET}
2073
2129
  error(`알 수 없는 deploy 인자: "${a}"`);
2074
2130
  log("");
2075
2131
  info(`사용법: gencow deploy [옵션]`);
2076
- info(` gencow deploy 백엔드 배포`);
2077
- info(` gencow deploy --static 정적 파일 배포`);
2078
- info(` gencow deploy --prod 프로덕션 배포`);
2132
+ info(` gencow deploy 프로덕션 배포 (Pro+)`);
2133
+ info(` gencow deploy --static 프로덕션 정적 배포 (Pro+)`);
2134
+ info(` gencow deploy --rollback 이전 버전으로 롤백`);
2079
2135
  info(` gencow deploy logs 서버 로그 조회`);
2080
2136
  info(` gencow deploy status 앱 상태 확인`);
2081
2137
  info(` gencow deploy --force 의존성 감사 스킵`);
2138
+ log("");
2139
+ info(`💡 개발 환경 배포:`);
2140
+ info(` gencow dev 백엔드 실시간 배포`);
2141
+ info(` gencow static [dir] 정적 파일 배포`);
2082
2142
  process.exit(1);
2083
2143
  }
2084
2144
  }
2085
2145
 
2086
- // gencow.json에서 appId 로드
2146
+ // gencow.json에서 appId + prodApp 로드
2087
2147
  const gencowJsonPath = resolve(process.cwd(), "gencow.json");
2148
+ let prodAppId = null;
2088
2149
  if (!appId && existsSync(gencowJsonPath)) {
2089
2150
  const gencowJson = JSON.parse(readFileSync(gencowJsonPath, "utf8"));
2090
2151
  appId = gencowJson.appId || gencowJson.appName; // 하위 호환
2152
+ prodAppId = gencowJson.prodApp || null;
2153
+ } else if (existsSync(gencowJsonPath)) {
2154
+ const gencowJson = JSON.parse(readFileSync(gencowJsonPath, "utf8"));
2155
+ prodAppId = gencowJson.prodApp || null;
2091
2156
  }
2092
2157
 
2093
2158
  // 프로젝트명은 display용으로만 사용 (appId가 아님)
@@ -2099,9 +2164,134 @@ ${BOLD}Examples:${RESET}
2099
2164
  }
2100
2165
  if (!displayName) displayName = basename(process.cwd());
2101
2166
 
2167
+ // ── Rollback 분기 ─────────────────────────────────────
2168
+ if (isRollback) {
2169
+ // prodApp이 있으면 prod 대상 롤백, 없으면 dev 대상 롤백
2170
+ const rollbackTarget = prodAppId || appId;
2171
+ if (!rollbackTarget) {
2172
+ error("앱 ID를 찾을 수 없습니다. gencow.json이 있는 프로젝트 루트에서 실행하세요.");
2173
+ process.exit(1);
2174
+ }
2175
+
2176
+ log(`\n${BOLD}${CYAN}Gencow Rollback${RESET}\n`);
2177
+ info(`앱: ${rollbackTarget}${prodAppId ? " (prod)" : ""}`);
2178
+ log("");
2179
+
2180
+ const rollbackStartTime = Date.now();
2181
+ const spinnerFrames = ["⣾", "⣽", "⣻", "⢿", "⡿", "⣟", "⣯", "⣷"];
2182
+ let spinnerIdx = 0;
2183
+ const spinner = setInterval(() => {
2184
+ const elapsed = ((Date.now() - rollbackStartTime) / 1000).toFixed(0);
2185
+ process.stdout.write(`\r ${CYAN}${spinnerFrames[spinnerIdx++ % spinnerFrames.length]}${RESET} 롤백 중... ${DIM}(${elapsed}s)${RESET} `);
2186
+ }, 120);
2187
+
2188
+ const rollbackRes = await platformFetch(creds, `/platform/apps/${rollbackTarget}/rollback`, {
2189
+ method: "POST",
2190
+ headers: { "Content-Type": "application/json" },
2191
+ });
2192
+
2193
+ clearInterval(spinner);
2194
+ process.stdout.write("\r" + " ".repeat(50) + "\r");
2195
+
2196
+ if (!rollbackRes.ok) {
2197
+ const errData = await rollbackRes.json().catch(() => ({}));
2198
+ error(`롤백 실패: ${errData.error || rollbackRes.statusText}`);
2199
+ process.exit(1);
2200
+ }
2201
+
2202
+ const rollbackData = await rollbackRes.json();
2203
+ const rollbackElapsed = ((Date.now() - rollbackStartTime) / 1000).toFixed(1);
2204
+
2205
+ log("");
2206
+ success(`🔄 롤백 완료! (${rollbackElapsed}s)`);
2207
+ info(`롤백: #${rollbackData.rolledBackFrom} → #${rollbackData.rolledBackTo}`);
2208
+ info(`번들: ${rollbackData.bundleHash}`);
2209
+ info(`URL: ${rollbackData.url}`);
2210
+ log("");
2211
+ warn(`ℹ️ 코드만 롤백되었습니다. 데이터베이스는 변경되지 않았습니다.`);
2212
+ log("");
2213
+ return;
2214
+ }
2215
+
2102
2216
  // ── Static Deploy 분기 ────────────────────────────────
2103
2217
  if (isStatic) {
2104
- return await this._deployStatic(creds, appId, displayName, staticDir, gencowJsonPath, { forceDeploy, noBackend, envTarget });
2218
+ // prod 모드면 prodApp 대상으로 정적 배포
2219
+ const staticTarget = (envTarget === "prod" && prodAppId) ? prodAppId : appId;
2220
+ return await this._deployStatic(creds, staticTarget, displayName, staticDir, gencowJsonPath, { forceDeploy, noBackend, envTarget });
2221
+ }
2222
+
2223
+ // ── Prod 앱 자동 생성 + 배포 대상 전환 (Pro+) ──────────
2224
+ if (envTarget === "prod" && appId) {
2225
+ if (!prodAppId) {
2226
+ // prod 앱이 없음 — 첫 프로덕션 배포
2227
+ const ciMode = process.env.CI === "true" || deployArgs.includes("--yes");
2228
+ if (!ciMode) {
2229
+ const { createInterface } = await import("readline");
2230
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
2231
+ log("");
2232
+ log(` ${BOLD}🚀 First production deployment!${RESET}`);
2233
+ log(` This will create a production app: ${CYAN}${appId}-prod${RESET}`);
2234
+ log("");
2235
+ const answer = await new Promise(resolve => {
2236
+ rl.question(` ${YELLOW}⚠${RESET} Proceed? (y/N) `, resolve);
2237
+ });
2238
+ rl.close();
2239
+ if (answer.toLowerCase() !== "y") {
2240
+ info("배포 취소됨.");
2241
+ return;
2242
+ }
2243
+ }
2244
+
2245
+ info("Prod 앱 생성 중...");
2246
+ const createProdRes = await platformFetch(creds, `/platform/apps/${appId}/create-prod`, {
2247
+ method: "POST",
2248
+ headers: { "Content-Type": "application/json" },
2249
+ });
2250
+
2251
+ if (!createProdRes.ok) {
2252
+ const errData = await createProdRes.json().catch(() => ({}));
2253
+ if (createProdRes.status === 403) {
2254
+ log("");
2255
+ log(` ${RED}⛔ Production Deploy — Pro 플랜 이상 필요${RESET}`);
2256
+ log("");
2257
+ log(` ${DIM}gencow deploy는 프로덕션 환경에 배포하는 명령어입니다.${RESET}`);
2258
+ log("");
2259
+ log(` ${BOLD}💡 개발 환경 배포:${RESET}`);
2260
+ log(` ${GREEN}gencow dev${RESET} ${DIM}← 백엔드 실시간 배포 + 라이브 로그${RESET}`);
2261
+ log(` ${GREEN}gencow static dist/${RESET} ${DIM}← 정적 파일(dist/) 배포${RESET}`);
2262
+ log("");
2263
+ log(` ${BOLD}🚀 프로덕션 배포 잠금 해제:${RESET}`);
2264
+ log(` ${GREEN}gencow upgrade${RESET} ${DIM}← Pro 플랜으로 업그레이드${RESET}`);
2265
+ log("");
2266
+ } else {
2267
+ error(`Prod 앱 생성 실패: ${errData.error || createProdRes.statusText}`);
2268
+ }
2269
+ process.exit(1);
2270
+ }
2271
+
2272
+ const createProdData = await createProdRes.json();
2273
+ prodAppId = createProdData.prodApp;
2274
+
2275
+ // gencow.json 업데이트
2276
+ const gencowJson = existsSync(gencowJsonPath)
2277
+ ? JSON.parse(readFileSync(gencowJsonPath, "utf8"))
2278
+ : {};
2279
+ gencowJson.prodApp = prodAppId;
2280
+ writeFileSync(gencowJsonPath, JSON.stringify(gencowJson, null, 2));
2281
+
2282
+ if (createProdData.alreadyExists) {
2283
+ info(`Prod 앱 확인: ${prodAppId}`);
2284
+ } else {
2285
+ success(`Prod 앱 생성 완료: ${prodAppId}`);
2286
+ info(`URL: ${createProdData.url}`);
2287
+ // 프로비저닝 대기
2288
+ await new Promise(r => setTimeout(r, 3000));
2289
+ }
2290
+ }
2291
+
2292
+ // 배포 대상을 prod 앱으로 전환
2293
+ appId = prodAppId;
2294
+ info(`배포 대상: ${CYAN}${appId}${RESET} (production)`);
2105
2295
  }
2106
2296
 
2107
2297
  log(`\n${BOLD}${CYAN}Gencow Deploy${RESET}\n`);
@@ -2114,20 +2304,6 @@ ${BOLD}Examples:${RESET}
2114
2304
  info(`포맷: tar.gz`);
2115
2305
  log("");
2116
2306
 
2117
- // 프로덕션 배포 확인
2118
- if (envTarget === "prod") {
2119
- const { createInterface } = await import("readline");
2120
- const rl = createInterface({ input: process.stdin, output: process.stdout });
2121
- const answer = await new Promise(resolve => {
2122
- rl.question(` ${YELLOW}⚠${RESET} 프로덕션 배포를 진행하시겠습니까? (y/N) `, resolve);
2123
- });
2124
- rl.close();
2125
- if (answer.toLowerCase() !== "y") {
2126
- info("배포 취소됨.");
2127
- return;
2128
- }
2129
- }
2130
-
2131
2307
  // 0-1. drizzle-kit generate 자동 실행 (번들링 전 migrations/ 최신화)
2132
2308
  {
2133
2309
  const { execSync: execGen } = await import("child_process");
@@ -2521,7 +2697,7 @@ ${BOLD}Examples:${RESET}
2521
2697
  if (!targetDir || !existsSync(resolve(process.cwd(), targetDir))) {
2522
2698
  error(`정적 파일 폴더를 찾을 수 없습니다.`);
2523
2699
  info(`자동 감지 순서: ${AUTO_DETECT.join(", ")}`);
2524
- info(`수동 지정: gencow deploy --static <dir>`);
2700
+ info(`수동 지정: gencow static <dir>`);
2525
2701
  process.exit(1);
2526
2702
  }
2527
2703
 
@@ -2946,9 +3122,19 @@ ${BOLD}Examples:${RESET}
2946
3122
  if (existsSync(gencowJsonPath)) {
2947
3123
  const gencowJson = JSON.parse(readFileSync(gencowJsonPath, "utf8"));
2948
3124
  appId = gencowJson.appId || gencowJson.appName; // 하위 호환
3125
+ // --prod 시 prod 앱으로 대상 전환
3126
+ if (envTarget === "prod" && gencowJson.prodApp) {
3127
+ appId = gencowJson.prodApp;
3128
+ }
2949
3129
  }
2950
3130
  }
2951
3131
 
3132
+ // --prod인데 prod 앱이 없는 경우
3133
+ if (envTarget === "prod" && appId && !appId.endsWith("-prod")) {
3134
+ error("Prod 앱이 아직 없습니다. gencow deploy를 먼저 실행하세요.");
3135
+ return;
3136
+ }
3137
+
2952
3138
  if (!appId) {
2953
3139
  error("앱 ID를 찾을 수 없습니다. gencow deploy를 먼저 실행하세요.");
2954
3140
  return;
@@ -3083,6 +3269,138 @@ ${BOLD}Examples:${RESET}
3083
3269
  }
3084
3270
  },
3085
3271
 
3272
+ // ── config ────────────────────────────────────────────
3273
+ async config(...configArgs) {
3274
+ const creds = requireCreds();
3275
+ const subCmd = configArgs[0] || "help";
3276
+ const restArgs = configArgs.slice(1);
3277
+
3278
+ // 앱 ID 결정
3279
+ let appId = null;
3280
+ for (let i = 0; i < restArgs.length; i++) {
3281
+ if (restArgs[i] === "--app" || restArgs[i] === "-a") appId = restArgs[++i];
3282
+ }
3283
+
3284
+ if (!appId) {
3285
+ const gencowJsonPath = resolve(process.cwd(), "gencow.json");
3286
+ if (existsSync(gencowJsonPath)) {
3287
+ const gencowJson = JSON.parse(readFileSync(gencowJsonPath, "utf8"));
3288
+ appId = gencowJson.appId || gencowJson.appName;
3289
+ }
3290
+ }
3291
+
3292
+ if (subCmd === "help" || subCmd === "--help" || subCmd === "-h") {
3293
+ log(`\n${BOLD}${CYAN}gencow config${RESET} — 앱 설정 관리\n`);
3294
+ log(` ${CYAN}set${RESET} image.maxWidth <값> Auto WebP 최대 폭 (px)`);
3295
+ log(` ${CYAN}set${RESET} image.quality <값> Auto WebP 품질 (1-100)`);
3296
+ log(` ${CYAN}get${RESET} image 현재 이미지 설정 조회`);
3297
+ log(` ${CYAN}reset${RESET} image 이미지 설정 초기화 (Tier 기본값)\n`);
3298
+ log(` ${DIM}옵션:${RESET}`);
3299
+ log(` ${DIM}--app, -a <앱이름> 대상 앱 지정 (기본: gencow.json)${RESET}\n`);
3300
+ return;
3301
+ }
3302
+
3303
+ if (!appId) {
3304
+ error("앱 ID를 찾을 수 없습니다. gencow deploy를 먼저 실행하세요.");
3305
+ return;
3306
+ }
3307
+
3308
+ switch (subCmd) {
3309
+ case "set": {
3310
+ const key = restArgs.find((a, i) => !a.startsWith("-") && !(restArgs[i - 1] === "--app" || restArgs[i - 1] === "-a"));
3311
+ const val = restArgs[restArgs.indexOf(key) + 1];
3312
+
3313
+ if (!key || val === undefined) {
3314
+ error("사용법: gencow config set image.maxWidth <값>");
3315
+ return;
3316
+ }
3317
+
3318
+ const imageConfig = {};
3319
+ if (key === "image.maxWidth" || key === "image.max-width") {
3320
+ const v = parseInt(val);
3321
+ if (isNaN(v) || v < 0 || v > 10000) {
3322
+ error("maxWidth는 0~10000 사이의 값이어야 합니다. (0 = 리셋)");
3323
+ return;
3324
+ }
3325
+ imageConfig.autoMaxWidth = v;
3326
+ } else if (key === "image.quality") {
3327
+ const v = parseInt(val);
3328
+ if (isNaN(v) || v < 0 || v > 100) {
3329
+ error("quality는 0~100 사이의 값이어야 합니다. (0 = 리셋)");
3330
+ return;
3331
+ }
3332
+ imageConfig.autoQuality = v;
3333
+ } else {
3334
+ error(`알 수 없는 설정 키: ${key}`);
3335
+ info("사용 가능: image.maxWidth, image.quality");
3336
+ return;
3337
+ }
3338
+
3339
+ const res = await rpcMutation(creds, "apps.updateImageConfig", { name: appId, imageConfig });
3340
+ if (res.ok) {
3341
+ const data = await res.json();
3342
+ success(`${key} = ${val} 설정 완료`);
3343
+ if (data.imageConfig) {
3344
+ info(`현재 설정: ${JSON.stringify(data.imageConfig)}`);
3345
+ }
3346
+ info(`${DIM}※ 다음 이미지 요청부터 적용됩니다. 기존 캐시는 별도 키로 저장됩니다.${RESET}`);
3347
+ } else {
3348
+ const errData = await res.json().catch(() => ({}));
3349
+ error(`설정 실패: ${errData.error || res.statusText}`);
3350
+ }
3351
+ break;
3352
+ }
3353
+
3354
+ case "get": {
3355
+ const key = restArgs.find(a => !a.startsWith("-"));
3356
+ if (key && key !== "image") {
3357
+ error(`알 수 없는 설정 키: ${key}`);
3358
+ info("사용 가능: image");
3359
+ return;
3360
+ }
3361
+
3362
+ const res = await rpcQuery(creds, "apps.getImageConfig", { name: appId });
3363
+ if (res.ok) {
3364
+ const config = await res.json();
3365
+ log(`\n${BOLD}${CYAN}이미지 설정${RESET} — ${appId}\n`);
3366
+ if (Object.keys(config).length === 0) {
3367
+ info("커스텀 설정 없음 (Tier 기본값 사용)");
3368
+ } else {
3369
+ if (config.autoMaxWidth) log(` ${GREEN}autoMaxWidth${RESET} ${config.autoMaxWidth} px`);
3370
+ if (config.autoQuality) log(` ${GREEN}autoQuality${RESET} ${config.autoQuality}`);
3371
+ }
3372
+ log("");
3373
+ } else {
3374
+ error("설정 조회 실패");
3375
+ }
3376
+ break;
3377
+ }
3378
+
3379
+ case "reset": {
3380
+ const key = restArgs.find(a => !a.startsWith("-"));
3381
+ if (key && key !== "image") {
3382
+ error(`알 수 없는 설정 키: ${key}`);
3383
+ return;
3384
+ }
3385
+
3386
+ // autoMaxWidth=0 + autoQuality=0 → 둘 다 리셋
3387
+ const res = await rpcMutation(creds, "apps.updateImageConfig", {
3388
+ name: appId,
3389
+ imageConfig: { autoMaxWidth: 0, autoQuality: 0 },
3390
+ });
3391
+ if (res.ok) {
3392
+ success("이미지 설정 초기화 완료 (Tier 기본값 사용)");
3393
+ } else {
3394
+ error("초기화 실패");
3395
+ }
3396
+ break;
3397
+ }
3398
+
3399
+ default:
3400
+ error(`알 수 없는 하위 명령: ${subCmd}`);
3401
+ info("사용법: gencow config [set|get|reset] ...");
3402
+ }
3403
+ },
3086
3404
 
3087
3405
  // ── files ─────────────────────────────────────────────
3088
3406
  async files(...filesArgs) {