From 653b93db1df3a3a3293e49cac1c8c7ec530762ad Mon Sep 17 00:00:00 2001 From: "Huu Le (Lee)" <39040748+leehuwuj@users.noreply.github.com> Date: Mon, 22 Jan 2024 14:03:57 +0700 Subject: [PATCH] add run app option (#399) --- create-app.ts | 4 +- e2e/basic.spec.ts | 34 ++-- e2e/utils.ts | 148 ++++++++++-------- helpers/python.ts | 57 ++++--- helpers/run-app.ts | 77 +++++++++ helpers/types.ts | 3 +- helpers/typescript.ts | 39 +++-- index.ts | 27 +++- questions.ts | 58 +++++-- templates/types/simple/express/package.json | 3 + .../types/streaming/express/package.json | 3 + templates/types/streaming/nextjs/package.json | 3 + 12 files changed, 312 insertions(+), 144 deletions(-) create mode 100644 helpers/run-app.ts diff --git a/create-app.ts b/create-app.ts index a0d43701..2725e3d9 100644 --- a/create-app.ts +++ b/create-app.ts @@ -34,7 +34,7 @@ export async function createApp({ communityProjectPath, vectorDb, externalPort, - installDependencies, + postInstallAction, }: InstallAppArgs): Promise<void> { const root = path.resolve(appPath); @@ -76,7 +76,7 @@ export async function createApp({ communityProjectPath, vectorDb, externalPort, - installDependencies, + postInstallAction, }; if (frontend) { diff --git a/e2e/basic.spec.ts b/e2e/basic.spec.ts index 3e76f526..ee4ba33b 100644 --- a/e2e/basic.spec.ts +++ b/e2e/basic.spec.ts @@ -9,7 +9,7 @@ import type { TemplateType, TemplateUI, } from "../helpers"; -import { createTestDir, runApp, runCreateLlama, type AppType } from "./utils"; +import { createTestDir, runCreateLlama, type AppType } from "./utils"; const templateTypes: TemplateType[] = ["streaming", "simple"]; const templateFrameworks: TemplateFramework[] = [ @@ -47,27 +47,26 @@ for (const templateType of templateTypes) { let externalPort: number; let cwd: string; let name: string; - let cps: ChildProcess[] = []; + let appProcess: ChildProcess; + const postInstallAction = "runApp"; test.beforeAll(async () => { port = Math.floor(Math.random() * 10000) + 10000; externalPort = port + 1; - cwd = await createTestDir(); - name = runCreateLlama( + const result = await runCreateLlama( cwd, templateType, templateFramework, templateEngine, templateUI, appType, + port, externalPort, + postInstallAction, ); - - if (templateFramework !== "fastapi") { - // don't run the app for fastapi for now (adds python dependency) - cps = await runApp(cwd, name, appType, port, externalPort); - } + name = result.projectName; + appProcess = result.appProcess; }); test("App folder should exist", async () => { @@ -75,9 +74,7 @@ for (const templateType of templateTypes) { expect(dirExists).toBeTruthy(); }); test("Frontend should have a title", async ({ page }) => { - test.skip( - appType === "--no-frontend" || templateFramework === "fastapi", - ); + test.skip(appType === "--no-frontend"); await page.goto(`http://localhost:${port}`); await expect(page.getByText("Built by LlamaIndex")).toBeVisible(); }); @@ -85,9 +82,7 @@ for (const templateType of templateTypes) { test("Frontend should be able to submit a message and receive a response", async ({ page, }) => { - test.skip( - appType === "--no-frontend" || templateFramework === "fastapi", - ); + test.skip(appType === "--no-frontend"); await page.goto(`http://localhost:${port}`); await page.fill("form input", "hello"); await page.click("form button[type=submit]"); @@ -107,11 +102,10 @@ for (const templateType of templateTypes) { test("Backend should response when calling API", async ({ request, }) => { - test.skip( - appType !== "--no-frontend" || templateFramework === "fastapi", - ); + test.skip(appType !== "--no-frontend"); + const backendPort = appType === "" ? port : externalPort; const response = await request.post( - `http://localhost:${port}/api/chat`, + `http://localhost:${backendPort}/api/chat`, { data: { messages: [ @@ -130,7 +124,7 @@ for (const templateType of templateTypes) { // clean processes test.afterAll(async () => { - cps.map((cp) => cp.kill()); + appProcess.kill(); }); }); } diff --git a/e2e/utils.ts b/e2e/utils.ts index ac291e9a..9e2c8dde 100644 --- a/e2e/utils.ts +++ b/e2e/utils.ts @@ -1,81 +1,77 @@ -import { ChildProcess, exec, execSync } from "child_process"; +import { ChildProcess, exec } from "child_process"; import crypto from "node:crypto"; import { mkdir } from "node:fs/promises"; import * as path from "path"; import waitPort from "wait-port"; +import { + TemplateEngine, + TemplateFramework, + TemplatePostInstallAction, + TemplateType, + TemplateUI, +} from "../helpers"; export type AppType = "--frontend" | "--no-frontend" | ""; const MODEL = "gpt-3.5-turbo"; +export type CreateLlamaResult = { + projectName: string; + appProcess: ChildProcess; +}; // eslint-disable-next-line max-params -export async function runApp( - cwd: string, - name: string, - appType: AppType, +export async function checkAppHasStarted( + frontend: boolean, + framework: TemplateFramework, port: number, externalPort: number, -): Promise<ChildProcess[]> { - const cps: ChildProcess[] = []; - - try { - switch (appType) { - case "--frontend": - cps.push( - await createProcess( - "npm run dev", - path.join(cwd, name, "backend"), - externalPort, - ), - ); - cps.push( - await createProcess( - "npm run dev", - path.join(cwd, name, "frontend"), - port, - ), - ); - break; - default: - cps.push( - await createProcess("npm run dev", path.join(cwd, name), port), - ); - break; + timeout: number, +) { + if (frontend) { + await Promise.all([ + waitPort({ + host: "localhost", + port: port, + timeout, + }), + waitPort({ + host: "localhost", + port: externalPort, + timeout, + }), + ]).catch((err) => { + console.error(err); + throw err; + }); + } else { + let wPort: number; + if (framework === "nextjs") { + wPort = port; + } else { + wPort = externalPort; } - } catch (e) { - cps.forEach((cp) => cp.kill()); - throw e; + await waitPort({ + host: "localhost", + port: wPort, + timeout, + }).catch((err) => { + console.error(err); + throw err; + }); } - return cps; -} - -async function createProcess(command: string, cwd: string, port: number) { - const cp = exec(command, { - cwd, - env: { - ...process.env, - PORT: `${port}`, - }, - }); - if (!cp) throw new Error(`Can't start process ${command} in ${cwd}`); - - await waitPort({ - host: "localhost", - port, - timeout: 1000 * 60, - }); - return cp; } // eslint-disable-next-line max-params -export function runCreateLlama( +export async function runCreateLlama( cwd: string, - templateType: string, - templateFramework: string, - templateEngine: string, - templateUI: string, + templateType: TemplateType, + templateFramework: TemplateFramework, + templateEngine: TemplateEngine, + templateUI: TemplateUI, appType: AppType, + port: number, externalPort: number, -) { + postInstallAction: TemplatePostInstallAction, +): Promise<CreateLlamaResult> { const createLlama = path.join(__dirname, "..", "dist", "index.js"); const name = [ @@ -104,17 +100,43 @@ export function runCreateLlama( appType, "--eslint", "--use-npm", + "--port", + port, "--external-port", externalPort, - "--install-dependencies", + "--post-install-action", + postInstallAction, ].join(" "); console.log(`running command '${command}' in ${cwd}`); - execSync(command, { - stdio: "inherit", + let appProcess = exec(command, { cwd, }); - return name; + appProcess.on("error", (err) => { + console.error(err); + appProcess.kill(); + }); + // Show log from cp + appProcess.stdout?.on("data", (data) => { + console.log(data.toString()); + }); + + // Wait for app to start + if (postInstallAction === "runApp") { + await checkAppHasStarted( + appType === "--frontend", + templateFramework, + port, + externalPort, + 1000 * 60 * 5, + ); + } + + return { + projectName: name, + appProcess, + }; } + export async function createTestDir() { const cwd = path.join(__dirname, ".cache", crypto.randomUUID()); await mkdir(cwd, { recursive: true }); diff --git a/helpers/python.ts b/helpers/python.ts index 4b282310..163a2f6b 100644 --- a/helpers/python.ts +++ b/helpers/python.ts @@ -1,6 +1,6 @@ import fs from "fs/promises"; import path from "path"; -import { cyan, yellow } from "picocolors"; +import { cyan, red, yellow } from "picocolors"; import { parse, stringify } from "smol-toml"; import terminalLink from "terminal-link"; import { copy } from "./copy"; @@ -92,13 +92,39 @@ export const addDependencies = async ( } }; +export const installPythonDependencies = (root: string) => { + if (isPoetryAvailable()) { + console.log( + `Installing python dependencies using poetry. This may take a while...`, + ); + const installSuccessful = tryPoetryInstall(); + if (!installSuccessful) { + console.error( + red("Install failed. Please install dependencies manually."), + ); + process.exit(1); + } + } else { + console.warn( + yellow( + `Poetry is not available in the current environment. The Python dependencies will not be installed automatically. +Please check ${terminalLink( + "Poetry Installation", + `https://python-poetry.org/docs/#installation`, + )} to install poetry first, then install the dependencies manually.`, + ), + ); + process.exit(1); + } +}; + export const installPythonTemplate = async ({ root, template, framework, engine, vectorDb, - installDependencies, + postInstallAction, }: Pick< InstallTemplateArgs, | "root" @@ -106,7 +132,7 @@ export const installPythonTemplate = async ({ | "template" | "engine" | "vectorDb" - | "installDependencies" + | "postInstallAction" >) => { console.log("\nInitializing Python project with template:", template, "\n"); const templatePath = path.join( @@ -154,28 +180,7 @@ export const installPythonTemplate = async ({ const addOnDependencies = getAdditionalDependencies(vectorDb); await addDependencies(root, addOnDependencies); - // install python dependencies - if (installDependencies) { - if (isPoetryAvailable()) { - console.log( - `Installing python dependencies using poetry. This may take a while...`, - ); - const installSuccessful = tryPoetryInstall(); - if (!installSuccessful) { - console.warn( - yellow("Install failed. Please install dependencies manually."), - ); - } - } else { - console.warn( - yellow( - `Poetry is not available in the current environment. The Python dependencies will not be installed automatically. -Please check ${terminalLink( - "Poetry Installation", - `https://python-poetry.org/docs/#installation`, - )} to install poetry first, then install the dependencies manually.`, - ), - ); - } + if (postInstallAction !== "none") { + installPythonDependencies(root); } }; diff --git a/helpers/run-app.ts b/helpers/run-app.ts new file mode 100644 index 00000000..b77da58b --- /dev/null +++ b/helpers/run-app.ts @@ -0,0 +1,77 @@ +import { ChildProcess, spawn } from "child_process"; +import { log } from "console"; +import path from "path"; +import { TemplateFramework } from "./types"; + +// eslint-disable-next-line max-params +export async function runApp( + appPath: string, + frontend: boolean, + framework: TemplateFramework, + port?: number, + externalPort?: number, +): Promise<any> { + let backendAppProcess: ChildProcess; + let frontendAppProcess: ChildProcess | undefined; + let frontendPort = port || 3000; + let backendPort = externalPort || 8000; + + // Callback to kill app processes + const killAppProcesses = () => { + log("Killing app processes..."); + backendAppProcess.kill(); + frontendAppProcess?.kill(); + }; + process.on("exit", () => { + killAppProcesses(); + }); + + let backendCommand = ""; + let backendArgs: string[]; + if (framework === "fastapi") { + backendCommand = "poetry"; + backendArgs = [ + "run", + "uvicorn", + "main:app", + "--host=0.0.0.0", + "--port=" + (externalPort || backendPort), + ]; + } else if (framework === "nextjs") { + backendCommand = "npm"; + backendArgs = ["run", "dev"]; + backendPort = frontendPort; + } else { + backendCommand = "npm"; + backendArgs = ["run", "dev"]; + } + + if (frontend) { + return new Promise((resolve, reject) => { + backendAppProcess = spawn(backendCommand, backendArgs, { + stdio: "inherit", + cwd: path.join(appPath, "backend"), + env: { ...process.env, PORT: `${backendPort}` }, + }); + frontendAppProcess = spawn("npm", ["run", "dev"], { + stdio: "inherit", + cwd: path.join(appPath, "frontend"), + env: { ...process.env, PORT: `${frontendPort}` }, + }); + }).catch((err) => { + console.error(err); + killAppProcesses(); + }); + } else { + return new Promise((resolve, reject) => { + backendAppProcess = spawn(backendCommand, backendArgs, { + stdio: "inherit", + cwd: appPath, + env: { ...process.env, PORT: `${backendPort}` }, + }); + }).catch((err) => { + console.log(err); + killAppProcesses(); + }); + } +} diff --git a/helpers/types.ts b/helpers/types.ts index 6c85c1c9..0f28caad 100644 --- a/helpers/types.ts +++ b/helpers/types.ts @@ -5,6 +5,7 @@ export type TemplateFramework = "nextjs" | "express" | "fastapi"; export type TemplateEngine = "simple" | "context"; export type TemplateUI = "html" | "shadcn"; export type TemplateVectorDB = "none" | "mongo" | "pg"; +export type TemplatePostInstallAction = "none" | "dependencies" | "runApp"; export interface InstallTemplateArgs { appName: string; @@ -23,5 +24,5 @@ export interface InstallTemplateArgs { communityProjectPath?: string; vectorDb?: TemplateVectorDB; externalPort?: number; - installDependencies?: boolean; + postInstallAction?: TemplatePostInstallAction; } diff --git a/helpers/typescript.ts b/helpers/typescript.ts index 81b89504..8b138c21 100644 --- a/helpers/typescript.ts +++ b/helpers/typescript.ts @@ -5,6 +5,7 @@ import { bold, cyan } from "picocolors"; import { version } from "../../core/package.json"; import { copy } from "../helpers/copy"; import { callPackageManager } from "../helpers/install"; +import { PackageManager } from "./get-pkg-manager"; import { InstallTemplateArgs } from "./types"; const rename = (name: string) => { @@ -23,6 +24,28 @@ const rename = (name: string) => { } } }; + +export const installTSDependencies = async ( + packageJson: any, + packageManager: PackageManager, + isOnline: boolean, +): Promise<void> => { + console.log("\nInstalling dependencies:"); + for (const dependency in packageJson.dependencies) + console.log(`- ${cyan(dependency)}`); + + console.log("\nInstalling devDependencies:"); + for (const dependency in packageJson.devDependencies) + console.log(`- ${cyan(dependency)}`); + + console.log(); + + await callPackageManager(packageManager, isOnline).catch((error) => { + console.error("Failed to install TS dependencies. Exiting..."); + process.exit(1); + }); +}; + /** * Install a LlamaIndex internal template to a given `root` directory. */ @@ -39,7 +62,7 @@ export const installTSTemplate = async ({ customApiPath, forBackend, vectorDb, - installDependencies, + postInstallAction, }: InstallTemplateArgs) => { console.log(bold(`Using ${packageManager}.`)); @@ -211,17 +234,7 @@ export const installTSTemplate = async ({ JSON.stringify(packageJson, null, 2) + os.EOL, ); - if (installDependencies) { - console.log("\nInstalling dependencies:"); - for (const dependency in packageJson.dependencies) - console.log(`- ${cyan(dependency)}`); - - console.log("\nInstalling devDependencies:"); - for (const dependency in packageJson.devDependencies) - console.log(`- ${cyan(dependency)}`); - - console.log(); - - await callPackageManager(packageManager, isOnline); + if (postInstallAction !== "none") { + await installTSDependencies(packageJson, packageManager, isOnline); } }; diff --git a/index.ts b/index.ts index 4be39f65..fae221f2 100644 --- a/index.ts +++ b/index.ts @@ -10,6 +10,7 @@ import checkForUpdate from "update-check"; import { createApp } from "./create-app"; import { getPkgManager } from "./helpers/get-pkg-manager"; import { isFolderEmpty } from "./helpers/is-folder-empty"; +import { runApp } from "./helpers/run-app"; import { validateNpmName } from "./helpers/validate-pkg"; import packageJson from "./package.json"; import { QuestionArgs, askQuestions, onPromptState } from "./questions"; @@ -110,20 +111,27 @@ const program = new Commander.Command(packageJson.name) ` Select OpenAI model to use. E.g. gpt-3.5-turbo. +`, + ) + .option( + "--port <port>", + ` + + Select UI port. `, ) .option( "--external-port <external>", ` -Select external port. + Select external port. `, ) .option( - "--install-dependencies", + "--post-install-action <action>", ` -Whether install dependencies (backend/frontend) automatically or not. + Choose an action after installation. For example, 'runApp' or 'dependencies'. The default option is just to generate the app. `, ) .allowUnknownOption() @@ -231,9 +239,20 @@ async function run(): Promise<void> { communityProjectPath: program.communityProjectPath, vectorDb: program.vectorDb, externalPort: program.externalPort, - installDependencies: program.installDependencies, + postInstallAction: program.postInstallAction, }); conf.set("preferences", preferences); + + if (program.postInstallAction === "runApp") { + console.log("Running app..."); + await runApp( + root, + program.frontend, + program.framework, + program.port, + program.externalPort, + ); + } } const update = checkForUpdate(packageJson).catch(() => null); diff --git a/questions.ts b/questions.ts index c6fd984a..90c87d42 100644 --- a/questions.ts +++ b/questions.ts @@ -20,6 +20,7 @@ const defaults: QuestionArgs = { openAiKey: "", model: "gpt-3.5-turbo", communityProjectPath: "", + postInstallAction: "dependencies", }; const handlers = { @@ -39,9 +40,9 @@ const getVectorDbChoices = (framework: TemplateFramework) => { { title: "PostgreSQL", value: "pg" }, ]; - const vectodbLang = framework === "fastapi" ? "python" : "typescript"; + const vectordbLang = framework === "fastapi" ? "python" : "typescript"; const compPath = path.join(__dirname, "..", "templates", "components"); - const vectordbPath = path.join(compPath, "vectordbs", vectodbLang); + const vectordbPath = path.join(compPath, "vectordbs", vectordbLang); const availableChoices = fs .readdirSync(vectordbPath) @@ -211,19 +212,6 @@ export const askQuestions = async ( } } - if (program.installDependencies === undefined) { - const { installDependencies } = await prompts({ - onState: onPromptState, - type: "toggle", - name: "installDependencies", - message: `Would you like to install dependencies automatically? This may take a while`, - initial: getPrefOrDefault("installDependencies"), - active: "Yes", - inactive: "No", - }); - program.installDependencies = Boolean(installDependencies); - } - if (!program.model) { if (ciInfo.isCI) { program.model = getPrefOrDefault("model"); @@ -326,6 +314,46 @@ export const askQuestions = async ( } } + // Ask for next action after installation + if (program.postInstallAction === undefined) { + if (ciInfo.isCI) { + program.postInstallAction = getPrefOrDefault("postInstallAction"); + } else { + let actionChoices = [ + { + title: "Just generate code (~1 sec)", + value: "none", + }, + { + title: "Generate code and install dependencies (~2 min)", + value: "dependencies", + }, + ]; + + const hasOpenAiKey = program.openAiKey || process.env["OPENAI_API_KEY"]; + if (program.vectorDb === "none" && hasOpenAiKey) { + actionChoices.push({ + title: + "Generate code, install dependencies, and run the app (~2 min)", + value: "runApp", + }); + } + + const { action } = await prompts( + { + type: "select", + name: "action", + message: "How would you like to proceed?", + choices: actionChoices, + initial: 1, + }, + handlers, + ); + + program.postInstallAction = action; + } + } + // TODO: consider using zod to validate the input (doesn't work like this as not every option is required) // templateUISchema.parse(program.ui); // templateEngineSchema.parse(program.engine); diff --git a/templates/types/simple/express/package.json b/templates/types/simple/express/package.json index 849261e4..b323f2f1 100644 --- a/templates/types/simple/express/package.json +++ b/templates/types/simple/express/package.json @@ -14,6 +14,9 @@ "express": "^4.18.2", "llamaindex": "0.0.37" }, + "overrides": { + "chromadb": "1.7.3" + }, "devDependencies": { "@types/cors": "^2.8.17", "@types/express": "^4.17.21", diff --git a/templates/types/streaming/express/package.json b/templates/types/streaming/express/package.json index 3e46bb5f..db5edf3a 100644 --- a/templates/types/streaming/express/package.json +++ b/templates/types/streaming/express/package.json @@ -15,6 +15,9 @@ "express": "^4.18.2", "llamaindex": "0.0.37" }, + "overrides": { + "chromadb": "1.7.3" + }, "devDependencies": { "@types/cors": "^2.8.16", "@types/express": "^4.17.21", diff --git a/templates/types/streaming/nextjs/package.json b/templates/types/streaming/nextjs/package.json index 2f23029d..2683634f 100644 --- a/templates/types/streaming/nextjs/package.json +++ b/templates/types/streaming/nextjs/package.json @@ -27,6 +27,9 @@ "supports-color": "^9.4.0", "tailwind-merge": "^2.1.0" }, + "overrides": { + "chromadb": "1.7.3" + }, "devDependencies": { "@types/node": "^20.10.3", "@types/react": "^18.2.42", -- GitLab