Skip to content

Commit 90a20b4

Browse files
authored
feat: only list available package managers in blink init (#47)
1 parent ed26599 commit 90a20b4

File tree

4 files changed

+203
-55
lines changed

4 files changed

+203
-55
lines changed

packages/blink/src/cli/init.test.ts

Lines changed: 129 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,15 @@
1-
import { describe, it, expect } from "bun:test";
2-
import { getFilesForTemplate } from "./init";
3-
import { render, BLINK_COMMAND, makeTmpDir, KEY_CODES } from "./lib/terminal";
1+
import { describe, it, expect, mock } from "bun:test";
2+
import { getFilesForTemplate, getAvailablePackageManagers } from "./init";
3+
import {
4+
render,
5+
BLINK_COMMAND,
6+
makeTmpDir,
7+
KEY_CODES,
8+
pathToCliEntrypoint,
9+
} from "./lib/terminal";
410
import { join } from "path";
5-
import { readFile } from "fs/promises";
11+
import { readFile, writeFile, chmod, mkdir } from "fs/promises";
12+
import { execSync } from "child_process";
613

714
const getFile = (files: Record<string, string>, filename: string): string => {
815
const fileContent = files[filename];
@@ -241,10 +248,9 @@ describe("init command", () => {
241248
screen.includes("What package manager do you want to use?")
242249
);
243250
const screen = term.getScreen();
244-
expect(screen).toContain("Bun");
245-
expect(screen).toContain("NPM");
246-
expect(screen).toContain("PNPM");
247-
expect(screen).toContain("Yarn");
251+
// At least one package manager should be available in the test environment
252+
// We don't check for all of them since they may not be installed
253+
expect(screen.includes("Bun")).toBe(true);
248254
term.write(KEY_CODES.ENTER);
249255
await term.waitUntil((screen) =>
250256
screen.includes("API key saved to .env.local")
@@ -254,4 +260,119 @@ describe("init command", () => {
254260
const envFileContent = await readFile(envFilePath, "utf-8");
255261
expect(envFileContent.split("\n")).toContain("OPENAI_API_KEY=sk-test-123");
256262
});
263+
264+
describe("package manager detection", () => {
265+
async function setupMockPackageManagers(
266+
packageManagers: Array<"bun" | "npm" | "pnpm" | "yarn">
267+
): Promise<AsyncDisposable & { binDir: string; PATH: string }> {
268+
const tmpDir = await makeTmpDir();
269+
const binDir = join(tmpDir.path, "bin");
270+
await mkdir(binDir);
271+
272+
const allPackageManagers = ["bun", "npm", "pnpm", "yarn"] as const;
273+
274+
// Create dummy executables for each package manager
275+
for (const pm of allPackageManagers) {
276+
const scriptPath = join(binDir, pm);
277+
if (packageManagers.includes(pm)) {
278+
// Create working mock for available package managers
279+
await writeFile(scriptPath, `#!/bin/sh\nexit 0\n`, "utf-8");
280+
} else {
281+
// Create failing mock for unavailable package managers
282+
await writeFile(scriptPath, `#!/bin/sh\nexit 1\n`, "utf-8");
283+
}
284+
await chmod(scriptPath, 0o755);
285+
}
286+
287+
// Prepend our bin directory to PATH so our mocks are found first,
288+
// but keep the rest of PATH so system commands like 'script' still work
289+
const newPath = `${binDir}:${process.env.PATH || ""}`;
290+
291+
return {
292+
binDir,
293+
PATH: newPath,
294+
[Symbol.asyncDispose]: () => tmpDir[Symbol.asyncDispose](),
295+
};
296+
}
297+
298+
const absoluteBunPath = execSync("which bun").toString().trim();
299+
300+
async function navigateToPackageManagerPrompt(
301+
PATH: string
302+
): Promise<AsyncDisposable & { screen: string }> {
303+
const tempDir = await makeTmpDir();
304+
using term = render(`${absoluteBunPath} ${pathToCliEntrypoint} init`, {
305+
cwd: tempDir.path,
306+
env: { ...process.env, PATH },
307+
});
308+
309+
// Navigate through prompts to package manager selection
310+
await term.waitUntil((screen) => screen.includes("Scratch"));
311+
term.write(KEY_CODES.DOWN);
312+
await term.waitUntil((screen) =>
313+
screen.includes("Basic agent with example tool")
314+
);
315+
term.write(KEY_CODES.ENTER);
316+
317+
await term.waitUntil((screen) =>
318+
screen.includes("Which AI provider do you want to use?")
319+
);
320+
term.write(KEY_CODES.ENTER);
321+
322+
await term.waitUntil((screen) =>
323+
screen.includes("Enter your OpenAI API key:")
324+
);
325+
term.write(KEY_CODES.ENTER); // Skip API key
326+
327+
// Wait for either package manager prompt or manual install message
328+
await term.waitUntil(
329+
(screen) =>
330+
screen.includes("What package manager do you want to use?") ||
331+
screen.includes("Please install dependencies by running:")
332+
);
333+
334+
return {
335+
screen: term.getScreen(),
336+
[Symbol.asyncDispose]: () => tempDir[Symbol.asyncDispose](),
337+
};
338+
}
339+
340+
it("should show all package managers when all are available", async () => {
341+
await using mockPms = await setupMockPackageManagers([
342+
"bun",
343+
"npm",
344+
"pnpm",
345+
"yarn",
346+
]);
347+
await using result = await navigateToPackageManagerPrompt(mockPms.PATH);
348+
349+
// All package managers should be available
350+
expect(result.screen).toContain("Bun");
351+
expect(result.screen).toContain("NPM");
352+
expect(result.screen).toContain("PNPM");
353+
expect(result.screen).toContain("Yarn");
354+
});
355+
356+
it("should show only bun and npm when only they are available", async () => {
357+
await using mockPms = await setupMockPackageManagers(["bun", "npm"]);
358+
await using result = await navigateToPackageManagerPrompt(mockPms.PATH);
359+
360+
// Only bun and npm should be available
361+
expect(result.screen).toContain("Bun");
362+
expect(result.screen).toContain("NPM");
363+
expect(result.screen).not.toContain("PNPM");
364+
expect(result.screen).not.toContain("Yarn");
365+
});
366+
367+
it("should show manual install message when no package managers are available", async () => {
368+
await using mockPms = await setupMockPackageManagers([]);
369+
await using result = await navigateToPackageManagerPrompt(mockPms.PATH);
370+
371+
// Should show manual install message instead of package manager selection
372+
expect(result.screen).toContain("npm install");
373+
expect(result.screen).not.toContain(
374+
"What package manager do you want to use?"
375+
);
376+
});
377+
});
257378
});

packages/blink/src/cli/init.ts

Lines changed: 72 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -8,13 +8,21 @@ import {
88
select,
99
text,
1010
} from "@clack/prompts";
11-
import { spawn } from "child_process";
11+
import { spawn, exec } from "child_process";
1212
import { readdir, readFile, writeFile } from "fs/promises";
1313
import { basename, join } from "path";
1414
import Handlebars from "handlebars";
1515
import { templates, type TemplateId } from "./init-templates";
1616
import { setupSlackApp } from "./setup-slack-app";
1717

18+
async function isCommandAvailable(command: string): Promise<boolean> {
19+
return new Promise((resolve, reject) => {
20+
exec(`${command} --version`, { timeout: 5000 }, (error) => {
21+
resolve(!error);
22+
});
23+
});
24+
}
25+
1826
export function getFilesForTemplate(
1927
template: TemplateId,
2028
variables: {
@@ -70,6 +78,28 @@ export function getFilesForTemplate(
7078
return files;
7179
}
7280

81+
const packageManagers = [
82+
{ label: "Bun", value: "bun" },
83+
{ label: "NPM", value: "npm" },
84+
{ label: "PNPM", value: "pnpm" },
85+
{ label: "Yarn", value: "yarn" },
86+
] as const;
87+
88+
export async function getAvailablePackageManagers(): Promise<
89+
(typeof packageManagers)[number][]
90+
> {
91+
const availabilityChecks = await Promise.all(
92+
packageManagers.map(async ({ value: pm }) => {
93+
const available = await isCommandAvailable(pm);
94+
return { pm, available };
95+
})
96+
);
97+
return packageManagers.filter(
98+
({ value: pm }) =>
99+
availabilityChecks.find(({ pm: pm2 }) => pm2 === pm)?.available
100+
);
101+
}
102+
73103
export default async function init(directory?: string): Promise<void> {
74104
if (!directory) {
75105
directory = process.cwd();
@@ -108,6 +138,9 @@ export default async function init(directory?: string): Promise<void> {
108138
}
109139
const template = templateChoice satisfies TemplateId;
110140

141+
// spawn the promise in advance to avoid delaying the UI
142+
const availablePackageManagersPromise = getAvailablePackageManagers();
143+
111144
const aiProviders = {
112145
openai: { envVar: "OPENAI_API_KEY", label: "OpenAI" },
113146
anthropic: { envVar: "ANTHROPIC_API_KEY", label: "Anthropic" },
@@ -162,35 +195,27 @@ export default async function init(directory?: string): Promise<void> {
162195
packageManager = "npm";
163196
}
164197
if (!packageManager) {
165-
// Ask the user what to use.
166-
const pm = await select({
167-
options: [
168-
{
169-
label: "Bun",
170-
value: "bun",
171-
},
172-
{
173-
label: "NPM",
174-
value: "npm",
175-
},
176-
{
177-
label: "PNPM",
178-
value: "pnpm",
179-
},
180-
{
181-
label: "Yarn",
182-
value: "yarn",
183-
},
184-
],
185-
message: "What package manager do you want to use?",
186-
});
187-
if (isCancel(pm)) {
188-
process.exit(0);
198+
const availablePackageManagers = await availablePackageManagersPromise;
199+
200+
if (availablePackageManagers.length === 0) {
201+
log.info("Please install dependencies by running:");
202+
log.info(" npm install");
203+
} else {
204+
// Ask the user what to use from available options
205+
const pm = await select({
206+
options: availablePackageManagers,
207+
message: "What package manager do you want to use?",
208+
});
209+
if (isCancel(pm)) {
210+
process.exit(0);
211+
}
212+
packageManager = pm;
189213
}
190-
packageManager = pm;
191214
}
192215

193-
log.info(`Using ${packageManager} as the package manager.`);
216+
if (packageManager) {
217+
log.info(`Using ${packageManager} as the package manager.`);
218+
}
194219

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

220-
const child = spawn(packageManager, ["install"], {
221-
stdio: "inherit",
222-
cwd: directory,
223-
});
224-
225-
await new Promise((resolve, reject) => {
226-
child.on("close", (code) => {
227-
if (code === 0) {
228-
resolve(undefined);
229-
} else {
230-
}
245+
if (packageManager) {
246+
const child = spawn(packageManager, ["install"], {
247+
stdio: "inherit",
248+
cwd: directory,
231249
});
232-
child.on("error", (error) => {
233-
reject(error);
250+
251+
await new Promise((resolve, reject) => {
252+
child.on("close", (code) => {
253+
if (code === 0) {
254+
resolve(undefined);
255+
} else {
256+
}
257+
});
258+
child.on("error", (error) => {
259+
reject(error);
260+
});
234261
});
235-
});
236-
// Log a newline which makes it look a bit nicer.
237-
console.log("");
262+
// Log a newline which makes it look a bit nicer.
263+
console.log("");
264+
}
238265

239266
let exitProcessManually = false;
240267

@@ -266,11 +293,11 @@ export default async function init(directory?: string): Promise<void> {
266293
npm: "npm run dev",
267294
pnpm: "pnpm run dev",
268295
yarn: "yarn dev",
269-
}[packageManager];
296+
}[packageManager ?? "npm"];
270297

271298
log.success(`To get started, run:
272299
273-
${runDevCommand ?? "blink dev"}`);
300+
${runDevCommand}`);
274301
outro("Edit agent.ts to hot-reload your agent.");
275302

276303
if (exitProcessManually) {

packages/blink/src/cli/lib/terminal.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -211,7 +211,7 @@ class TerminalInstanceImpl implements TerminalInstance {
211211
}
212212
}
213213

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

217217
export function render(

packages/blink/src/tui/dev.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,7 @@ const Root = ({ directory }: { directory: string }) => {
5858
},
5959
onBuildError: (error) => {
6060
console.log(
61-
chalk.red(`⚙ ${error.message}${error.file ? ` (${error.file})` : ""}`)
61+
`${chalk.red(`⚙ [Build Error]`)} ${chalk.gray(error.message)}${error.file ? chalk.bold(` (${error.file})`) : ""}`
6262
);
6363
},
6464
onEnvLoaded: (keys) => {

0 commit comments

Comments
 (0)