diff --git a/packages/blink/src/cli/init.test.ts b/packages/blink/src/cli/init.test.ts index feb37dd..cd1983d 100644 --- a/packages/blink/src/cli/init.test.ts +++ b/packages/blink/src/cli/init.test.ts @@ -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, filename: string): string => { const fileContent = files[filename]; @@ -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") @@ -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 { + 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 { + 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?" + ); + }); + }); }); diff --git a/packages/blink/src/cli/init.ts b/packages/blink/src/cli/init.ts index 8d65c37..71fb157 100644 --- a/packages/blink/src/cli/init.ts +++ b/packages/blink/src/cli/init.ts @@ -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 { + return new Promise((resolve, reject) => { + exec(`${command} --version`, { timeout: 5000 }, (error) => { + resolve(!error); + }); + }); +} + export function getFilesForTemplate( template: TemplateId, variables: { @@ -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 { if (!directory) { directory = process.cwd(); @@ -108,6 +138,9 @@ export default async function init(directory?: string): Promise { } 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" }, @@ -162,35 +195,27 @@ export default async function init(directory?: string): Promise { 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]> = []; @@ -217,24 +242,26 @@ export default async function init(directory?: string): Promise { // 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; @@ -266,11 +293,11 @@ export default async function init(directory?: string): Promise { 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) { diff --git a/packages/blink/src/cli/lib/terminal.ts b/packages/blink/src/cli/lib/terminal.ts index 126c841..f33eadf 100644 --- a/packages/blink/src/cli/lib/terminal.ts +++ b/packages/blink/src/cli/lib/terminal.ts @@ -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( diff --git a/packages/blink/src/tui/dev.tsx b/packages/blink/src/tui/dev.tsx index 0030301..523be42 100644 --- a/packages/blink/src/tui/dev.tsx +++ b/packages/blink/src/tui/dev.tsx @@ -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) => {