gencow 0.1.74 → 0.1.76
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 +398 -425
- package/package.json +1 -1
package/bin/gencow.mjs
CHANGED
|
@@ -23,6 +23,7 @@ import { resolve, dirname, basename } from "path";
|
|
|
23
23
|
import { homedir } from "os";
|
|
24
24
|
import { fileURLToPath } from "url";
|
|
25
25
|
import { createRequire } from "module";
|
|
26
|
+
import { buildReadmeMarkdown, extractComponentsBlock } from "../lib/readme-codegen.mjs";
|
|
26
27
|
|
|
27
28
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
28
29
|
|
|
@@ -416,314 +417,21 @@ process.exit(0);
|
|
|
416
417
|
// Pure logic lives in lib/readme-codegen.mjs (testable).
|
|
417
418
|
// This wrapper handles only file I/O.
|
|
418
419
|
|
|
420
|
+
|
|
419
421
|
function generateReadmeMd(config, apiObj) {
|
|
420
422
|
const absoluteFunctions = resolve(process.cwd(), config.functionsDir);
|
|
421
423
|
const readmePath = resolve(absoluteFunctions, "README.md");
|
|
422
424
|
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
const namespaces = Object.keys(apiObj);
|
|
426
|
-
|
|
427
|
-
let md = `# Gencow API Guide\n`;
|
|
428
|
-
md += `> ⚡ Auto-generated by Gencow CLI — do not edit manually.\n`;
|
|
429
|
-
md += `> Last updated: ${now}\n`;
|
|
430
|
-
md += `> 📚 Gencow 공식 문서 (전체 참조): https://docs.gencow.com/llms-full.txt\n\n`;
|
|
431
|
-
md += `---\n\n`;
|
|
432
|
-
|
|
433
|
-
// ── 1. API Reference Table ─────────────────────────────
|
|
434
|
-
md += `## 📦 Available APIs\n\n`;
|
|
435
|
-
for (const [ns, fns] of Object.entries(apiObj)) {
|
|
436
|
-
md += `### \`${ns}\`\n\n`;
|
|
437
|
-
md += `| Function | Type | Description |\n`;
|
|
438
|
-
md += `| :--- | :--- | :--- |\n`;
|
|
439
|
-
for (const q of fns.queries) {
|
|
440
|
-
md += `| \`api.${ns}.${q}\` | \`query\` | Fetch ${ns} data |\n`;
|
|
441
|
-
}
|
|
442
|
-
for (const m of fns.mutations) {
|
|
443
|
-
md += `| \`api.${ns}.${m}\` | \`mutation\` | Modify ${ns} data |\n`;
|
|
444
|
-
}
|
|
445
|
-
md += `\n`;
|
|
446
|
-
}
|
|
447
|
-
|
|
448
|
-
// ── 1-1. React 사용법 (Primary — 최상단 배치) ────────────
|
|
449
|
-
md += `---\n\n## ⚡ 데이터 사용법 (필수 — 반드시 이 방식을 사용하세요)\n\n`;
|
|
450
|
-
md += `> ⚠️ **\`useQuery\` / \`useMutation\` Hook을 반드시 사용하세요.**\n`;
|
|
451
|
-
md += `> Gencow는 WebSocket 실시간 동기화를 내장하고 있어, Hook을 사용하면 데이터가 **자동으로 동기화**됩니다.\n`;
|
|
452
|
-
md += `> \`fetch()\`를 직접 호출하거나 \`apiPost()\` 같은 래퍼를 만들지 마세요.\n\n`;
|
|
453
|
-
md += `\`\`\`typescript\n`;
|
|
454
|
-
md += `import { api } from "@/gencow/api"; // 자동 생성됨\n`;
|
|
455
|
-
md += `import { useQuery, useMutation } from "@gencow/react";\n\n`;
|
|
456
|
-
for (const [ns, fns] of Object.entries(apiObj)) {
|
|
457
|
-
if (fns.queries.length > 0) {
|
|
458
|
-
md += `// ${ns} 데이터 조회 (실시간 자동 갱신)\n`;
|
|
459
|
-
md += `const ${ns} = useQuery(api.${ns}.${fns.queries[0]}); // ${ns.charAt(0).toUpperCase() + ns.slice(1)}[] | undefined\n`;
|
|
460
|
-
}
|
|
461
|
-
if (fns.mutations.length > 0) {
|
|
462
|
-
md += `// ${ns} 데이터 변경 (로딩/에러 자동 관리)\n`;
|
|
463
|
-
md += `const [${fns.mutations[0]}${ns.charAt(0).toUpperCase() + ns.slice(1)}, isPending] = useMutation(api.${ns}.${fns.mutations[0]});\n`;
|
|
464
|
-
}
|
|
465
|
-
md += `\n`;
|
|
466
|
-
}
|
|
467
|
-
md += `\`\`\`\n\n`;
|
|
468
|
-
md += `### ❌ 절대 하지 마세요\n\n`;
|
|
469
|
-
md += `\`\`\`typescript\n`;
|
|
470
|
-
md += `// ❌ fetch()로 직접 API 호출하지 마세요\n`;
|
|
471
|
-
md += `fetch("/api/query", { body: JSON.stringify({ name: "tasks.create", args: {...} }) })\n`;
|
|
472
|
-
md += `apiPost("tasks/create", { ... }) // ❌ 래퍼도 만들지 마세요\n\n`;
|
|
473
|
-
md += `// ✅ useMutation을 사용하세요 — 실시간 동기화 + 로딩 상태 자동 관리\n`;
|
|
474
|
-
md += `const [create] = useMutation(api.tasks.create);\n`;
|
|
475
|
-
md += `await create({ title: "새 태스크" });\n`;
|
|
476
|
-
md += `// → 서버가 WebSocket으로 useQuery를 자동 갱신 — fetchTasks() 같은 수동 리페치 불필요!\n`;
|
|
477
|
-
md += `\`\`\`\n\n`;
|
|
478
|
-
md += `### 조건부 쿼리 (skip/enabled)\n\n`;
|
|
479
|
-
md += `선택된 항목이 없을 때 등 조건부로 쿼리를 건너뛰어야 할 때:\n\n`;
|
|
480
|
-
md += `\`\`\`typescript\n`;
|
|
481
|
-
md += `// 방법 A: "skip" 토큰 (Convex 스타일) — 추천\n`;
|
|
482
|
-
md += `const messages = useQuery(api.chat.getMessages,\n`;
|
|
483
|
-
md += ` conversationId ? { conversationId } : "skip"\n`;
|
|
484
|
-
md += `);\n\n`;
|
|
485
|
-
md += `// 방법 B: enabled 옵션 (TanStack Query 스타일)\n`;
|
|
486
|
-
md += `const messages = useQuery(api.chat.getMessages,\n`;
|
|
487
|
-
md += ` { conversationId },\n`;
|
|
488
|
-
md += ` { enabled: !!conversationId }\n`;
|
|
489
|
-
md += `);\n`;
|
|
490
|
-
md += `// → skip 상태에서는 API 호출 없이 undefined 반환\n`;
|
|
491
|
-
md += `\`\`\`\n\n`;
|
|
492
|
-
|
|
493
|
-
// ── 1-2. Auth ────────────────────────────────────────────
|
|
494
|
-
md += `---\n\n## 🔐 인증 (better-auth, 세션 기반)\n\n`;
|
|
495
|
-
md += `| 엔드포인트 | 메서드 | 설명 |\n`;
|
|
496
|
-
md += `| :--- | :--- | :--- |\n`;
|
|
497
|
-
md += `| \`/api/auth/sign-up/email\` | POST | 회원가입 (\`{ email, password, name }\`) |\n`;
|
|
498
|
-
md += `| \`/api/auth/sign-in/email\` | POST | 로그인 (\`{ email, password }\`) |\n`;
|
|
499
|
-
md += `| \`/api/auth/sign-out\` | POST | 로그아웃 |\n\n`;
|
|
500
|
-
md += `> ⚠️ 모든 API 요청에 \`credentials: "include"\`를 포함해야 합니다 (세션 쿠키 전송)\n`;
|
|
501
|
-
md += `> ⚠️ JWT 토큰이나 \`/auth/register\` 같은 커스텀 경로를 만들지 마세요.\n\n`;
|
|
502
|
-
md += `### Next.js 프론트엔드 연동 (필수)\n\n`;
|
|
503
|
-
md += `프론트엔드(localhost:3000)와 백엔드(localhost:5456)가 다른 포트에서 실행되므로,\n`;
|
|
504
|
-
md += `\`next.config.ts\`에 rewrites 프록시를 설정해야 인증이 작동합니다:\n\n`;
|
|
505
|
-
md += `\`\`\`typescript\n`;
|
|
506
|
-
md += `// next.config.ts\n`;
|
|
507
|
-
md += `const nextConfig = {\n`;
|
|
508
|
-
md += ` async rewrites() {\n`;
|
|
509
|
-
md += ` return [\n`;
|
|
510
|
-
md += ` { source: "/api/:path*", destination: "http://localhost:5456/api/:path*" },\n`;
|
|
511
|
-
md += ` { source: "/ws", destination: "http://localhost:5456/ws" },\n`;
|
|
512
|
-
md += ` ];\n`;
|
|
513
|
-
md += ` },\n`;
|
|
514
|
-
md += `};\n`;
|
|
515
|
-
md += `\`\`\`\n\n`;
|
|
516
|
-
md += `또는 \`gencowAuth()\`에 명시적으로 baseUrl을 전달하세요:\n\n`;
|
|
517
|
-
md += `\`\`\`typescript\n`;
|
|
518
|
-
md += `const { signIn, useAuth } = gencowAuth(import.meta.env.VITE_API_URL || "http://localhost:5456");\n`;
|
|
519
|
-
md += `\`\`\`\n\n`;
|
|
520
|
-
|
|
521
|
-
// ── 1-3. RPC 직접 호출 (접힘 — 비-React 환경용) ──────────
|
|
522
|
-
md += `---\n\n`;
|
|
523
|
-
md += `<details>\n`;
|
|
524
|
-
md += `<summary>📡 RPC 직접 호출 (Node.js / cURL / 비-React 환경)</summary>\n\n`;
|
|
525
|
-
md += `> ⚠️ React에서는 이 방법을 사용하지 마세요. 위의 \`useQuery\`/\`useMutation\`을 사용하세요.\n\n`;
|
|
526
|
-
md += `\`\`\`typescript\n`;
|
|
527
|
-
md += `// Query 호출 (self-fetch: 서버 내부에서 다른 함수 호출 시)\n`;
|
|
528
|
-
md += `const baseUrl = process.env.GENCOW_INTERNAL_URL || "http://localhost:5456";\n`;
|
|
529
|
-
md += `const res = await fetch(\`\${baseUrl}/api/query\`, {\n`;
|
|
530
|
-
md += ` method: "POST",\n`;
|
|
531
|
-
md += ` headers: { "Content-Type": "application/json" },\n`;
|
|
532
|
-
md += ` credentials: "include",\n`;
|
|
533
|
-
if (namespaces.length > 0 && Object.values(apiObj)[0]?.queries?.[0]) {
|
|
534
|
-
md += ` body: JSON.stringify({ name: "${namespaces[0]}.${Object.values(apiObj)[0].queries[0]}", args: {} }),\n`;
|
|
535
|
-
} else {
|
|
536
|
-
md += ` body: JSON.stringify({ name: "namespace.functionName", args: {} }),\n`;
|
|
537
|
-
}
|
|
538
|
-
md += `});\n\n`;
|
|
539
|
-
if (Object.values(apiObj)[0]?.mutations?.length > 0) {
|
|
540
|
-
const firstMut = Object.values(apiObj)[0].mutations[0];
|
|
541
|
-
md += `// Mutation 호출\n`;
|
|
542
|
-
md += `const res = await fetch(\`\${baseUrl}/api/mutation\`, {\n`;
|
|
543
|
-
md += ` method: "POST",\n`;
|
|
544
|
-
md += ` headers: { "Content-Type": "application/json" },\n`;
|
|
545
|
-
md += ` credentials: "include",\n`;
|
|
546
|
-
md += ` body: JSON.stringify({ name: "${namespaces[0]}.${firstMut}", args: { /* ... */ } }),\n`;
|
|
547
|
-
md += `});\n`;
|
|
548
|
-
}
|
|
549
|
-
md += `\`\`\`\n\n`;
|
|
550
|
-
md += `</details>\n\n`;
|
|
551
|
-
|
|
552
|
-
// ── 3. Copy-Paste AI Prompt ────────────────────────────
|
|
553
|
-
md += `---\n\n## 🤖 AI Vibe-Coding Prompt (복사해서 AI에게 전달)\n\n`;
|
|
554
|
-
md += `> 아래 프롬프트를 Cursor / Claude / ChatGPT에 붙여넣으세요.\n\n`;
|
|
555
|
-
md += `\`\`\`\n`;
|
|
556
|
-
md += `다음은 현재 Gencow 백엔드에 등록된 API 구조야:\n\n`;
|
|
557
|
-
for (const [ns, fns] of Object.entries(apiObj)) {
|
|
558
|
-
md += `[${ns}]\n`;
|
|
559
|
-
for (const q of fns.queries) md += ` - api.${ns}.${q} (query)\n`;
|
|
560
|
-
for (const m of fns.mutations) md += ` - api.${ns}.${m} (mutation)\n`;
|
|
561
|
-
}
|
|
562
|
-
md += `\n`;
|
|
563
|
-
md += `React Hook 사용법:\n`;
|
|
564
|
-
md += ` useQuery(api.namespace.fnName) // 데이터 구독 (실시간 자동 갱신)\n`;
|
|
565
|
-
md += ` useMutation(api.namespace.fnName) // 데이터 변경 (로딩/에러 자동 관리)\n`;
|
|
566
|
-
md += `\n`;
|
|
567
|
-
md += `⚠️ 중요 규칙:\n`;
|
|
568
|
-
md += `- 반드시 useQuery와 useMutation을 사용해서 데이터와 연결해줘.\n`;
|
|
569
|
-
md += `- fetch()를 직접 호출하거나 apiPost() 같은 래퍼를 만들지 마.\n`;
|
|
570
|
-
md += `- gencow/api.ts는 자동 생성된 파일이야. 수동으로 만들지 마.\n`;
|
|
571
|
-
md += `- gencow/index.ts의 re-export는 export * as moduleName from "./moduleName" 패턴을 써.\n`;
|
|
572
|
-
md += ` Module, Mod 같은 접미사를 붙이지 마.\n`;
|
|
573
|
-
md += `\n`;
|
|
574
|
-
md += `⚠️ mutation 제한:\n`;
|
|
575
|
-
md += `- mutation은 10초 이내에 완료되어야 해. 외부 API나 LLM 호출이 길면 단계별로 분리해.\n`;
|
|
576
|
-
md += `- 긴 작업은 ctx.scheduler.runAfter(0, "module.nextStep", { sessionId }) 로 다음 단계를 예약.\n`;
|
|
577
|
-
md += `- 예: 크롤링(Step1) → 필터링(Step2) → 요약(Step3) 각각 별도 mutation으로 분리.\n`;
|
|
578
|
-
md += `- 같은 서버의 다른 모듈 함수를 호출할 때는 fetch()가 아닌 직접 import해서 호출해.\n`;
|
|
579
|
-
md += ` 예: import { fetchNews } from "./naverApi"; → const result = await fetchNews.handler(ctx, { keyword });\n`;
|
|
580
|
-
md += ` HTTP self-fetch (fetch("/api/mutation")) 패턴은 불필요한 네트워크 우회이므로 사용하지 마.\n`;
|
|
581
|
-
md += `\n`;
|
|
582
|
-
md += `크론 잡 (예약 작업):\n`;
|
|
583
|
-
md += ` gencow/crons.ts에서 cronJobs()로 선언\n`;
|
|
584
|
-
md += ` crons.interval("name", { minutes: N }, "module.mutation")\n`;
|
|
585
|
-
md += ` crons.daily("name", { hour: H }, "module.mutation")\n`;
|
|
586
|
-
md += ` crons.weekly("name", { dayOfWeek: D, hour: H }, "module.mutation")\n`;
|
|
587
|
-
md += ` crons.cron("name", "cron-expression", "module.mutation")\n`;
|
|
588
|
-
md += ` 반드시 export default crons 필요\n`;
|
|
589
|
-
md += `\n`;
|
|
590
|
-
md += `위 API를 기반으로 Next.js + Tailwind CSS UI 컴포넌트를 만들어줘.\n`;
|
|
591
|
-
md += `- TypeScript 타입을 최대한 활용하고, 로딩/에러 상태도 처리해줘.\n`;
|
|
592
|
-
md += `- ctx.ai.chat()을 사용해서 AI를 호출하고, OpenAI SDK를 직접 설치하지 마.\n`;
|
|
593
|
-
md += `\n`;
|
|
594
|
-
md += `배포 규칙:\n`;
|
|
595
|
-
md += `- 백엔드: \`npx gencow deploy\` (gencow/ 폴더만 배포됨. 프론트엔드는 포함 안 됨)\n`;
|
|
596
|
-
md += `- 프론트엔드: VITE_API_URL=https://{앱ID}.gencow.app npm run build 후 \`npx gencow deploy --static dist/\`\n`;
|
|
597
|
-
md += `- gencow deploy는 프론트엔드를 포함하지 않아. 반드시 별도로 --static 배포해.\n`;
|
|
598
|
-
md += `- 환경변수는 \`npx gencow env set KEY=VALUE\`로 관리해.\n`;
|
|
599
|
-
md += `- .env 파일은 로컬 개발 전용이야. 서버에는 gencow env push로 올려.\n`;
|
|
600
|
-
md += `\`\`\`\n\n`;
|
|
601
|
-
|
|
602
|
-
// ── 4. AI 사용법 ────────────────────────────────────
|
|
603
|
-
md += `---\n\n## 🤖 AI 사용법 (ctx.ai)\n\n`;
|
|
604
|
-
md += `> Gencow는 OpenAI GPT-4o/4o-mini를 내장 지원합니다.\n`;
|
|
605
|
-
md += `> API 키 관리, 과금, 보안을 플랫폼이 자동 처리합니다.\n\n`;
|
|
606
|
-
md += `### 백엔드에서 AI 호출\n\n`;
|
|
607
|
-
md += `\`\`\`typescript\n`;
|
|
608
|
-
md += `// gencow/ai.ts\n`;
|
|
609
|
-
md += `import { mutation, v } from "@gencow/core";\n\n`;
|
|
610
|
-
md += `export const chat = mutation("ai.chat", {\n`;
|
|
611
|
-
md += ` args: { message: v.string() },\n`;
|
|
612
|
-
md += ` handler: async (ctx, args) => {\n`;
|
|
613
|
-
md += ` const result = await ctx.ai.chat({\n`;
|
|
614
|
-
md += ` model: "gpt-4o-mini", // 또는 "gpt-4o"\n`;
|
|
615
|
-
md += ` messages: [{ role: "user", content: args.message }],\n`;
|
|
616
|
-
md += ` });\n`;
|
|
617
|
-
md += ` return result.text;\n`;
|
|
618
|
-
md += ` },\n`;
|
|
619
|
-
md += `});\n`;
|
|
620
|
-
md += `\`\`\`\n\n`;
|
|
621
|
-
md += `### 로컬 개발 설정\n\n`;
|
|
622
|
-
md += `로컬에서 AI 기능을 테스트하려면 \`.env\`에 키를 추가하세요:\n\n`;
|
|
623
|
-
md += `\`\`\`\n# .env\nOPENAI_API_KEY=sk-your-key-here\n\`\`\`\n\n`;
|
|
624
|
-
md += `> ⚠️ 배포 후에는 플랫폼 키가 자동 사용됩니다. .env의 키는 로컬 전용입니다.\n\n`;
|
|
625
|
-
md += `### ❌ 하지 마세요\n\n`;
|
|
626
|
-
md += `\`\`\`typescript\n`;
|
|
627
|
-
md += `// ❌ OpenAI SDK를 직접 설치하지 마세요\n`;
|
|
628
|
-
md += `import OpenAI from "openai";\n`;
|
|
629
|
-
md += `const openai = new OpenAI({ apiKey: process.env.OPENAI_API_KEY });\n\n`;
|
|
630
|
-
md += `// ✅ ctx.ai.chat()을 사용하세요 — 키 관리 + 과금 자동\n`;
|
|
631
|
-
md += `const result = await ctx.ai.chat({ model: "gpt-4o-mini", messages: [...] });\n`;
|
|
632
|
-
md += `\`\`\`\n\n`;
|
|
633
|
-
|
|
634
|
-
// ── 5. 배포 가이드 ───────────────────────────────────
|
|
635
|
-
md += `---\n\n## 🚀 배포\n\n`;
|
|
636
|
-
md += `### 클라우드 배포 (3단계)\n\n`;
|
|
637
|
-
md += `\`\`\`bash\n`;
|
|
638
|
-
md += `# 1. 로그인 (최초 1회)\n`;
|
|
639
|
-
md += `npx gencow login\n\n`;
|
|
640
|
-
md += `# 2. 배포\n`;
|
|
641
|
-
md += `npx gencow deploy\n\n`;
|
|
642
|
-
md += `# 3. 환경변수 설정 (필요 시)\n`;
|
|
643
|
-
md += `npx gencow env set DATABASE_URL=postgres://...\n`;
|
|
644
|
-
md += `npx gencow env list\n`;
|
|
645
|
-
md += `\`\`\`\n\n`;
|
|
646
|
-
md += `> 배포 후 앱은 \`https://{앱이름}.gencow.app\`에서 접근 가능\n\n`;
|
|
647
|
-
md += `### 환경변수 관리\n\n`;
|
|
648
|
-
md += `| 명령어 | 설명 |\n`;
|
|
649
|
-
md += `| :--- | :--- |\n`;
|
|
650
|
-
md += `| \`gencow env list\` | 현재 환경변수 목록 |\n`;
|
|
651
|
-
md += `| \`gencow env set KEY=VALUE\` | 환경변수 추가/수정 |\n`;
|
|
652
|
-
md += `| \`gencow env unset KEY\` | 환경변수 삭제 |\n`;
|
|
653
|
-
md += `| \`gencow env push\` | 로컬 .env를 서버에 일괄 업로드 |\n\n`;
|
|
654
|
-
|
|
655
|
-
// ── 6. Cron Jobs ──────────────────────────────────────
|
|
656
|
-
md += `---\n\n## ⏰ Cron Jobs (예약 작업)\n\n`;
|
|
657
|
-
md += `\`gencow/crons.ts\` 파일에 선언하면 서버 시작 시 자동 등록됩니다.\n\n`;
|
|
658
|
-
md += `\`\`\`typescript\n`;
|
|
659
|
-
md += `import { cronJobs } from "@gencow/core";\n\n`;
|
|
660
|
-
md += `const crons = cronJobs();\n\n`;
|
|
661
|
-
md += `// 매 30분마다 실행\n`;
|
|
662
|
-
md += `crons.interval("sync", { minutes: 30 }, "data.sync");\n\n`;
|
|
663
|
-
md += `// 매일 오전 9시 실행 (서버 시간)\n`;
|
|
664
|
-
md += `crons.daily("report", { hour: 9 }, "reports.generate");\n\n`;
|
|
665
|
-
md += `// 매주 월요일 오전 10시\n`;
|
|
666
|
-
md += `crons.weekly("weekly", { dayOfWeek: 1, hour: 10 }, "reports.weekly");\n\n`;
|
|
667
|
-
md += `// 커스텀 cron 표현식\n`;
|
|
668
|
-
md += `crons.cron("custom", "0 */2 * * *", "custom.handler");\n\n`;
|
|
669
|
-
md += `export default crons; // ← 필수!\n`;
|
|
670
|
-
md += `\`\`\`\n\n`;
|
|
671
|
-
md += `> ⚠️ \`export default crons\`가 없으면 서버가 cron을 인식하지 않습니다.\n`;
|
|
672
|
-
md += `> ⚠️ action 문자열은 기존 mutation 이름과 정확히 매칭되어야 합니다.\n\n`;
|
|
673
|
-
md += `> 💡 cron 핸들러에서 mutation을 호출하려면 self-fetch를 사용하세요:\n`;
|
|
674
|
-
md += `> \`\`\`typescript\n`;
|
|
675
|
-
md += `> const baseUrl = process.env.GENCOW_INTERNAL_URL || "http://localhost:5456";\n`;
|
|
676
|
-
md += `> await fetch(\`\${baseUrl}/api/mutation\`, { method: "POST", ... });\n`;
|
|
677
|
-
md += `> \`\`\`\n\n`;
|
|
678
|
-
|
|
679
|
-
// ── 7. 배포 ──────────────────────────────────────────────
|
|
680
|
-
md += `---\n\n## 🚀 배포\n\n`;
|
|
681
|
-
md += `### 백엔드 API 배포\n`;
|
|
682
|
-
md += `\`\`\`bash\n`;
|
|
683
|
-
md += `gencow deploy # gencow/ 폴더 → 클라우드 서버 배포\n`;
|
|
684
|
-
md += `\`\`\`\n\n`;
|
|
685
|
-
md += `### 프론트엔드 배포 (frontend/ 있는 경우)\n`;
|
|
686
|
-
md += `\`\`\`bash\n`;
|
|
687
|
-
md += `# 1. 백엔드 URL을 환경변수로 빌드\n`;
|
|
688
|
-
md += `cd frontend\n`;
|
|
689
|
-
md += `VITE_API_URL=https://{앱ID}.gencow.app npm run build\n\n`;
|
|
690
|
-
md += `# 2. 빌드 결과물 정적 배포\n`;
|
|
691
|
-
md += `gencow deploy --static dist/\n`;
|
|
692
|
-
md += `\`\`\`\n\n`;
|
|
693
|
-
md += `### 정적 사이트 전용 (API 없는 경우)\n`;
|
|
694
|
-
md += `\`\`\`bash\n`;
|
|
695
|
-
md += `gencow deploy --static dist/ # 순수 HTML/CSS/JS만 배포\n`;
|
|
696
|
-
md += `\`\`\`\n\n`;
|
|
697
|
-
md += `> ⚠️ 프론트엔드에서 API를 호출하려면 빌드 시 \`VITE_API_URL\`을 반드시 설정하세요.\n\n`;
|
|
698
|
-
md += `### CORS 설정\n\n`;
|
|
699
|
-
md += `- \`*.gencow.app\` 서브도메인 간 요청은 **자동 허용**됩니다.\n`;
|
|
700
|
-
md += `- 커스텀 도메인에서 API를 호출하려면 환경변수를 설정하세요:\n\n`;
|
|
701
|
-
md += `\`\`\`bash\n`;
|
|
702
|
-
md += `gencow env set CORS_ORIGINS=https://myapp.com,https://www.myapp.com\n`;
|
|
703
|
-
md += `\`\`\`\n\n`;
|
|
704
|
-
|
|
705
|
-
// ── 8. Dev Tips ────────────────────────────────────────
|
|
706
|
-
md += `---\n\n## 💡 개발 팁\n\n`;
|
|
707
|
-
md += `- \`gencow/\` 폴더 내 파일을 수정하면 \`api.ts\`와 이 README가 **자동으로 재생성**됩니다.\n`;
|
|
708
|
-
md += `- 스키마 변경 후 \`gencow db:push\`를 실행하면 DB가 즉시 동기화됩니다.\n`;
|
|
709
|
-
md += `- MCP 서버를 사용하면 AI가 이 구조를 자동으로 인식합니다.\n`;
|
|
710
|
-
md += `- 로컬 개발: \`gencow dev\` → \`http://localhost:5456\`\n`;
|
|
711
|
-
md += `- Self-fetch: \`process.env.GENCOW_INTERNAL_URL\` — cron/mutation에서 다른 함수 호출 시 사용\n`;
|
|
712
|
-
md += `- .env 파일은 로컬 전용. 서버에는 \`gencow env push\`로 올리세요.\n`;
|
|
713
|
-
|
|
714
|
-
// ── 5. 기존 컴포넌트 섹션 보존 (gencow add로 추가된 부분) ──
|
|
715
|
-
const COMP_START = "<!-- gencow-components-start -->";
|
|
716
|
-
const COMP_END = "<!-- gencow-components-end -->";
|
|
717
|
-
|
|
425
|
+
// Preserve existing component block (added by `gencow add`)
|
|
426
|
+
let existingComponentsBlock;
|
|
718
427
|
try {
|
|
719
428
|
const existingReadme = readFileSync(readmePath, "utf-8");
|
|
720
|
-
|
|
721
|
-
const startIdx = existingReadme.indexOf(COMP_START);
|
|
722
|
-
const endIdx = existingReadme.indexOf(COMP_END) + COMP_END.length;
|
|
723
|
-
md += "\n" + existingReadme.slice(startIdx, endIdx) + "\n";
|
|
724
|
-
}
|
|
429
|
+
existingComponentsBlock = extractComponentsBlock(existingReadme);
|
|
725
430
|
} catch { /* README doesn't exist yet */ }
|
|
726
431
|
|
|
432
|
+
// Generate README content using the canonical pure function
|
|
433
|
+
const md = buildReadmeMarkdown(apiObj, { existingComponentsBlock });
|
|
434
|
+
|
|
727
435
|
writeFileSync(readmePath, md);
|
|
728
436
|
success(`Generated ${config.functionsDir}/README.md (AI vibe-coding guide)`);
|
|
729
437
|
}
|
|
@@ -1770,10 +1478,10 @@ ${BOLD}BaaS commands (login required):${RESET}
|
|
|
1770
1478
|
${DIM}--prod Deploy to production (confirmation required)${RESET}
|
|
1771
1479
|
${DIM}--name, -n Specify app name${RESET}
|
|
1772
1480
|
${DIM}--static [dir] Deploy static files (dist/, out/, build/)${RESET}
|
|
1773
|
-
${GREEN}env list${RESET} List
|
|
1774
|
-
${GREEN}env set K=V${RESET} Set
|
|
1775
|
-
${GREEN}env unset KEY${RESET} Remove
|
|
1776
|
-
${GREEN}env push${RESET} Push
|
|
1481
|
+
${GREEN}env list${RESET} List cloud env vars ${DIM}(--local for local dev server)${RESET}
|
|
1482
|
+
${GREEN}env set K=V${RESET} Set cloud env var ${DIM}(--local for local)${RESET}
|
|
1483
|
+
${GREEN}env unset KEY${RESET} Remove cloud env var ${DIM}(--local for local)${RESET}
|
|
1484
|
+
${GREEN}env push${RESET} Push .env to cloud ${DIM}(--local for local)${RESET}
|
|
1777
1485
|
${GREEN}domain set${RESET} Connect custom domain ${DIM}(myapp.com)${RESET}
|
|
1778
1486
|
${GREEN}domain status${RESET} Check domain DNS/TLS status
|
|
1779
1487
|
${GREEN}domain remove${RESET} Disconnect custom domain
|
|
@@ -1948,11 +1656,13 @@ ${BOLD}Examples:${RESET}
|
|
|
1948
1656
|
let staticDir = null; // --static 옵션
|
|
1949
1657
|
let isStatic = false;
|
|
1950
1658
|
let forceDeploy = false; // --force: skip dependency audit
|
|
1659
|
+
let noBackend = false; // --no-backend: skip backend auto-deploy in --static mode
|
|
1951
1660
|
|
|
1952
1661
|
for (let i = 0; i < deployArgs.length; i++) {
|
|
1953
1662
|
const a = deployArgs[i];
|
|
1954
1663
|
if (a === "--prod") envTarget = "prod";
|
|
1955
1664
|
else if (a === "--force" || a === "-f") forceDeploy = true;
|
|
1665
|
+
else if (a === "--no-backend") noBackend = true;
|
|
1956
1666
|
else if (a === "--app" || a === "-a") appId = deployArgs[++i];
|
|
1957
1667
|
else if (a === "--static") {
|
|
1958
1668
|
isStatic = true;
|
|
@@ -1983,7 +1693,7 @@ ${BOLD}Examples:${RESET}
|
|
|
1983
1693
|
|
|
1984
1694
|
// ── Static Deploy 분기 ────────────────────────────────
|
|
1985
1695
|
if (isStatic) {
|
|
1986
|
-
return await this._deployStatic(creds, appId, displayName, staticDir, gencowJsonPath);
|
|
1696
|
+
return await this._deployStatic(creds, appId, displayName, staticDir, gencowJsonPath, { forceDeploy, noBackend, envTarget });
|
|
1987
1697
|
}
|
|
1988
1698
|
|
|
1989
1699
|
log(`\n${BOLD}${CYAN}Gencow Deploy${RESET}\n`);
|
|
@@ -2198,7 +1908,9 @@ ${BOLD}Examples:${RESET}
|
|
|
2198
1908
|
},
|
|
2199
1909
|
|
|
2200
1910
|
// ── deploy --static 내부 헬퍼 ─────────────────────────
|
|
2201
|
-
async _deployStatic(creds, appId, displayName, staticDirArg, gencowJsonPath) {
|
|
1911
|
+
async _deployStatic(creds, appId, displayName, staticDirArg, gencowJsonPath, opts = {}) {
|
|
1912
|
+
const { forceDeploy = false, noBackend = false, envTarget = "dev" } = opts;
|
|
1913
|
+
|
|
2202
1914
|
// 정적 폴더 자동 감지
|
|
2203
1915
|
const AUTO_DETECT = ["dist", "out", "build", ".next/out"];
|
|
2204
1916
|
let targetDir = staticDirArg;
|
|
@@ -2220,8 +1932,24 @@ ${BOLD}Examples:${RESET}
|
|
|
2220
1932
|
process.exit(1);
|
|
2221
1933
|
}
|
|
2222
1934
|
|
|
2223
|
-
|
|
2224
|
-
|
|
1935
|
+
// ── 백엔드 감지: CWD 또는 상위 디렉토리에 gencow/ 존재 여부 ──
|
|
1936
|
+
const parentDir = resolve(process.cwd(), "..");
|
|
1937
|
+
const hasCwdBackend = existsSync(resolve(process.cwd(), "gencow"))
|
|
1938
|
+
&& existsSync(resolve(process.cwd(), "package.json"));
|
|
1939
|
+
const hasParentBackend = existsSync(resolve(parentDir, "gencow"))
|
|
1940
|
+
&& (existsSync(resolve(parentDir, "gencow.config.ts")) || existsSync(resolve(parentDir, "package.json")));
|
|
1941
|
+
const detectedBackend = hasCwdBackend || hasParentBackend;
|
|
1942
|
+
const backendRoot = hasCwdBackend ? process.cwd() : (hasParentBackend ? parentDir : null);
|
|
1943
|
+
const shouldDeployBackend = detectedBackend && !noBackend;
|
|
1944
|
+
|
|
1945
|
+
if (shouldDeployBackend) {
|
|
1946
|
+
log(`\n${BOLD}${CYAN}Gencow Deploy (Fullstack)${RESET}\n`);
|
|
1947
|
+
info(`백엔드 감지: ${backendRoot === process.cwd() ? "gencow/" : resolve(backendRoot, "gencow/")} (자동 배포)`);
|
|
1948
|
+
info(`프론트엔드: ${targetDir}/`);
|
|
1949
|
+
} else {
|
|
1950
|
+
log(`\n${BOLD}${CYAN}Gencow Static Deploy${RESET}\n`);
|
|
1951
|
+
info(`폴더: ${targetDir}/`);
|
|
1952
|
+
}
|
|
2225
1953
|
if (appId) {
|
|
2226
1954
|
info(`앱 ID: ${appId}`);
|
|
2227
1955
|
} else {
|
|
@@ -2229,65 +1957,152 @@ ${BOLD}Examples:${RESET}
|
|
|
2229
1957
|
}
|
|
2230
1958
|
log("");
|
|
2231
1959
|
|
|
2232
|
-
// ──
|
|
2233
|
-
|
|
2234
|
-
|
|
2235
|
-
|
|
2236
|
-
const
|
|
2237
|
-
const
|
|
2238
|
-
|
|
2239
|
-
const
|
|
2240
|
-
|
|
2241
|
-
|
|
2242
|
-
|
|
2243
|
-
|
|
1960
|
+
// ── 가드레일: 빌드 결과물 API 참조 스캔 ───────────────
|
|
1961
|
+
// 백엔드 자동 배포 시에는 API 서버가 존재하므로 경고 불필요
|
|
1962
|
+
if (!shouldDeployBackend) {
|
|
1963
|
+
try {
|
|
1964
|
+
const fullTargetDir = resolve(process.cwd(), targetDir);
|
|
1965
|
+
const scanDir = (dir) => {
|
|
1966
|
+
const entries = readdirSync(dir, { withFileTypes: true });
|
|
1967
|
+
const files = [];
|
|
1968
|
+
for (const e of entries) {
|
|
1969
|
+
const p = resolve(dir, e.name);
|
|
1970
|
+
if (e.isDirectory() && e.name !== "node_modules") {
|
|
1971
|
+
files.push(...scanDir(p));
|
|
1972
|
+
} else if (e.isFile() && e.name.endsWith(".js")) {
|
|
1973
|
+
files.push(p);
|
|
1974
|
+
}
|
|
1975
|
+
}
|
|
1976
|
+
return files;
|
|
1977
|
+
};
|
|
1978
|
+
const jsFiles = scanDir(fullTargetDir);
|
|
1979
|
+
const apiRefFiles = [];
|
|
1980
|
+
for (const f of jsFiles) {
|
|
1981
|
+
const content = readFileSync(f, "utf8");
|
|
1982
|
+
if (content.includes("/api/query") || content.includes("/api/mutation")) {
|
|
1983
|
+
apiRefFiles.push(f.replace(fullTargetDir + "/", ""));
|
|
2244
1984
|
}
|
|
2245
1985
|
}
|
|
2246
|
-
|
|
2247
|
-
|
|
2248
|
-
|
|
2249
|
-
|
|
2250
|
-
|
|
2251
|
-
|
|
2252
|
-
|
|
2253
|
-
|
|
1986
|
+
if (apiRefFiles.length > 0) {
|
|
1987
|
+
log("");
|
|
1988
|
+
warn(`빌드 파일에서 API 참조가 발견되었습니다:`);
|
|
1989
|
+
for (const f of apiRefFiles.slice(0, 5)) log(` ${DIM}- ${f}${RESET}`);
|
|
1990
|
+
if (apiRefFiles.length > 5) log(` ${DIM}... 외 ${apiRefFiles.length - 5}개${RESET}`);
|
|
1991
|
+
log("");
|
|
1992
|
+
warn(`정적 호스팅에는 API 서버가 없어 404가 발생합니다.`);
|
|
1993
|
+
info(`💡 백엔드가 필요하다면: ${CYAN}VITE_API_URL=https://{backend}.gencow.app npm run build${RESET} 후 배포`);
|
|
1994
|
+
info(`💡 별도 백엔드를 사용한다면: ${CYAN}VITE_API_URL${RESET} 환경변수로 빌드 대상을 지정하세요.`);
|
|
1995
|
+
log("");
|
|
1996
|
+
|
|
1997
|
+
const { createInterface } = await import("readline");
|
|
1998
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
1999
|
+
const answer = await new Promise(resolve => {
|
|
2000
|
+
rl.question(` ${YELLOW}⚠${RESET} 그래도 정적 배포를 계속하시겠습니까? (y/N) `, resolve);
|
|
2001
|
+
});
|
|
2002
|
+
rl.close();
|
|
2003
|
+
if (answer.toLowerCase() !== "y") {
|
|
2004
|
+
info("배포 취소됨.");
|
|
2005
|
+
return;
|
|
2006
|
+
}
|
|
2007
|
+
log("");
|
|
2008
|
+
}
|
|
2009
|
+
} catch { /* 스캔 실패 — 무시하고 진행 */ }
|
|
2010
|
+
}
|
|
2011
|
+
|
|
2012
|
+
// ── 백엔드 자동 배포 (감지된 경우) ────────────────────
|
|
2013
|
+
if (shouldDeployBackend) {
|
|
2014
|
+
log(` ${BOLD}── 백엔드 배포 ──────────────────────${RESET}\n`);
|
|
2015
|
+
|
|
2016
|
+
// 1-0. Pre-deploy dependency audit
|
|
2017
|
+
if (!forceDeploy) {
|
|
2018
|
+
try {
|
|
2019
|
+
const { auditDeployDependencies, formatAuditError } = await import("../lib/deploy-auditor.mjs");
|
|
2020
|
+
const entryPoint = resolve(backendRoot, "gencow", "index.ts");
|
|
2021
|
+
if (existsSync(entryPoint)) {
|
|
2022
|
+
const auditResult = await auditDeployDependencies(entryPoint);
|
|
2023
|
+
if (!auditResult.passed) {
|
|
2024
|
+
log(formatAuditError(auditResult));
|
|
2025
|
+
process.exit(1);
|
|
2026
|
+
}
|
|
2027
|
+
}
|
|
2028
|
+
} catch (auditErr) {
|
|
2029
|
+
warn(`의존성 검사 스킵: ${auditErr.message}`);
|
|
2254
2030
|
}
|
|
2255
2031
|
}
|
|
2256
|
-
if (apiRefFiles.length > 0) {
|
|
2257
|
-
log("");
|
|
2258
|
-
warn(`빌드 파일에서 API 참조가 발견되었습니다:`);
|
|
2259
|
-
for (const f of apiRefFiles.slice(0, 5)) log(` ${DIM}- ${f}${RESET}`);
|
|
2260
|
-
if (apiRefFiles.length > 5) log(` ${DIM}... 외 ${apiRefFiles.length - 5}개${RESET}`);
|
|
2261
|
-
log("");
|
|
2262
|
-
warn(`정적 호스팅에는 API 서버가 없어 404가 발생합니다.`);
|
|
2263
|
-
info(`💡 백엔드가 필요하다면: ${CYAN}VITE_API_URL=https://{backend}.gencow.app npm run build${RESET} 후 배포`);
|
|
2264
|
-
info(`💡 별도 백엔드를 사용한다면: ${CYAN}VITE_API_URL${RESET} 환경변수로 빌드 대상을 지정하세요.`);
|
|
2265
|
-
log("");
|
|
2266
2032
|
|
|
2267
|
-
|
|
2268
|
-
|
|
2269
|
-
|
|
2270
|
-
|
|
2271
|
-
|
|
2272
|
-
|
|
2273
|
-
|
|
2274
|
-
|
|
2275
|
-
|
|
2033
|
+
// 1. tar.gz 패키징 (백엔드)
|
|
2034
|
+
info("백엔드 패키징 중...");
|
|
2035
|
+
const { execSync: exec } = await import("child_process");
|
|
2036
|
+
const tmpBackendBundle = resolve(backendRoot, ".gencow", "deploy-bundle.tar.gz");
|
|
2037
|
+
mkdirSync(dirname(tmpBackendBundle), { recursive: true });
|
|
2038
|
+
|
|
2039
|
+
const backendFiles = ["gencow/", "package.json"];
|
|
2040
|
+
if (existsSync(resolve(backendRoot, "bun.lockb"))) backendFiles.push("bun.lockb");
|
|
2041
|
+
if (existsSync(resolve(backendRoot, "package-lock.json"))) backendFiles.push("package-lock.json");
|
|
2042
|
+
if (existsSync(resolve(backendRoot, "tsconfig.json"))) backendFiles.push("tsconfig.json");
|
|
2043
|
+
|
|
2044
|
+
try {
|
|
2045
|
+
exec(`tar -czf "${tmpBackendBundle}" ${backendFiles.join(" ")}`, { cwd: backendRoot });
|
|
2046
|
+
} catch (e) {
|
|
2047
|
+
error(`백엔드 패키징 실패: ${e.message}`);
|
|
2048
|
+
process.exit(1);
|
|
2049
|
+
}
|
|
2050
|
+
|
|
2051
|
+
const backendBundleSize = statSync(tmpBackendBundle).size;
|
|
2052
|
+
success(`백엔드 번들 생성: ${(backendBundleSize / 1024).toFixed(1)} KB`);
|
|
2053
|
+
|
|
2054
|
+
// 2. 앱이 없으면 생성
|
|
2055
|
+
if (!appId) {
|
|
2056
|
+
info("앱 생성 중 (ID 자동 생성)...");
|
|
2057
|
+
const createRes = await rpcMutation(creds, "apps.create", { name: displayName });
|
|
2058
|
+
if (!createRes.ok) {
|
|
2059
|
+
const createErr = await createRes.json().catch(() => ({}));
|
|
2060
|
+
error(`앱 생성 실패: ${createErr.error || createRes.statusText}`);
|
|
2061
|
+
process.exit(1);
|
|
2276
2062
|
}
|
|
2277
|
-
|
|
2063
|
+
const createData = await createRes.json();
|
|
2064
|
+
appId = createData.appId || createData.name;
|
|
2065
|
+
if (!appId) {
|
|
2066
|
+
error("앱 생성 응답에 appId가 없습니다.");
|
|
2067
|
+
process.exit(1);
|
|
2068
|
+
}
|
|
2069
|
+
success(`앱 "${appId}" 생성 완료. 프로비저닝 대기 중...`);
|
|
2070
|
+
writeFileSync(gencowJsonPath, JSON.stringify({
|
|
2071
|
+
appId, displayName, platformUrl: creds.platformUrl,
|
|
2072
|
+
}, null, 2));
|
|
2073
|
+
info(`${DIM}gencow.json 생성됨 (appId: ${appId})${RESET}`);
|
|
2074
|
+
await new Promise(r => setTimeout(r, 3000));
|
|
2278
2075
|
}
|
|
2279
|
-
} catch { /* 스캔 실패 — 무시하고 진행 */ }
|
|
2280
2076
|
|
|
2281
|
-
|
|
2282
|
-
|
|
2283
|
-
|
|
2284
|
-
|
|
2285
|
-
|
|
2286
|
-
|
|
2287
|
-
|
|
2288
|
-
|
|
2289
|
-
|
|
2077
|
+
// 3. 백엔드 업로드
|
|
2078
|
+
info("백엔드 배포 중...");
|
|
2079
|
+
const backendBuffer = readFileSync(tmpBackendBundle);
|
|
2080
|
+
const backendDeployRes = await platformFetch(creds, `/platform/apps/${appId}/deploy?env=${envTarget}`, {
|
|
2081
|
+
method: "POST",
|
|
2082
|
+
headers: { "Content-Type": "application/octet-stream" },
|
|
2083
|
+
body: backendBuffer,
|
|
2084
|
+
});
|
|
2085
|
+
try { unlinkSync(tmpBackendBundle); } catch { }
|
|
2086
|
+
|
|
2087
|
+
if (!backendDeployRes.ok) {
|
|
2088
|
+
const errData = await backendDeployRes.json().catch(() => ({}));
|
|
2089
|
+
error(`백엔드 배포 실패: ${errData.error || backendDeployRes.statusText}`);
|
|
2090
|
+
if (errData.crashLogs?.length) {
|
|
2091
|
+
log("");
|
|
2092
|
+
warn("서버 시작 실패 원인:");
|
|
2093
|
+
for (const line of errData.crashLogs) log(` ${DIM}${line}${RESET}`);
|
|
2094
|
+
}
|
|
2095
|
+
error("백엔드 배포 실패로 프론트엔드 배포를 건너뜁니다.");
|
|
2096
|
+
process.exit(1);
|
|
2097
|
+
}
|
|
2098
|
+
|
|
2099
|
+
const backendData = await backendDeployRes.json();
|
|
2100
|
+
success(`백엔드 배포 완료!`);
|
|
2101
|
+
info(`URL: ${backendData.url}`);
|
|
2102
|
+
info(`Hash: ${backendData.bundleHash}`);
|
|
2103
|
+
updateEnvLocalUrl(backendData.url);
|
|
2290
2104
|
log("");
|
|
2105
|
+
log(` ${BOLD}── 프론트엔드 배포 ──────────────────${RESET}\n`);
|
|
2291
2106
|
}
|
|
2292
2107
|
|
|
2293
2108
|
// 1. tar.gz 패키징
|
|
@@ -3265,108 +3080,268 @@ process.exit(0);
|
|
|
3265
3080
|
});
|
|
3266
3081
|
},
|
|
3267
3082
|
|
|
3268
|
-
// ── env — 환경변수 관리
|
|
3083
|
+
// ── env — 환경변수 관리 (Cloud-First + --local) ─────────
|
|
3269
3084
|
async env(...envArgs) {
|
|
3270
|
-
|
|
3271
|
-
const
|
|
3272
|
-
const
|
|
3273
|
-
const
|
|
3085
|
+
// ── --local 플래그 파싱 ──
|
|
3086
|
+
const isLocal = envArgs.includes("--local");
|
|
3087
|
+
const filteredArgs = envArgs.filter(a => a !== "--local");
|
|
3088
|
+
const subcmd = filteredArgs[0] || "list";
|
|
3089
|
+
|
|
3090
|
+
// ── 로컬 모드: 기존 localhost 로직 ──
|
|
3091
|
+
if (isLocal) {
|
|
3092
|
+
const config = loadConfig();
|
|
3093
|
+
const port = process.env.PORT || config.port || 5456;
|
|
3094
|
+
const base = `http://localhost:${port}/_admin/settings/env-vars`;
|
|
3095
|
+
|
|
3096
|
+
// ── local env list ──
|
|
3097
|
+
if (subcmd === "list") {
|
|
3098
|
+
try {
|
|
3099
|
+
const res = await fetch(base);
|
|
3100
|
+
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
3101
|
+
const vars = await res.json();
|
|
3102
|
+
|
|
3103
|
+
log(`\n${BOLD}Environment Variables${RESET} ${DIM}(local, ${vars.length} total)${RESET}\n`);
|
|
3104
|
+
|
|
3105
|
+
if (vars.length === 0) {
|
|
3106
|
+
info("No environment variables configured.");
|
|
3107
|
+
info(`Use ${GREEN}gencow env set --local KEY=VALUE${RESET} to add one.`);
|
|
3108
|
+
} else {
|
|
3109
|
+
const maxLen = Math.max(...vars.map(v => v.name.length), 4);
|
|
3110
|
+
log(` ${DIM}${"NAME".padEnd(maxLen)} VALUE${RESET}`);
|
|
3111
|
+
log(` ${DIM}${"─".repeat(maxLen)} ${"─".repeat(20)}${RESET}`);
|
|
3112
|
+
for (const v of vars) {
|
|
3113
|
+
log(` ${BOLD}${v.name.padEnd(maxLen)}${RESET} ${DIM}${v.maskedValue}${RESET}`);
|
|
3114
|
+
}
|
|
3115
|
+
}
|
|
3116
|
+
log("");
|
|
3117
|
+
} catch (e) {
|
|
3118
|
+
error(`Failed to fetch env vars: ${e.message}`);
|
|
3119
|
+
info(`Make sure the local dev server is running (gencow dev --local)`);
|
|
3120
|
+
}
|
|
3121
|
+
return;
|
|
3122
|
+
}
|
|
3123
|
+
|
|
3124
|
+
// ── local env set KEY=VALUE ──
|
|
3125
|
+
if (subcmd === "set") {
|
|
3126
|
+
const pairs = filteredArgs.slice(1);
|
|
3127
|
+
if (pairs.length === 0) {
|
|
3128
|
+
error("Usage: gencow env set --local KEY=VALUE [KEY2=VALUE2 ...]");
|
|
3129
|
+
return;
|
|
3130
|
+
}
|
|
3131
|
+
for (const pair of pairs) {
|
|
3132
|
+
const eqIdx = pair.indexOf("=");
|
|
3133
|
+
if (eqIdx < 1) { error(`Invalid format: ${pair}. Use KEY=VALUE`); continue; }
|
|
3134
|
+
const name = pair.slice(0, eqIdx).trim();
|
|
3135
|
+
const value = pair.slice(eqIdx + 1).trim();
|
|
3136
|
+
if (!name) { error("Key name cannot be empty"); continue; }
|
|
3137
|
+
if (!value) { error(`Value for ${name} cannot be empty`); continue; }
|
|
3138
|
+
|
|
3139
|
+
try {
|
|
3140
|
+
const res = await fetch(base, {
|
|
3141
|
+
method: "POST",
|
|
3142
|
+
headers: { "Content-Type": "application/json" },
|
|
3143
|
+
body: JSON.stringify({ name, value }),
|
|
3144
|
+
});
|
|
3145
|
+
if (!res.ok) {
|
|
3146
|
+
const body = await res.json().catch(() => ({}));
|
|
3147
|
+
throw new Error(body.error || `HTTP ${res.status}`);
|
|
3148
|
+
}
|
|
3149
|
+
success(`Set ${BOLD}${name}${RESET} ${DIM}(local)${RESET}`);
|
|
3150
|
+
} catch (e) {
|
|
3151
|
+
error(`Failed to set ${name}: ${e.message}`);
|
|
3152
|
+
}
|
|
3153
|
+
}
|
|
3154
|
+
log(`\n ${DIM}⚠ Restart local server to apply changes${RESET}\n`);
|
|
3155
|
+
return;
|
|
3156
|
+
}
|
|
3157
|
+
|
|
3158
|
+
// ── local env unset KEY ──
|
|
3159
|
+
if (subcmd === "unset" || subcmd === "remove" || subcmd === "delete") {
|
|
3160
|
+
const keys = filteredArgs.slice(1);
|
|
3161
|
+
if (keys.length === 0) { error("Usage: gencow env unset --local KEY [KEY2 ...]"); return; }
|
|
3162
|
+
for (const key of keys) {
|
|
3163
|
+
try {
|
|
3164
|
+
const res = await fetch(`${base}/${encodeURIComponent(key)}`, { method: "DELETE" });
|
|
3165
|
+
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
3166
|
+
success(`Removed ${BOLD}${key}${RESET} ${DIM}(local)${RESET}`);
|
|
3167
|
+
} catch (e) {
|
|
3168
|
+
error(`Failed to remove ${key}: ${e.message}`);
|
|
3169
|
+
}
|
|
3170
|
+
}
|
|
3171
|
+
log(`\n ${DIM}⚠ Restart local server to apply changes${RESET}\n`);
|
|
3172
|
+
return;
|
|
3173
|
+
}
|
|
3174
|
+
|
|
3175
|
+
// ── local env push ──
|
|
3176
|
+
if (subcmd === "push") {
|
|
3177
|
+
const envPath = resolve(process.cwd(), ".env");
|
|
3178
|
+
if (!existsSync(envPath)) { error("No .env file found in current directory"); return; }
|
|
3179
|
+
|
|
3180
|
+
const content = readFileSync(envPath, "utf-8");
|
|
3181
|
+
const pairs = [];
|
|
3182
|
+
for (const line of content.split("\n")) {
|
|
3183
|
+
const trimmed = line.trim();
|
|
3184
|
+
if (!trimmed || trimmed.startsWith("#")) continue;
|
|
3185
|
+
const eqIdx = trimmed.indexOf("=");
|
|
3186
|
+
if (eqIdx < 1) continue;
|
|
3187
|
+
const key = trimmed.slice(0, eqIdx).trim();
|
|
3188
|
+
let val = trimmed.slice(eqIdx + 1).trim();
|
|
3189
|
+
if ((val.startsWith('"') && val.endsWith('"')) || (val.startsWith("'") && val.endsWith("'"))) {
|
|
3190
|
+
val = val.slice(1, -1);
|
|
3191
|
+
}
|
|
3192
|
+
if (key && val) pairs.push({ key, val });
|
|
3193
|
+
}
|
|
3194
|
+
|
|
3195
|
+
if (pairs.length === 0) { warn("No valid KEY=VALUE pairs found in .env"); return; }
|
|
3196
|
+
|
|
3197
|
+
log(`\n${BOLD}Pushing .env${RESET} ${DIM}(local, ${pairs.length} variables)${RESET}\n`);
|
|
3274
3198
|
|
|
3275
|
-
|
|
3199
|
+
let ok = 0, fail = 0;
|
|
3200
|
+
for (const { key, val } of pairs) {
|
|
3201
|
+
try {
|
|
3202
|
+
const res = await fetch(base, {
|
|
3203
|
+
method: "POST",
|
|
3204
|
+
headers: { "Content-Type": "application/json" },
|
|
3205
|
+
body: JSON.stringify({ name: key, value: val }),
|
|
3206
|
+
});
|
|
3207
|
+
if (!res.ok) throw new Error(`HTTP ${res.status}`);
|
|
3208
|
+
success(`${key}`);
|
|
3209
|
+
ok++;
|
|
3210
|
+
} catch {
|
|
3211
|
+
error(`${key}`);
|
|
3212
|
+
fail++;
|
|
3213
|
+
}
|
|
3214
|
+
}
|
|
3215
|
+
|
|
3216
|
+
log(`\n ${GREEN}${ok} set${RESET}${fail > 0 ? `, ${RED}${fail} failed${RESET}` : ""}`);
|
|
3217
|
+
log(` ${DIM}⚠ Restart local server to apply changes${RESET}\n`);
|
|
3218
|
+
return;
|
|
3219
|
+
}
|
|
3220
|
+
|
|
3221
|
+
error(`Unknown env subcommand: ${subcmd}`);
|
|
3222
|
+
log(`\n ${BOLD}Usage (local):${RESET}`);
|
|
3223
|
+
log(` gencow env list --local List local env vars`);
|
|
3224
|
+
log(` gencow env set --local K=V Set local variable(s)`);
|
|
3225
|
+
log(` gencow env unset --local KEY Remove local variable(s)`);
|
|
3226
|
+
log(` gencow env push --local Push .env to local server\n`);
|
|
3227
|
+
return;
|
|
3228
|
+
}
|
|
3229
|
+
|
|
3230
|
+
// ── 클라우드 모드 (기본) ──
|
|
3231
|
+
// gencow.json에서 appId 읽기
|
|
3232
|
+
const gencowJsonPath = resolve(process.cwd(), "gencow.json");
|
|
3233
|
+
let appId = null;
|
|
3234
|
+
if (existsSync(gencowJsonPath)) {
|
|
3235
|
+
try {
|
|
3236
|
+
const gencowJson = JSON.parse(readFileSync(gencowJsonPath, "utf8"));
|
|
3237
|
+
appId = gencowJson.appId || gencowJson.appName;
|
|
3238
|
+
} catch { /* ignore parse error */ }
|
|
3239
|
+
}
|
|
3240
|
+
|
|
3241
|
+
if (!appId) {
|
|
3242
|
+
error("No gencow.json found. Cannot determine cloud app.");
|
|
3243
|
+
info(`Run ${GREEN}gencow deploy${RESET} first to create a cloud app.`);
|
|
3244
|
+
info(`Or use ${GREEN}gencow env set --local KEY=VALUE${RESET} for local dev server.`);
|
|
3245
|
+
return;
|
|
3246
|
+
}
|
|
3247
|
+
|
|
3248
|
+
const creds = requireCreds();
|
|
3249
|
+
|
|
3250
|
+
// ── cloud env list ──
|
|
3276
3251
|
if (subcmd === "list") {
|
|
3277
3252
|
try {
|
|
3278
|
-
const res = await
|
|
3279
|
-
|
|
3253
|
+
const res = await platformFetch(creds, `/platform/apps/${appId}/env`, {
|
|
3254
|
+
method: "GET",
|
|
3255
|
+
});
|
|
3256
|
+
if (!res.ok) {
|
|
3257
|
+
const body = await res.json().catch(() => ({}));
|
|
3258
|
+
throw new Error(body.error || `HTTP ${res.status}`);
|
|
3259
|
+
}
|
|
3280
3260
|
const vars = await res.json();
|
|
3281
3261
|
|
|
3282
|
-
log(`\n${BOLD}Environment Variables${RESET} ${DIM}(${vars.length} total)${RESET}\n`);
|
|
3262
|
+
log(`\n${BOLD}Environment Variables${RESET} ${DIM}(cloud: ${appId}, ${vars.length} total)${RESET}\n`);
|
|
3283
3263
|
|
|
3284
3264
|
if (vars.length === 0) {
|
|
3285
3265
|
info("No environment variables configured.");
|
|
3286
3266
|
info(`Use ${GREEN}gencow env set KEY=VALUE${RESET} to add one.`);
|
|
3287
3267
|
} else {
|
|
3288
|
-
const maxLen = Math.max(...vars.map(v => v.
|
|
3289
|
-
log(` ${DIM}${"NAME".padEnd(maxLen)}
|
|
3290
|
-
log(` ${DIM}${"─".repeat(maxLen)} ${"─".repeat(
|
|
3268
|
+
const maxLen = Math.max(...vars.map(v => v.key.length), 4);
|
|
3269
|
+
log(` ${DIM}${"NAME".padEnd(maxLen)} ENV${RESET}`);
|
|
3270
|
+
log(` ${DIM}${"─".repeat(maxLen)} ${"─".repeat(6)}${RESET}`);
|
|
3291
3271
|
for (const v of vars) {
|
|
3292
|
-
log(` ${BOLD}${v.
|
|
3272
|
+
log(` ${BOLD}${v.key.padEnd(maxLen)}${RESET} ${DIM}${v.env}${RESET}`);
|
|
3293
3273
|
}
|
|
3294
3274
|
}
|
|
3295
3275
|
log("");
|
|
3296
3276
|
} catch (e) {
|
|
3297
3277
|
error(`Failed to fetch env vars: ${e.message}`);
|
|
3298
|
-
info(`Make sure the server is running (gencow dev)`);
|
|
3299
3278
|
}
|
|
3300
3279
|
return;
|
|
3301
3280
|
}
|
|
3302
3281
|
|
|
3303
|
-
// ── env set KEY=VALUE ──
|
|
3282
|
+
// ── cloud env set KEY=VALUE ──
|
|
3304
3283
|
if (subcmd === "set") {
|
|
3305
|
-
const pairs =
|
|
3284
|
+
const pairs = filteredArgs.slice(1);
|
|
3306
3285
|
if (pairs.length === 0) {
|
|
3307
3286
|
error("Usage: gencow env set KEY=VALUE [KEY2=VALUE2 ...]");
|
|
3308
3287
|
return;
|
|
3309
3288
|
}
|
|
3310
3289
|
for (const pair of pairs) {
|
|
3311
3290
|
const eqIdx = pair.indexOf("=");
|
|
3312
|
-
if (eqIdx < 1) {
|
|
3313
|
-
error(`Invalid format: ${pair}. Use KEY=VALUE`);
|
|
3314
|
-
continue;
|
|
3315
|
-
}
|
|
3291
|
+
if (eqIdx < 1) { error(`Invalid format: ${pair}. Use KEY=VALUE`); continue; }
|
|
3316
3292
|
const name = pair.slice(0, eqIdx).trim();
|
|
3317
3293
|
const value = pair.slice(eqIdx + 1).trim();
|
|
3318
3294
|
if (!name) { error("Key name cannot be empty"); continue; }
|
|
3319
3295
|
if (!value) { error(`Value for ${name} cannot be empty`); continue; }
|
|
3320
3296
|
|
|
3321
3297
|
try {
|
|
3322
|
-
const res = await
|
|
3298
|
+
const res = await platformFetch(creds, `/platform/apps/${appId}/env`, {
|
|
3323
3299
|
method: "POST",
|
|
3324
3300
|
headers: { "Content-Type": "application/json" },
|
|
3325
|
-
body: JSON.stringify({ name, value }),
|
|
3301
|
+
body: JSON.stringify({ key: name, value }),
|
|
3326
3302
|
});
|
|
3327
3303
|
if (!res.ok) {
|
|
3328
3304
|
const body = await res.json().catch(() => ({}));
|
|
3329
3305
|
throw new Error(body.error || `HTTP ${res.status}`);
|
|
3330
3306
|
}
|
|
3331
|
-
success(`Set ${BOLD}${name}${RESET}`);
|
|
3307
|
+
success(`Set ${BOLD}${name}${RESET} ${DIM}(cloud: ${appId})${RESET}`);
|
|
3332
3308
|
} catch (e) {
|
|
3333
3309
|
error(`Failed to set ${name}: ${e.message}`);
|
|
3334
3310
|
}
|
|
3335
3311
|
}
|
|
3336
|
-
log(`\n ${DIM}⚠ Restart
|
|
3312
|
+
log(`\n ${DIM}⚠ Restart app to apply changes${RESET}\n`);
|
|
3337
3313
|
return;
|
|
3338
3314
|
}
|
|
3339
3315
|
|
|
3340
|
-
// ── env unset KEY ──
|
|
3316
|
+
// ── cloud env unset KEY ──
|
|
3341
3317
|
if (subcmd === "unset" || subcmd === "remove" || subcmd === "delete") {
|
|
3342
|
-
const keys =
|
|
3343
|
-
if (keys.length === 0) {
|
|
3344
|
-
error("Usage: gencow env unset KEY [KEY2 ...]");
|
|
3345
|
-
return;
|
|
3346
|
-
}
|
|
3318
|
+
const keys = filteredArgs.slice(1);
|
|
3319
|
+
if (keys.length === 0) { error("Usage: gencow env unset KEY [KEY2 ...]"); return; }
|
|
3347
3320
|
for (const key of keys) {
|
|
3348
3321
|
try {
|
|
3349
|
-
const res = await
|
|
3350
|
-
|
|
3351
|
-
|
|
3322
|
+
const res = await platformFetch(creds, `/platform/apps/${appId}/env/${encodeURIComponent(key)}`, {
|
|
3323
|
+
method: "DELETE",
|
|
3324
|
+
});
|
|
3325
|
+
if (!res.ok) {
|
|
3326
|
+
const body = await res.json().catch(() => ({}));
|
|
3327
|
+
throw new Error(body.error || `HTTP ${res.status}`);
|
|
3328
|
+
}
|
|
3329
|
+
success(`Removed ${BOLD}${key}${RESET} ${DIM}(cloud: ${appId})${RESET}`);
|
|
3352
3330
|
} catch (e) {
|
|
3353
3331
|
error(`Failed to remove ${key}: ${e.message}`);
|
|
3354
3332
|
}
|
|
3355
3333
|
}
|
|
3356
|
-
log(`\n ${DIM}⚠ Restart
|
|
3334
|
+
log(`\n ${DIM}⚠ Restart app to apply changes${RESET}\n`);
|
|
3357
3335
|
return;
|
|
3358
3336
|
}
|
|
3359
3337
|
|
|
3360
|
-
// ── env push — .env to
|
|
3338
|
+
// ── cloud env push — .env to cloud ──
|
|
3361
3339
|
if (subcmd === "push") {
|
|
3362
3340
|
const envPath = resolve(process.cwd(), ".env");
|
|
3363
|
-
if (!existsSync(envPath)) {
|
|
3364
|
-
error("No .env file found in current directory");
|
|
3365
|
-
return;
|
|
3366
|
-
}
|
|
3341
|
+
if (!existsSync(envPath)) { error("No .env file found in current directory"); return; }
|
|
3367
3342
|
|
|
3368
3343
|
const content = readFileSync(envPath, "utf-8");
|
|
3369
|
-
const
|
|
3344
|
+
const vars = {};
|
|
3370
3345
|
for (const line of content.split("\n")) {
|
|
3371
3346
|
const trimmed = line.trim();
|
|
3372
3347
|
if (!trimmed || trimmed.startsWith("#")) continue;
|
|
@@ -3374,50 +3349,48 @@ process.exit(0);
|
|
|
3374
3349
|
if (eqIdx < 1) continue;
|
|
3375
3350
|
const key = trimmed.slice(0, eqIdx).trim();
|
|
3376
3351
|
let val = trimmed.slice(eqIdx + 1).trim();
|
|
3377
|
-
// Remove surrounding quotes
|
|
3378
3352
|
if ((val.startsWith('"') && val.endsWith('"')) || (val.startsWith("'") && val.endsWith("'"))) {
|
|
3379
3353
|
val = val.slice(1, -1);
|
|
3380
3354
|
}
|
|
3381
|
-
if (key && val)
|
|
3355
|
+
if (key && val) vars[key] = val;
|
|
3382
3356
|
}
|
|
3383
3357
|
|
|
3384
|
-
|
|
3385
|
-
|
|
3386
|
-
return;
|
|
3387
|
-
}
|
|
3358
|
+
const count = Object.keys(vars).length;
|
|
3359
|
+
if (count === 0) { warn("No valid KEY=VALUE pairs found in .env"); return; }
|
|
3388
3360
|
|
|
3389
|
-
log(`\n${BOLD}Pushing .env${RESET} ${DIM}(${
|
|
3361
|
+
log(`\n${BOLD}Pushing .env${RESET} ${DIM}(cloud: ${appId}, ${count} variables)${RESET}\n`);
|
|
3390
3362
|
|
|
3391
|
-
|
|
3392
|
-
|
|
3393
|
-
|
|
3394
|
-
|
|
3395
|
-
|
|
3396
|
-
|
|
3397
|
-
|
|
3398
|
-
|
|
3399
|
-
});
|
|
3400
|
-
|
|
3363
|
+
try {
|
|
3364
|
+
const res = await platformFetch(creds, `/platform/apps/${appId}/env/bulk`, {
|
|
3365
|
+
method: "PUT",
|
|
3366
|
+
headers: { "Content-Type": "application/json" },
|
|
3367
|
+
body: JSON.stringify({ vars }),
|
|
3368
|
+
});
|
|
3369
|
+
if (!res.ok) {
|
|
3370
|
+
const body = await res.json().catch(() => ({}));
|
|
3371
|
+
throw new Error(body.error || `HTTP ${res.status}`);
|
|
3372
|
+
}
|
|
3373
|
+
const data = await res.json();
|
|
3374
|
+
for (const key of Object.keys(vars)) {
|
|
3401
3375
|
success(`${key}`);
|
|
3402
|
-
ok++;
|
|
3403
|
-
} catch {
|
|
3404
|
-
error(`${key}`);
|
|
3405
|
-
fail++;
|
|
3406
3376
|
}
|
|
3377
|
+
log(`\n ${GREEN}${data.count || count} set${RESET} ${DIM}(cloud: ${appId})${RESET}`);
|
|
3378
|
+
} catch (e) {
|
|
3379
|
+
error(`Failed to push env vars: ${e.message}`);
|
|
3407
3380
|
}
|
|
3408
3381
|
|
|
3409
|
-
log(
|
|
3410
|
-
log(` ${DIM}⚠ Restart server to apply changes${RESET}\n`);
|
|
3382
|
+
log(` ${DIM}⚠ Restart app to apply changes${RESET}\n`);
|
|
3411
3383
|
return;
|
|
3412
3384
|
}
|
|
3413
3385
|
|
|
3414
3386
|
// Unknown subcmd
|
|
3415
3387
|
error(`Unknown env subcommand: ${subcmd}`);
|
|
3416
3388
|
log(`\n ${BOLD}Usage:${RESET}`);
|
|
3417
|
-
log(` gencow env list
|
|
3418
|
-
log(` gencow env set K=V
|
|
3419
|
-
log(` gencow env unset KEY
|
|
3420
|
-
log(` gencow env push
|
|
3389
|
+
log(` gencow env list List cloud env vars`);
|
|
3390
|
+
log(` gencow env set K=V Set cloud variable(s)`);
|
|
3391
|
+
log(` gencow env unset KEY Remove cloud variable(s)`);
|
|
3392
|
+
log(` gencow env push Push .env to cloud`);
|
|
3393
|
+
log(` ${DIM}Add --local for local dev server${RESET}\n`);
|
|
3421
3394
|
},
|
|
3422
3395
|
|
|
3423
3396
|
async platform() {
|