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
137 changes: 129 additions & 8 deletions packages/blink/src/cli/init.test.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,15 @@
import { describe, it, expect } from "bun:test";
import { getFilesForTemplate } from "./init";
import { render, BLINK_COMMAND, makeTmpDir, KEY_CODES } from "./lib/terminal";
import { describe, it, expect, mock } from "bun:test";
import { getFilesForTemplate, getAvailablePackageManagers } from "./init";
import {
render,
BLINK_COMMAND,
makeTmpDir,
KEY_CODES,
pathToCliEntrypoint,
} from "./lib/terminal";
import { join } from "path";
import { readFile } from "fs/promises";
import { readFile, writeFile, chmod, mkdir } from "fs/promises";
import { execSync } from "child_process";

const getFile = (files: Record<string, string>, filename: string): string => {
const fileContent = files[filename];
Expand Down Expand Up @@ -241,10 +248,9 @@ describe("init command", () => {
screen.includes("What package manager do you want to use?")
);
const screen = term.getScreen();
expect(screen).toContain("Bun");
expect(screen).toContain("NPM");
expect(screen).toContain("PNPM");
expect(screen).toContain("Yarn");
// At least one package manager should be available in the test environment
// We don't check for all of them since they may not be installed
expect(screen.includes("Bun")).toBe(true);
term.write(KEY_CODES.ENTER);
await term.waitUntil((screen) =>
screen.includes("API key saved to .env.local")
Expand All @@ -254,4 +260,119 @@ describe("init command", () => {
const envFileContent = await readFile(envFilePath, "utf-8");
expect(envFileContent.split("\n")).toContain("OPENAI_API_KEY=sk-test-123");
});

describe("package manager detection", () => {
async function setupMockPackageManagers(
packageManagers: Array<"bun" | "npm" | "pnpm" | "yarn">
): Promise<AsyncDisposable & { binDir: string; PATH: string }> {
const tmpDir = await makeTmpDir();
const binDir = join(tmpDir.path, "bin");
await mkdir(binDir);

const allPackageManagers = ["bun", "npm", "pnpm", "yarn"] as const;

// Create dummy executables for each package manager
for (const pm of allPackageManagers) {
const scriptPath = join(binDir, pm);
if (packageManagers.includes(pm)) {
// Create working mock for available package managers
await writeFile(scriptPath, `#!/bin/sh\nexit 0\n`, "utf-8");
} else {
// Create failing mock for unavailable package managers
await writeFile(scriptPath, `#!/bin/sh\nexit 1\n`, "utf-8");
}
await chmod(scriptPath, 0o755);
}

// Prepend our bin directory to PATH so our mocks are found first,
// but keep the rest of PATH so system commands like 'script' still work
const newPath = `${binDir}:${process.env.PATH || ""}`;

return {
binDir,
PATH: newPath,
[Symbol.asyncDispose]: () => tmpDir[Symbol.asyncDispose](),
};
}

const absoluteBunPath = execSync("which bun").toString().trim();

async function navigateToPackageManagerPrompt(
PATH: string
): Promise<AsyncDisposable & { screen: string }> {
const tempDir = await makeTmpDir();
using term = render(`${absoluteBunPath} ${pathToCliEntrypoint} init`, {
cwd: tempDir.path,
env: { ...process.env, PATH },
});

// Navigate through prompts to package manager selection
await term.waitUntil((screen) => screen.includes("Scratch"));
term.write(KEY_CODES.DOWN);
await term.waitUntil((screen) =>
screen.includes("Basic agent with example tool")
);
term.write(KEY_CODES.ENTER);

await term.waitUntil((screen) =>
screen.includes("Which AI provider do you want to use?")
);
term.write(KEY_CODES.ENTER);

await term.waitUntil((screen) =>
screen.includes("Enter your OpenAI API key:")
);
term.write(KEY_CODES.ENTER); // Skip API key

// Wait for either package manager prompt or manual install message
await term.waitUntil(
(screen) =>
screen.includes("What package manager do you want to use?") ||
screen.includes("Please install dependencies by running:")
);

return {
screen: term.getScreen(),
[Symbol.asyncDispose]: () => tempDir[Symbol.asyncDispose](),
};
}

it("should show all package managers when all are available", async () => {
await using mockPms = await setupMockPackageManagers([
"bun",
"npm",
"pnpm",
"yarn",
]);
await using result = await navigateToPackageManagerPrompt(mockPms.PATH);

// All package managers should be available
expect(result.screen).toContain("Bun");
expect(result.screen).toContain("NPM");
expect(result.screen).toContain("PNPM");
expect(result.screen).toContain("Yarn");
});

it("should show only bun and npm when only they are available", async () => {
await using mockPms = await setupMockPackageManagers(["bun", "npm"]);
await using result = await navigateToPackageManagerPrompt(mockPms.PATH);

// Only bun and npm should be available
expect(result.screen).toContain("Bun");
expect(result.screen).toContain("NPM");
expect(result.screen).not.toContain("PNPM");
expect(result.screen).not.toContain("Yarn");
});

it("should show manual install message when no package managers are available", async () => {
await using mockPms = await setupMockPackageManagers([]);
await using result = await navigateToPackageManagerPrompt(mockPms.PATH);

// Should show manual install message instead of package manager selection
expect(result.screen).toContain("npm install");
expect(result.screen).not.toContain(
"What package manager do you want to use?"
);
});
});
});
117 changes: 72 additions & 45 deletions packages/blink/src/cli/init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,21 @@ import {
select,
text,
} from "@clack/prompts";
import { spawn } from "child_process";
import { spawn, exec } from "child_process";
import { readdir, readFile, writeFile } from "fs/promises";
import { basename, join } from "path";
import Handlebars from "handlebars";
import { templates, type TemplateId } from "./init-templates";
import { setupSlackApp } from "./setup-slack-app";

async function isCommandAvailable(command: string): Promise<boolean> {
return new Promise((resolve, reject) => {
exec(`${command} --version`, { timeout: 5000 }, (error) => {
resolve(!error);
});
});
}

export function getFilesForTemplate(
template: TemplateId,
variables: {
Expand Down Expand Up @@ -70,6 +78,28 @@ export function getFilesForTemplate(
return files;
}

const packageManagers = [
{ label: "Bun", value: "bun" },
{ label: "NPM", value: "npm" },
{ label: "PNPM", value: "pnpm" },
{ label: "Yarn", value: "yarn" },
] as const;

export async function getAvailablePackageManagers(): Promise<
(typeof packageManagers)[number][]
> {
const availabilityChecks = await Promise.all(
packageManagers.map(async ({ value: pm }) => {
const available = await isCommandAvailable(pm);
return { pm, available };
})
);
return packageManagers.filter(
({ value: pm }) =>
availabilityChecks.find(({ pm: pm2 }) => pm2 === pm)?.available
);
}

export default async function init(directory?: string): Promise<void> {
if (!directory) {
directory = process.cwd();
Expand Down Expand Up @@ -108,6 +138,9 @@ export default async function init(directory?: string): Promise<void> {
}
const template = templateChoice satisfies TemplateId;

// spawn the promise in advance to avoid delaying the UI
const availablePackageManagersPromise = getAvailablePackageManagers();

const aiProviders = {
openai: { envVar: "OPENAI_API_KEY", label: "OpenAI" },
anthropic: { envVar: "ANTHROPIC_API_KEY", label: "Anthropic" },
Expand Down Expand Up @@ -162,35 +195,27 @@ export default async function init(directory?: string): Promise<void> {
packageManager = "npm";
}
if (!packageManager) {
// Ask the user what to use.
const pm = await select({
options: [
{
label: "Bun",
value: "bun",
},
{
label: "NPM",
value: "npm",
},
{
label: "PNPM",
value: "pnpm",
},
{
label: "Yarn",
value: "yarn",
},
],
message: "What package manager do you want to use?",
});
if (isCancel(pm)) {
process.exit(0);
const availablePackageManagers = await availablePackageManagersPromise;

if (availablePackageManagers.length === 0) {
log.info("Please install dependencies by running:");
log.info(" npm install");
} else {
// Ask the user what to use from available options
const pm = await select({
options: availablePackageManagers,
message: "What package manager do you want to use?",
});
if (isCancel(pm)) {
process.exit(0);
}
packageManager = pm;
}
packageManager = pm;
}

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

// Build envLocal array with API key if provided
const envLocal: Array<[string, string]> = [];
Expand All @@ -217,24 +242,26 @@ export default async function init(directory?: string): Promise<void> {
// Log a newline which makes it look a bit nicer.
console.log("");

const child = spawn(packageManager, ["install"], {
stdio: "inherit",
cwd: directory,
});

await new Promise((resolve, reject) => {
child.on("close", (code) => {
if (code === 0) {
resolve(undefined);
} else {
}
if (packageManager) {
const child = spawn(packageManager, ["install"], {
stdio: "inherit",
cwd: directory,
});
child.on("error", (error) => {
reject(error);

await new Promise((resolve, reject) => {
child.on("close", (code) => {
if (code === 0) {
resolve(undefined);
} else {
}
});
child.on("error", (error) => {
reject(error);
});
});
});
// Log a newline which makes it look a bit nicer.
console.log("");
// Log a newline which makes it look a bit nicer.
console.log("");
}

let exitProcessManually = false;

Expand Down Expand Up @@ -266,11 +293,11 @@ export default async function init(directory?: string): Promise<void> {
npm: "npm run dev",
pnpm: "pnpm run dev",
yarn: "yarn dev",
}[packageManager];
}[packageManager ?? "npm"];

log.success(`To get started, run:

${runDevCommand ?? "blink dev"}`);
${runDevCommand}`);
outro("Edit agent.ts to hot-reload your agent.");

if (exitProcessManually) {
Expand Down
2 changes: 1 addition & 1 deletion packages/blink/src/cli/lib/terminal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -211,7 +211,7 @@ class TerminalInstanceImpl implements TerminalInstance {
}
}

const pathToCliEntrypoint = join(import.meta.dirname, "..", "index.ts");
export const pathToCliEntrypoint = join(import.meta.dirname, "..", "index.ts");
export const BLINK_COMMAND = `bun ${pathToCliEntrypoint}`;

export function render(
Expand Down
2 changes: 1 addition & 1 deletion packages/blink/src/tui/dev.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ const Root = ({ directory }: { directory: string }) => {
},
onBuildError: (error) => {
console.log(
chalk.red(`⚙ ${error.message}${error.file ? ` (${error.file})` : ""}`)
`${chalk.red(`⚙ [Build Error]`)} ${chalk.gray(error.message)}${error.file ? chalk.bold(` (${error.file})`) : ""}`
);
},
onEnvLoaded: (keys) => {
Expand Down