diff --git a/packages/cli/src/cmd/init.test.ts b/packages/cli/src/cmd/init.test.ts index d1b68eb..356d166 100644 --- a/packages/cli/src/cmd/init.test.ts +++ b/packages/cli/src/cmd/init.test.ts @@ -1,5 +1,12 @@ import { describe, expect, it } from "vitest" -import { buildDegitSources, resolveDestDir, slugify } from "./init" +import { + buildDegitSources, + generateEnvContent, + mergeEnvContent, + parseEnvKeys, + resolveDestDir, + slugify +} from "./init" describe("init command - unit tests", () => { describe("slugify function", () => { @@ -132,4 +139,107 @@ describe("init command - unit tests", () => { expect(result).toBe("/home/user/projects") }) }) + + describe("parseEnvKeys", () => { + it("should parse simple env keys", () => { + const content = "FOO=bar\nBAZ=qux" + const keys = parseEnvKeys(content) + expect(keys).toEqual(new Set(["FOO", "BAZ"])) + }) + + it("should ignore comments", () => { + const content = "# This is a comment\nFOO=bar\n# Another comment" + const keys = parseEnvKeys(content) + expect(keys).toEqual(new Set(["FOO"])) + }) + + it("should ignore empty lines", () => { + const content = "FOO=bar\n\nBAZ=qux\n" + const keys = parseEnvKeys(content) + expect(keys).toEqual(new Set(["FOO", "BAZ"])) + }) + + it("should handle values with equals signs", () => { + const content = 'DATABASE_URL="postgres://user:pass=123@localhost"' + const keys = parseEnvKeys(content) + expect(keys).toEqual(new Set(["DATABASE_URL"])) + }) + + it("should trim whitespace from keys", () => { + const content = " FOO =bar" + const keys = parseEnvKeys(content) + expect(keys).toEqual(new Set(["FOO"])) + }) + }) + + describe("generateEnvContent", () => { + it("should generate env content with header", () => { + const requiredEnv = { FOO: "bar", BAZ: "qux" } + const content = generateEnvContent(requiredEnv) + expect(content).toBe("# Generated by StartupKit\nFOO=bar\nBAZ=qux\n") + }) + + it("should handle single entry", () => { + const requiredEnv = { AUTH_SECRET: "secret123" } + const content = generateEnvContent(requiredEnv) + expect(content).toBe("# Generated by StartupKit\nAUTH_SECRET=secret123\n") + }) + }) + + describe("mergeEnvContent", () => { + it("should add missing keys to existing content", () => { + const existingContent = "EXISTING=value" + const requiredEnv = { EXISTING: "ignored", NEW_KEY: "new_value" } + const { content, addedKeys } = mergeEnvContent( + existingContent, + requiredEnv + ) + expect(content).toBe("EXISTING=value\nNEW_KEY=new_value\n") + expect(addedKeys).toEqual(["NEW_KEY"]) + }) + + it("should not modify content when all keys exist", () => { + const existingContent = "FOO=bar\nBAZ=qux" + const requiredEnv = { FOO: "different", BAZ: "also_different" } + const { content, addedKeys } = mergeEnvContent( + existingContent, + requiredEnv + ) + expect(content).toBe("FOO=bar\nBAZ=qux") + expect(addedKeys).toEqual([]) + }) + + it("should add multiple missing keys", () => { + const existingContent = "EXISTING=value" + const requiredEnv = { KEY1: "val1", KEY2: "val2" } + const { content, addedKeys } = mergeEnvContent( + existingContent, + requiredEnv + ) + expect(content).toBe("EXISTING=value\nKEY1=val1\nKEY2=val2\n") + expect(addedKeys).toEqual(["KEY1", "KEY2"]) + }) + + it("should handle content with trailing newline", () => { + const existingContent = "EXISTING=value\n" + const requiredEnv = { NEW_KEY: "new_value" } + const { content, addedKeys } = mergeEnvContent( + existingContent, + requiredEnv + ) + expect(content).toBe("EXISTING=value\nNEW_KEY=new_value\n") + expect(addedKeys).toEqual(["NEW_KEY"]) + }) + + it("should preserve comments in existing content", () => { + const existingContent = "# My app config\nFOO=bar" + const requiredEnv = { BAZ: "qux" } + const { content, addedKeys } = mergeEnvContent( + existingContent, + requiredEnv + ) + expect(content).toBe("# My app config\nFOO=bar\nBAZ=qux\n") + expect(addedKeys).toEqual(["BAZ"]) + }) + }) }) diff --git a/packages/cli/src/cmd/init.ts b/packages/cli/src/cmd/init.ts index 0bf370f..3a83393 100644 --- a/packages/cli/src/cmd/init.ts +++ b/packages/cli/src/cmd/init.ts @@ -1,3 +1,4 @@ +import { existsSync, readFileSync, writeFileSync } from "node:fs" import path from "node:path" import degit from "degit" import inquirer from "inquirer" @@ -5,6 +6,46 @@ import { replaceInFile } from "replace-in-file" import { spinner } from "../lib/spinner" import { exec } from "../lib/system" +export function parseEnvKeys(content: string): Set { + return new Set( + content + .split("\n") + .filter((line) => line.includes("=") && !line.startsWith("#")) + .map((line) => line.split("=")[0].trim()) + ) +} + +export function generateEnvContent( + requiredEnv: Record +): string { + return `# Generated by StartupKit\n${Object.entries(requiredEnv) + .map(([key, value]) => `${key}=${value}`) + .join("\n")}\n` +} + +export function mergeEnvContent( + existingContent: string, + requiredEnv: Record +): { content: string; addedKeys: string[] } { + const existingKeys = parseEnvKeys(existingContent) + const missingEntries = Object.entries(requiredEnv).filter( + ([key]) => !existingKeys.has(key) + ) + + if (missingEntries.length === 0) { + return { content: existingContent, addedKeys: [] } + } + + const additions = missingEntries + .map(([key, value]) => `${key}=${value}`) + .join("\n") + + return { + content: `${existingContent.trimEnd()}\n${additions}\n`, + addedKeys: missingEntries.map(([k]) => k) + } +} + export function slugify(input: string): string { return input .toLowerCase() @@ -169,6 +210,25 @@ export async function init(props: { await exec("pnpm install --no-frozen-lockfile", { cwd: destDir }) }) + // Create or update .env.local with required keys + const envLocal = path.join(destDir, ".env.local") + const requiredEnv: Record = { + AUTH_SECRET: "FAKE1234567890123456789012345678901234567890", + DATABASE_URL: `postgresql://localhost:5432/${key}?schema=public` + } + + if (existsSync(envLocal)) { + const existingContent = readFileSync(envLocal, "utf-8") + const { content, addedKeys } = mergeEnvContent(existingContent, requiredEnv) + if (addedKeys.length > 0) { + writeFileSync(envLocal, content) + console.log(`✅ Added missing env vars: ${addedKeys.join(", ")}`) + } + } else { + writeFileSync(envLocal, generateEnvContent(requiredEnv)) + console.log("✅ Created .env.local") + } + // Generate AI agent instructions await spinner(`Generating AI agent instructions`, async () => { await exec("pnpm agents.md", { cwd: destDir })