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.
Files changed (2) hide show
  1. package/bin/gencow.mjs +398 -425
  2. 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
- const now = new Date().toLocaleString("ko-KR");
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
- if (existingReadme.includes(COMP_START)) {
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 remote env vars ${DIM}(--prod for production)${RESET}
1774
- ${GREEN}env set K=V${RESET} Set remote env var
1775
- ${GREEN}env unset KEY${RESET} Remove remote env var
1776
- ${GREEN}env push${RESET} Push local .env to remote
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
- log(`\n${BOLD}${CYAN}Gencow Static Deploy${RESET}\n`);
2224
- info(`폴더: ${targetDir}/`);
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
- // ── 가드레일 1: 빌드 결과물 API 참조 스캔 ──────────────
2233
- try {
2234
- const fullTargetDir = resolve(process.cwd(), targetDir);
2235
- const scanDir = (dir) => {
2236
- const entries = readdirSync(dir, { withFileTypes: true });
2237
- const files = [];
2238
- for (const e of entries) {
2239
- const p = resolve(dir, e.name);
2240
- if (e.isDirectory() && e.name !== "node_modules") {
2241
- files.push(...scanDir(p));
2242
- } else if (e.isFile() && e.name.endsWith(".js")) {
2243
- files.push(p);
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
- return files;
2247
- };
2248
- const jsFiles = scanDir(fullTargetDir);
2249
- const apiRefFiles = [];
2250
- for (const f of jsFiles) {
2251
- const content = readFileSync(f, "utf8");
2252
- if (content.includes("/api/query") || content.includes("/api/mutation")) {
2253
- apiRefFiles.push(f.replace(fullTargetDir + "/", ""));
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
- const { createInterface } = await import("readline");
2268
- const rl = createInterface({ input: process.stdin, output: process.stdout });
2269
- const answer = await new Promise(resolve => {
2270
- rl.question(` ${YELLOW}⚠${RESET} 그래도 정적 배포를 계속하시겠습니까? (y/N) `, resolve);
2271
- });
2272
- rl.close();
2273
- if (answer.toLowerCase() !== "y") {
2274
- info("배포 취소됨.");
2275
- return;
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
- log("");
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
- // ── 가드레일 2: 동일 프로젝트 백엔드 감지 ─────────────
2282
- const parentDir = resolve(process.cwd(), "..");
2283
- const hasParentBackend = existsSync(resolve(parentDir, "gencow"))
2284
- || existsSync(resolve(parentDir, "gencow.config.ts"));
2285
- const hasSelfBackend = existsSync(resolve(process.cwd(), "..", "gencow"))
2286
- && process.cwd().includes("frontend");
2287
- if (hasParentBackend || hasSelfBackend) {
2288
- warn(`상위 디렉토리에 Gencow 백엔드 프로젝트가 감지되었습니다.`);
2289
- info(`💡 통합 배포: ${CYAN}cd ${hasSelfBackend ? ".." : resolve(parentDir)} && gencow deploy${RESET}`);
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
- const subcmd = envArgs[0] || "list";
3271
- const config = loadConfig();
3272
- const port = process.env.PORT || config.port || 5456;
3273
- const base = `http://localhost:${port}/_admin/settings/env-vars`;
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
- // ── env list ──
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 fetch(base);
3279
- if (!res.ok) throw new Error(`HTTP ${res.status}`);
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.name.length), 4);
3289
- log(` ${DIM}${"NAME".padEnd(maxLen)} VALUE${RESET}`);
3290
- log(` ${DIM}${"─".repeat(maxLen)} ${"─".repeat(20)}${RESET}`);
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.name.padEnd(maxLen)}${RESET} ${DIM}${v.maskedValue}${RESET}`);
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 = envArgs.slice(1);
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 fetch(base, {
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 server to apply changes${RESET}\n`);
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 = envArgs.slice(1);
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 fetch(`${base}/${encodeURIComponent(key)}`, { method: "DELETE" });
3350
- if (!res.ok) throw new Error(`HTTP ${res.status}`);
3351
- success(`Removed ${BOLD}${key}${RESET}`);
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 server to apply changes${RESET}\n`);
3334
+ log(`\n ${DIM}⚠ Restart app to apply changes${RESET}\n`);
3357
3335
  return;
3358
3336
  }
3359
3337
 
3360
- // ── env push — .env to server ──
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 pairs = [];
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) pairs.push({ key, val });
3355
+ if (key && val) vars[key] = val;
3382
3356
  }
3383
3357
 
3384
- if (pairs.length === 0) {
3385
- warn("No valid KEY=VALUE pairs found in .env");
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}(${pairs.length} variables)${RESET}\n`);
3361
+ log(`\n${BOLD}Pushing .env${RESET} ${DIM}(cloud: ${appId}, ${count} variables)${RESET}\n`);
3390
3362
 
3391
- let ok = 0;
3392
- let fail = 0;
3393
- for (const { key, val } of pairs) {
3394
- try {
3395
- const res = await fetch(base, {
3396
- method: "POST",
3397
- headers: { "Content-Type": "application/json" },
3398
- body: JSON.stringify({ name: key, value: val }),
3399
- });
3400
- if (!res.ok) throw new Error(`HTTP ${res.status}`);
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(`\n ${GREEN}${ok} set${RESET}${fail > 0 ? `, ${RED}${fail} failed${RESET}` : ""}`);
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 List environment variables`);
3418
- log(` gencow env set K=V Set variable(s)`);
3419
- log(` gencow env unset KEY Remove variable(s)`);
3420
- log(` gencow env push Push local .env to server\n`);
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() {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gencow",
3
- "version": "0.1.74",
3
+ "version": "0.1.76",
4
4
  "description": "Gencow — AI Backend Engine",
5
5
  "type": "module",
6
6
  "bin": {