Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
112 changes: 111 additions & 1 deletion packages/cli/src/cmd/init.test.ts
Original file line number Diff line number Diff line change
@@ -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", () => {
Expand Down Expand Up @@ -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"])
})
})
})
60 changes: 60 additions & 0 deletions packages/cli/src/cmd/init.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,51 @@
import { existsSync, readFileSync, writeFileSync } from "node:fs"
import path from "node:path"
import degit from "degit"
import inquirer from "inquirer"
import { replaceInFile } from "replace-in-file"
import { spinner } from "../lib/spinner"
import { exec } from "../lib/system"

export function parseEnvKeys(content: string): Set<string> {
return new Set(
content
.split("\n")
.filter((line) => line.includes("=") && !line.startsWith("#"))
.map((line) => line.split("=")[0].trim())
)
}

export function generateEnvContent(
requiredEnv: Record<string, string>
): string {
return `# Generated by StartupKit\n${Object.entries(requiredEnv)
.map(([key, value]) => `${key}=${value}`)
.join("\n")}\n`
}

export function mergeEnvContent(
existingContent: string,
requiredEnv: Record<string, string>
): { 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()
Expand Down Expand Up @@ -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<string, string> = {
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 })
Expand Down