Skip to content

Commit 5f00612

Browse files
authored
feat: better example env vars in init templates (#30)
1 parent 0a06047 commit 5f00612

File tree

12 files changed

+332
-68
lines changed

12 files changed

+332
-68
lines changed

packages/blink/src/cli/init-templates/index.ts

Lines changed: 7 additions & 7 deletions
Large diffs are not rendered by default.

packages/blink/src/cli/init-templates/scratch/_noignore.env.local

Lines changed: 0 additions & 4 deletions
This file was deleted.
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
# Store local environment variables here.
2+
# They will be used by blink dev for development.
3+
{{#each envLocal}}
4+
{{this.[0]}}={{this.[1]}}
5+
{{/each}}
6+
{{#unless (hasAnyEnvVar envLocal "OPENAI_API_KEY" "ANTHROPIC_API_KEY" "AI_GATEWAY_API_KEY")}}
7+
# OPENAI_API_KEY=
8+
# ANTHROPIC_API_KEY=
9+
# AI_GATEWAY_API_KEY=
10+
{{/unless}}
Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
11
# Store production environment variables here.
22
# They will be upserted as secrets on blink deploy.
3-
# EXTERNAL_SERVICE_API_KEY=
3+
# OPENAI_API_KEY=
4+
# ANTHROPIC_API_KEY=
5+
# AI_GATEWAY_API_KEY=

packages/blink/src/cli/init-templates/slack-bot/_noignore.env.local

Lines changed: 0 additions & 5 deletions
This file was deleted.
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
# Store local environment variables here.
2+
# They will be used by blink dev for development.
3+
{{#each envLocal}}
4+
{{this.[0]}}={{this.[1]}}
5+
{{/each}}
6+
{{#unless (hasAnyEnvVar envLocal "OPENAI_API_KEY" "ANTHROPIC_API_KEY" "AI_GATEWAY_API_KEY")}}
7+
# OPENAI_API_KEY=
8+
# ANTHROPIC_API_KEY=
9+
# AI_GATEWAY_API_KEY=
10+
{{/unless}}
11+
SLACK_BOT_TOKEN=xoxb-your-token-here
12+
SLACK_SIGNING_SECRET=your-signing-secret-here
Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,7 @@
11
# Store production environment variables here.
22
# They will be upserted as secrets on blink deploy.
3-
# EXTERNAL_SERVICE_API_KEY=
3+
# SLACK_BOT_TOKEN=
4+
# SLACK_SIGNING_SECRET=
5+
# OPENAI_API_KEY=
6+
# ANTHROPIC_API_KEY=
7+
# AI_GATEWAY_API_KEY=
Lines changed: 214 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,214 @@
1+
import { describe, it, expect } from "bun:test";
2+
import { getFilesForTemplate } from "./init";
3+
4+
const getFile = (files: Record<string, string>, filename: string): string => {
5+
const fileContent = files[filename];
6+
if (fileContent === undefined) {
7+
throw new Error(`File ${filename} is undefined`);
8+
}
9+
return fileContent;
10+
};
11+
12+
// Test helper for .env.local AI provider key behavior
13+
function testAiProviderKeyBehavior(template: "scratch" | "slack-bot") {
14+
describe(".env.local AI provider key behavior", () => {
15+
it("should show AI provider placeholders when envLocal is empty", () => {
16+
const files = getFilesForTemplate(template, {
17+
packageName: "test-project",
18+
aiProvider: "anthropic",
19+
envLocal: [],
20+
});
21+
22+
const envLocal = getFile(files, ".env.local");
23+
expect(envLocal).toContain("# OPENAI_API_KEY=");
24+
expect(envLocal).toContain("# ANTHROPIC_API_KEY=");
25+
expect(envLocal).toContain("# AI_GATEWAY_API_KEY=");
26+
});
27+
28+
it("should not show AI provider placeholders when ANTHROPIC_API_KEY is provided", () => {
29+
const files = getFilesForTemplate(template, {
30+
packageName: "test-project",
31+
aiProvider: "anthropic",
32+
envLocal: [["ANTHROPIC_API_KEY", "sk-test-123"]],
33+
});
34+
35+
const envLocal = getFile(files, ".env.local");
36+
expect(envLocal).toContain("ANTHROPIC_API_KEY=sk-test-123");
37+
expect(envLocal).not.toContain("# OPENAI_API_KEY=");
38+
expect(envLocal).not.toContain("# ANTHROPIC_API_KEY=");
39+
expect(envLocal).not.toContain("# AI_GATEWAY_API_KEY=");
40+
});
41+
42+
it("should not show AI provider placeholders when OPENAI_API_KEY is provided", () => {
43+
const files = getFilesForTemplate(template, {
44+
packageName: "test-project",
45+
aiProvider: "openai",
46+
envLocal: [["OPENAI_API_KEY", "sk-test-456"]],
47+
});
48+
49+
const envLocal = getFile(files, ".env.local");
50+
expect(envLocal).toContain("OPENAI_API_KEY=sk-test-456");
51+
expect(envLocal).not.toContain("# OPENAI_API_KEY=");
52+
expect(envLocal).not.toContain("# ANTHROPIC_API_KEY=");
53+
expect(envLocal).not.toContain("# AI_GATEWAY_API_KEY=");
54+
});
55+
56+
it("should not show AI provider placeholders when AI_GATEWAY_API_KEY is provided", () => {
57+
const files = getFilesForTemplate(template, {
58+
packageName: "test-project",
59+
aiProvider: "vercel",
60+
envLocal: [["AI_GATEWAY_API_KEY", "gateway-key-789"]],
61+
});
62+
63+
const envLocal = getFile(files, ".env.local");
64+
expect(envLocal).toContain("AI_GATEWAY_API_KEY=gateway-key-789");
65+
expect(envLocal).not.toContain("# OPENAI_API_KEY=");
66+
expect(envLocal).not.toContain("# ANTHROPIC_API_KEY=");
67+
expect(envLocal).not.toContain("# AI_GATEWAY_API_KEY=");
68+
});
69+
70+
it("should preserve variable order from envLocal array", () => {
71+
const files = getFilesForTemplate(template, {
72+
packageName: "test-project",
73+
aiProvider: "anthropic",
74+
envLocal: [
75+
["CUSTOM_VAR_1", "value1"],
76+
["ANTHROPIC_API_KEY", "sk-test-123"],
77+
["CUSTOM_VAR_2", "value2"],
78+
],
79+
});
80+
81+
const envLocal = getFile(files, ".env.local");
82+
if (!envLocal) {
83+
throw new Error("envLocal is undefined");
84+
}
85+
const customVar1Index = envLocal.indexOf("CUSTOM_VAR_1=value1");
86+
const apiKeyIndex = envLocal.indexOf("ANTHROPIC_API_KEY=sk-test-123");
87+
const customVar2Index = envLocal.indexOf("CUSTOM_VAR_2=value2");
88+
89+
expect(customVar1Index).toBeLessThan(apiKeyIndex);
90+
expect(apiKeyIndex).toBeLessThan(customVar2Index);
91+
});
92+
});
93+
}
94+
95+
describe("getFilesForTemplate", () => {
96+
describe("scratch template", () => {
97+
testAiProviderKeyBehavior("scratch");
98+
99+
it("should render package.json with correct dependencies for anthropic provider", () => {
100+
const files = getFilesForTemplate("scratch", {
101+
packageName: "test-project",
102+
aiProvider: "anthropic",
103+
envLocal: [],
104+
});
105+
const packageJsonContent = getFile(files, "package.json");
106+
if (!packageJsonContent) {
107+
throw new Error("packageJson is undefined");
108+
}
109+
110+
const packageJson = JSON.parse(packageJsonContent);
111+
expect(packageJson.name).toBe("test-project");
112+
expect(packageJson.devDependencies["@ai-sdk/anthropic"]).toBe("latest");
113+
expect(packageJson.devDependencies["@ai-sdk/openai"]).toBeUndefined();
114+
});
115+
116+
it("should render package.json with correct dependencies for openai provider", () => {
117+
const files = getFilesForTemplate("scratch", {
118+
packageName: "test-project",
119+
aiProvider: "openai",
120+
envLocal: [],
121+
});
122+
123+
const packageJson = JSON.parse(getFile(files, "package.json"));
124+
expect(packageJson.devDependencies["@ai-sdk/openai"]).toBe("latest");
125+
expect(packageJson.devDependencies["@ai-sdk/anthropic"]).toBeUndefined();
126+
});
127+
});
128+
129+
describe("slack-bot template", () => {
130+
testAiProviderKeyBehavior("slack-bot");
131+
132+
it("should show Slack placeholders when envLocal is empty", () => {
133+
const files = getFilesForTemplate("slack-bot", {
134+
packageName: "test-slack-bot",
135+
aiProvider: "anthropic",
136+
envLocal: [],
137+
});
138+
139+
const envLocal = getFile(files, ".env.local");
140+
expect(envLocal).toContain("SLACK_BOT_TOKEN=xoxb-your-token-here");
141+
expect(envLocal).toContain(
142+
"SLACK_SIGNING_SECRET=your-signing-secret-here"
143+
);
144+
});
145+
146+
it("should show Slack placeholders even when AI key is provided", () => {
147+
const files = getFilesForTemplate("slack-bot", {
148+
packageName: "test-slack-bot",
149+
aiProvider: "openai",
150+
envLocal: [["OPENAI_API_KEY", "sk-test-456"]],
151+
});
152+
153+
const envLocal = getFile(files, ".env.local");
154+
expect(envLocal).toContain("SLACK_BOT_TOKEN=xoxb-your-token-here");
155+
expect(envLocal).toContain(
156+
"SLACK_SIGNING_SECRET=your-signing-secret-here"
157+
);
158+
});
159+
160+
it("should render package.json with slack dependencies", () => {
161+
const files = getFilesForTemplate("slack-bot", {
162+
packageName: "test-slack-bot",
163+
aiProvider: "anthropic",
164+
envLocal: [],
165+
});
166+
167+
const packageJson = JSON.parse(getFile(files, "package.json"));
168+
expect(packageJson.name).toBe("test-slack-bot");
169+
expect(packageJson.devDependencies["@slack/bolt"]).toBe("latest");
170+
expect(packageJson.devDependencies["@blink-sdk/slack"]).toBe("latest");
171+
});
172+
});
173+
174+
describe("agent.ts template rendering", () => {
175+
it("should render agent.ts with anthropic provider", () => {
176+
const files = getFilesForTemplate("scratch", {
177+
packageName: "test-project",
178+
aiProvider: "anthropic",
179+
envLocal: [],
180+
});
181+
182+
const agentTs = getFile(files, "agent.ts");
183+
expect(agentTs).toContain(
184+
'import { anthropic } from "@ai-sdk/anthropic"'
185+
);
186+
expect(agentTs).toContain('model: anthropic("claude-sonnet-4-5")');
187+
expect(agentTs).not.toContain("import { openai }");
188+
});
189+
190+
it("should render agent.ts with openai provider", () => {
191+
const files = getFilesForTemplate("scratch", {
192+
packageName: "test-project",
193+
aiProvider: "openai",
194+
envLocal: [],
195+
});
196+
197+
const agentTs = getFile(files, "agent.ts");
198+
expect(agentTs).toContain('import { openai } from "@ai-sdk/openai"');
199+
expect(agentTs).toContain('model: openai("gpt-5-codex")');
200+
expect(agentTs).not.toContain("import { anthropic }");
201+
});
202+
203+
it("should render agent.ts with vercel provider fallback", () => {
204+
const files = getFilesForTemplate("scratch", {
205+
packageName: "test-project",
206+
aiProvider: "vercel",
207+
envLocal: [],
208+
});
209+
210+
const agentTs = getFile(files, "agent.ts");
211+
expect(agentTs).toContain('model: "anthropic/claude-sonnet-4.5"');
212+
});
213+
});
214+
});

packages/blink/src/cli/init.ts

Lines changed: 29 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -15,11 +15,12 @@ import Handlebars from "handlebars";
1515
import { templates, type TemplateId } from "./init-templates";
1616
import { setupSlackApp } from "./setup-slack-app";
1717

18-
function getFilesForTemplate(
18+
export function getFilesForTemplate(
1919
template: TemplateId,
2020
variables: {
2121
packageName: string;
2222
aiProvider: string;
23+
envLocal: Array<[string, string]>;
2324
}
2425
): Record<string, string> {
2526
const templateFiles = templates[template];
@@ -28,6 +29,26 @@ function getFilesForTemplate(
2829
// Register eq helper for Handlebars
2930
Handlebars.registerHelper("eq", (a, b) => a === b);
3031

32+
// Register helper to check if a key exists in envLocal array
33+
Handlebars.registerHelper(
34+
"hasEnvVar",
35+
(envLocal: Array<[string, string]>, key: string) => {
36+
return envLocal.some((tuple) => tuple[0] === key);
37+
}
38+
);
39+
40+
// Register helper to check if any of multiple keys exist in envLocal array
41+
Handlebars.registerHelper(
42+
"hasAnyEnvVar",
43+
(envLocal: Array<[string, string]>, ...keys) => {
44+
// Remove the last argument which is the Handlebars options object
45+
const keysToCheck = keys.slice(0, -1);
46+
return keysToCheck.some((key) =>
47+
envLocal.some((tuple) => tuple[0] === key)
48+
);
49+
}
50+
);
51+
3152
// Copy all files and render .hbs templates
3253
for (const [filename, content] of Object.entries(templateFiles)) {
3354
let outputFilename = filename;
@@ -171,9 +192,16 @@ export default async function init(directory?: string): Promise<void> {
171192

172193
log.info(`Using ${packageManager} as the package manager.`);
173194

195+
// Build envLocal array with API key if provided
196+
const envLocal: Array<[string, string]> = [];
197+
if (apiKey && apiKey.trim() !== "") {
198+
envLocal.push([envVarName, apiKey]);
199+
}
200+
174201
const files = getFilesForTemplate(template, {
175202
packageName: name,
176203
aiProvider: aiProviderChoice,
204+
envLocal,
177205
});
178206

179207
await Promise.all(
@@ -182,25 +210,7 @@ export default async function init(directory?: string): Promise<void> {
182210
})
183211
);
184212

185-
// Append API key to .env.local if provided
186213
if (apiKey && apiKey.trim() !== "") {
187-
const envFilePath = join(directory, ".env.local");
188-
let existingContent = "";
189-
190-
// Read existing content if file exists
191-
try {
192-
existingContent = await readFile(envFilePath, "utf-8");
193-
} catch (error) {
194-
// File doesn't exist yet, that's fine
195-
}
196-
197-
// Ensure existing content ends with newline if it has content
198-
if (existingContent.length > 0 && !existingContent.endsWith("\n")) {
199-
existingContent += "\n";
200-
}
201-
202-
const newContent = existingContent + `${envVarName}=${apiKey}\n`;
203-
await writeFile(envFilePath, newContent);
204214
log.success(`API key saved to .env.local`);
205215
}
206216

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import { readdir, readFile, writeFile } from "fs/promises";
2+
import stringify from "json-stable-stringify";
3+
import { join } from "path";
4+
5+
export async function generateTemplates(): Promise<
6+
Record<string, Record<string, string>>
7+
> {
8+
const templatesDir = join(import.meta.dirname, "..", "init-templates");
9+
10+
// Read all template directories
11+
const entries = await readdir(templatesDir, { withFileTypes: true });
12+
const templateDirs = entries
13+
.filter((entry) => entry.isDirectory())
14+
.map((entry) => entry.name);
15+
16+
const templates: Record<string, Record<string, string>> = {};
17+
18+
// Read each template directory
19+
for (const templateId of templateDirs) {
20+
const templatePath = join(templatesDir, templateId);
21+
const files = await readdir(templatePath);
22+
23+
templates[templateId] = {};
24+
25+
for (const file of files) {
26+
const filePath = join(templatePath, file);
27+
const content = await readFile(filePath, "utf-8");
28+
29+
// Strip "_noignore" prefix from filename if present
30+
const outputFilename = file.startsWith("_noignore")
31+
? file.substring("_noignore".length)
32+
: file;
33+
34+
templates[templateId][outputFilename] = content;
35+
}
36+
}
37+
return templates;
38+
}

0 commit comments

Comments
 (0)