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
14 changes: 7 additions & 7 deletions packages/blink/src/cli/init-templates/index.ts

Large diffs are not rendered by default.

This file was deleted.

Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
# Store local environment variables here.
# They will be used by blink dev for development.
{{#each envLocal}}
{{this.[0]}}={{this.[1]}}
{{/each}}
{{#unless (hasAnyEnvVar envLocal "OPENAI_API_KEY" "ANTHROPIC_API_KEY" "AI_GATEWAY_API_KEY")}}
# OPENAI_API_KEY=
# ANTHROPIC_API_KEY=
# AI_GATEWAY_API_KEY=
{{/unless}}
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
# Store production environment variables here.
# They will be upserted as secrets on blink deploy.
# EXTERNAL_SERVICE_API_KEY=
# OPENAI_API_KEY=
# ANTHROPIC_API_KEY=
# AI_GATEWAY_API_KEY=

This file was deleted.

Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
# Store local environment variables here.
# They will be used by blink dev for development.
{{#each envLocal}}
{{this.[0]}}={{this.[1]}}
{{/each}}
{{#unless (hasAnyEnvVar envLocal "OPENAI_API_KEY" "ANTHROPIC_API_KEY" "AI_GATEWAY_API_KEY")}}
# OPENAI_API_KEY=
# ANTHROPIC_API_KEY=
# AI_GATEWAY_API_KEY=
{{/unless}}
SLACK_BOT_TOKEN=xoxb-your-token-here
SLACK_SIGNING_SECRET=your-signing-secret-here
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
# Store production environment variables here.
# They will be upserted as secrets on blink deploy.
# EXTERNAL_SERVICE_API_KEY=
# SLACK_BOT_TOKEN=
# SLACK_SIGNING_SECRET=
# OPENAI_API_KEY=
# ANTHROPIC_API_KEY=
# AI_GATEWAY_API_KEY=
214 changes: 214 additions & 0 deletions packages/blink/src/cli/init.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,214 @@
import { describe, it, expect } from "bun:test";
import { getFilesForTemplate } from "./init";

const getFile = (files: Record<string, string>, filename: string): string => {
const fileContent = files[filename];
if (fileContent === undefined) {
throw new Error(`File ${filename} is undefined`);
}
return fileContent;
};

// Test helper for .env.local AI provider key behavior
function testAiProviderKeyBehavior(template: "scratch" | "slack-bot") {
describe(".env.local AI provider key behavior", () => {
it("should show AI provider placeholders when envLocal is empty", () => {
const files = getFilesForTemplate(template, {
packageName: "test-project",
aiProvider: "anthropic",
envLocal: [],
});

const envLocal = getFile(files, ".env.local");
expect(envLocal).toContain("# OPENAI_API_KEY=");
expect(envLocal).toContain("# ANTHROPIC_API_KEY=");
expect(envLocal).toContain("# AI_GATEWAY_API_KEY=");
});

it("should not show AI provider placeholders when ANTHROPIC_API_KEY is provided", () => {
const files = getFilesForTemplate(template, {
packageName: "test-project",
aiProvider: "anthropic",
envLocal: [["ANTHROPIC_API_KEY", "sk-test-123"]],
});

const envLocal = getFile(files, ".env.local");
expect(envLocal).toContain("ANTHROPIC_API_KEY=sk-test-123");
expect(envLocal).not.toContain("# OPENAI_API_KEY=");
expect(envLocal).not.toContain("# ANTHROPIC_API_KEY=");
expect(envLocal).not.toContain("# AI_GATEWAY_API_KEY=");
});

it("should not show AI provider placeholders when OPENAI_API_KEY is provided", () => {
const files = getFilesForTemplate(template, {
packageName: "test-project",
aiProvider: "openai",
envLocal: [["OPENAI_API_KEY", "sk-test-456"]],
});

const envLocal = getFile(files, ".env.local");
expect(envLocal).toContain("OPENAI_API_KEY=sk-test-456");
expect(envLocal).not.toContain("# OPENAI_API_KEY=");
expect(envLocal).not.toContain("# ANTHROPIC_API_KEY=");
expect(envLocal).not.toContain("# AI_GATEWAY_API_KEY=");
});

it("should not show AI provider placeholders when AI_GATEWAY_API_KEY is provided", () => {
const files = getFilesForTemplate(template, {
packageName: "test-project",
aiProvider: "vercel",
envLocal: [["AI_GATEWAY_API_KEY", "gateway-key-789"]],
});

const envLocal = getFile(files, ".env.local");
expect(envLocal).toContain("AI_GATEWAY_API_KEY=gateway-key-789");
expect(envLocal).not.toContain("# OPENAI_API_KEY=");
expect(envLocal).not.toContain("# ANTHROPIC_API_KEY=");
expect(envLocal).not.toContain("# AI_GATEWAY_API_KEY=");
});

it("should preserve variable order from envLocal array", () => {
const files = getFilesForTemplate(template, {
packageName: "test-project",
aiProvider: "anthropic",
envLocal: [
["CUSTOM_VAR_1", "value1"],
["ANTHROPIC_API_KEY", "sk-test-123"],
["CUSTOM_VAR_2", "value2"],
],
});

const envLocal = getFile(files, ".env.local");
if (!envLocal) {
throw new Error("envLocal is undefined");
}
const customVar1Index = envLocal.indexOf("CUSTOM_VAR_1=value1");
const apiKeyIndex = envLocal.indexOf("ANTHROPIC_API_KEY=sk-test-123");
const customVar2Index = envLocal.indexOf("CUSTOM_VAR_2=value2");

expect(customVar1Index).toBeLessThan(apiKeyIndex);
expect(apiKeyIndex).toBeLessThan(customVar2Index);
});
});
}

describe("getFilesForTemplate", () => {
describe("scratch template", () => {
testAiProviderKeyBehavior("scratch");

it("should render package.json with correct dependencies for anthropic provider", () => {
const files = getFilesForTemplate("scratch", {
packageName: "test-project",
aiProvider: "anthropic",
envLocal: [],
});
const packageJsonContent = getFile(files, "package.json");
if (!packageJsonContent) {
throw new Error("packageJson is undefined");
}

const packageJson = JSON.parse(packageJsonContent);
expect(packageJson.name).toBe("test-project");
expect(packageJson.devDependencies["@ai-sdk/anthropic"]).toBe("latest");
expect(packageJson.devDependencies["@ai-sdk/openai"]).toBeUndefined();
});

it("should render package.json with correct dependencies for openai provider", () => {
const files = getFilesForTemplate("scratch", {
packageName: "test-project",
aiProvider: "openai",
envLocal: [],
});

const packageJson = JSON.parse(getFile(files, "package.json"));
expect(packageJson.devDependencies["@ai-sdk/openai"]).toBe("latest");
expect(packageJson.devDependencies["@ai-sdk/anthropic"]).toBeUndefined();
});
});

describe("slack-bot template", () => {
testAiProviderKeyBehavior("slack-bot");

it("should show Slack placeholders when envLocal is empty", () => {
const files = getFilesForTemplate("slack-bot", {
packageName: "test-slack-bot",
aiProvider: "anthropic",
envLocal: [],
});

const envLocal = getFile(files, ".env.local");
expect(envLocal).toContain("SLACK_BOT_TOKEN=xoxb-your-token-here");
expect(envLocal).toContain(
"SLACK_SIGNING_SECRET=your-signing-secret-here"
);
});

it("should show Slack placeholders even when AI key is provided", () => {
const files = getFilesForTemplate("slack-bot", {
packageName: "test-slack-bot",
aiProvider: "openai",
envLocal: [["OPENAI_API_KEY", "sk-test-456"]],
});

const envLocal = getFile(files, ".env.local");
expect(envLocal).toContain("SLACK_BOT_TOKEN=xoxb-your-token-here");
expect(envLocal).toContain(
"SLACK_SIGNING_SECRET=your-signing-secret-here"
);
});

it("should render package.json with slack dependencies", () => {
const files = getFilesForTemplate("slack-bot", {
packageName: "test-slack-bot",
aiProvider: "anthropic",
envLocal: [],
});

const packageJson = JSON.parse(getFile(files, "package.json"));
expect(packageJson.name).toBe("test-slack-bot");
expect(packageJson.devDependencies["@slack/bolt"]).toBe("latest");
expect(packageJson.devDependencies["@blink-sdk/slack"]).toBe("latest");
});
});

describe("agent.ts template rendering", () => {
it("should render agent.ts with anthropic provider", () => {
const files = getFilesForTemplate("scratch", {
packageName: "test-project",
aiProvider: "anthropic",
envLocal: [],
});

const agentTs = getFile(files, "agent.ts");
expect(agentTs).toContain(
'import { anthropic } from "@ai-sdk/anthropic"'
);
expect(agentTs).toContain('model: anthropic("claude-sonnet-4-5")');
expect(agentTs).not.toContain("import { openai }");
});

it("should render agent.ts with openai provider", () => {
const files = getFilesForTemplate("scratch", {
packageName: "test-project",
aiProvider: "openai",
envLocal: [],
});

const agentTs = getFile(files, "agent.ts");
expect(agentTs).toContain('import { openai } from "@ai-sdk/openai"');
expect(agentTs).toContain('model: openai("gpt-5-codex")');
expect(agentTs).not.toContain("import { anthropic }");
});

it("should render agent.ts with vercel provider fallback", () => {
const files = getFilesForTemplate("scratch", {
packageName: "test-project",
aiProvider: "vercel",
envLocal: [],
});

const agentTs = getFile(files, "agent.ts");
expect(agentTs).toContain('model: "anthropic/claude-sonnet-4.5"');
});
});
});
48 changes: 29 additions & 19 deletions packages/blink/src/cli/init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,11 +15,12 @@ import Handlebars from "handlebars";
import { templates, type TemplateId } from "./init-templates";
import { setupSlackApp } from "./setup-slack-app";

function getFilesForTemplate(
export function getFilesForTemplate(
template: TemplateId,
variables: {
packageName: string;
aiProvider: string;
envLocal: Array<[string, string]>;
}
): Record<string, string> {
const templateFiles = templates[template];
Expand All @@ -28,6 +29,26 @@ function getFilesForTemplate(
// Register eq helper for Handlebars
Handlebars.registerHelper("eq", (a, b) => a === b);

// Register helper to check if a key exists in envLocal array
Handlebars.registerHelper(
"hasEnvVar",
(envLocal: Array<[string, string]>, key: string) => {
return envLocal.some((tuple) => tuple[0] === key);
}
);

// Register helper to check if any of multiple keys exist in envLocal array
Handlebars.registerHelper(
"hasAnyEnvVar",
(envLocal: Array<[string, string]>, ...keys) => {
// Remove the last argument which is the Handlebars options object
const keysToCheck = keys.slice(0, -1);
return keysToCheck.some((key) =>
envLocal.some((tuple) => tuple[0] === key)
);
}
);

// Copy all files and render .hbs templates
for (const [filename, content] of Object.entries(templateFiles)) {
let outputFilename = filename;
Expand Down Expand Up @@ -171,9 +192,16 @@ export default async function init(directory?: string): Promise<void> {

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

// Build envLocal array with API key if provided
const envLocal: Array<[string, string]> = [];
if (apiKey && apiKey.trim() !== "") {
envLocal.push([envVarName, apiKey]);
}

const files = getFilesForTemplate(template, {
packageName: name,
aiProvider: aiProviderChoice,
envLocal,
});

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

// Append API key to .env.local if provided
if (apiKey && apiKey.trim() !== "") {
const envFilePath = join(directory, ".env.local");
let existingContent = "";

// Read existing content if file exists
try {
existingContent = await readFile(envFilePath, "utf-8");
} catch (error) {
// File doesn't exist yet, that's fine
}

// Ensure existing content ends with newline if it has content
if (existingContent.length > 0 && !existingContent.endsWith("\n")) {
existingContent += "\n";
}

const newContent = existingContent + `${envVarName}=${apiKey}\n`;
await writeFile(envFilePath, newContent);
log.success(`API key saved to .env.local`);
}

Expand Down
38 changes: 38 additions & 0 deletions packages/blink/src/cli/lib/templates.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { readdir, readFile, writeFile } from "fs/promises";
import stringify from "json-stable-stringify";
import { join } from "path";

export async function generateTemplates(): Promise<
Record<string, Record<string, string>>
> {
const templatesDir = join(import.meta.dirname, "..", "init-templates");

// Read all template directories
const entries = await readdir(templatesDir, { withFileTypes: true });
const templateDirs = entries
.filter((entry) => entry.isDirectory())
.map((entry) => entry.name);

const templates: Record<string, Record<string, string>> = {};

// Read each template directory
for (const templateId of templateDirs) {
const templatePath = join(templatesDir, templateId);
const files = await readdir(templatePath);

templates[templateId] = {};

for (const file of files) {
const filePath = join(templatePath, file);
const content = await readFile(filePath, "utf-8");

// Strip "_noignore" prefix from filename if present
const outputFilename = file.startsWith("_noignore")
? file.substring("_noignore".length)
: file;

templates[templateId][outputFilename] = content;
}
}
return templates;
}
Loading