From af2a1687c9bbcc581f5a0677ca5bf5f7d7058657 Mon Sep 17 00:00:00 2001
From: thucpn <thucsh2@gmail.com>
Date: Mon, 4 Dec 2023 13:51:53 +0700
Subject: [PATCH] feat: options to download community projects

---
 create-app.ts       |   2 +
 helpers/constant.ts |   2 +
 helpers/repo.ts     |  63 +++++++++
 index.ts            | 334 +++++++++++++++++++++++++-------------------
 templates/index.ts  |  21 +++
 templates/types.ts  |   1 +
 6 files changed, 277 insertions(+), 146 deletions(-)
 create mode 100644 helpers/constant.ts
 create mode 100644 helpers/repo.ts

diff --git a/create-app.ts b/create-app.ts
index 05186ed4..1e13f5ca 100644
--- a/create-app.ts
+++ b/create-app.ts
@@ -31,6 +31,7 @@ export async function createApp({
   frontend,
   openAIKey,
   model,
+  communityProjectPath,
 }: InstallAppArgs): Promise<void> {
   const root = path.resolve(appPath);
 
@@ -69,6 +70,7 @@ export async function createApp({
     eslint,
     openAIKey,
     model,
+    communityProjectPath,
   };
 
   if (frontend) {
diff --git a/helpers/constant.ts b/helpers/constant.ts
new file mode 100644
index 00000000..341fba2c
--- /dev/null
+++ b/helpers/constant.ts
@@ -0,0 +1,2 @@
+export const COMMUNITY_OWNER = "run-llama";
+export const COMMUNITY_REPO = "create_llama_projects";
diff --git a/helpers/repo.ts b/helpers/repo.ts
new file mode 100644
index 00000000..2471a91a
--- /dev/null
+++ b/helpers/repo.ts
@@ -0,0 +1,63 @@
+import { createWriteStream, promises } from "fs";
+import got from "got";
+import { tmpdir } from "os";
+import { join } from "path";
+import { Stream } from "stream";
+import tar from "tar";
+import { promisify } from "util";
+import { makeDir } from "./make-dir";
+
+export type RepoInfo = {
+  username: string;
+  name: string;
+  branch: string;
+  filePath: string;
+};
+
+const pipeline = promisify(Stream.pipeline);
+
+async function downloadTar(url: string) {
+  const tempFile = join(tmpdir(), `next.js-cna-example.temp-${Date.now()}`);
+  await pipeline(got.stream(url), createWriteStream(tempFile));
+  return tempFile;
+}
+
+export async function downloadAndExtractRepo(
+  root: string,
+  { username, name, branch, filePath }: RepoInfo,
+) {
+  await makeDir(root);
+
+  const tempFile = await downloadTar(
+    `https://codeload.github.com/${username}/${name}/tar.gz/${branch}`,
+  );
+
+  await tar.x({
+    file: tempFile,
+    cwd: root,
+    strip: filePath ? filePath.split("/").length + 1 : 1,
+    filter: (p) =>
+      p.startsWith(
+        `${name}-${branch.replace(/\//g, "-")}${
+          filePath ? `/${filePath}/` : "/"
+        }`,
+      ),
+  });
+
+  await promises.unlink(tempFile);
+}
+
+export async function getRepoRootFolders(
+  owner: string,
+  repo: string,
+): Promise<string[]> {
+  const url = `https://api.github.com/repos/${owner}/${repo}/contents`;
+
+  const response = await got(url, {
+    responseType: "json",
+  });
+
+  const data = response.body as any[];
+  const folders = data.filter((item) => item.type === "dir");
+  return folders.map((item) => item.name);
+}
diff --git a/index.ts b/index.ts
index 67a0f53c..3d5ff919 100644
--- a/index.ts
+++ b/index.ts
@@ -9,8 +9,10 @@ import { blue, bold, cyan, green, red, yellow } from "picocolors";
 import prompts from "prompts";
 import checkForUpdate from "update-check";
 import { InstallAppArgs, createApp } from "./create-app";
+import { COMMUNITY_OWNER, COMMUNITY_REPO } from "./helpers/constant";
 import { getPkgManager } from "./helpers/get-pkg-manager";
 import { isFolderEmpty } from "./helpers/is-folder-empty";
+import { getRepoRootFolders } from "./helpers/repo";
 import { validateNpmName } from "./helpers/validate-pkg";
 import packageJson from "./package.json";
 
@@ -169,6 +171,7 @@ async function run(): Promise<void> {
     frontend: false,
     openAIKey: "",
     model: "gpt-3.5-turbo",
+    communityProjectPath: "",
   };
   const getPrefOrDefault = (field: keyof Args) =>
     preferences[field] ?? defaults[field];
@@ -180,32 +183,6 @@ async function run(): Promise<void> {
     },
   };
 
-  if (!program.framework) {
-    if (ciInfo.isCI) {
-      program.framework = getPrefOrDefault("framework");
-    } else {
-      const { framework } = await prompts(
-        {
-          type: "select",
-          name: "framework",
-          message: "Which framework would you like to use?",
-          choices: [
-            { title: "NextJS", value: "nextjs" },
-            { title: "Express", value: "express" },
-            { title: "FastAPI (Python)", value: "fastapi" },
-          ],
-          initial: 0,
-        },
-        handlers,
-      );
-      program.framework = framework;
-      preferences.framework = framework;
-    }
-  }
-
-  if (program.framework === "nextjs") {
-    program.template = "streaming";
-  }
   if (!program.template) {
     if (ciInfo.isCI) {
       program.template = getPrefOrDefault("template");
@@ -218,6 +195,7 @@ async function run(): Promise<void> {
           choices: [
             { title: "Chat without streaming", value: "simple" },
             { title: "Chat with streaming", value: "streaming" },
+            { title: "Community templates", value: "community" },
           ],
           initial: 1,
         },
@@ -228,145 +206,208 @@ async function run(): Promise<void> {
     }
   }
 
-  if (program.framework === "express" || program.framework === "fastapi") {
-    // if a backend-only framework is selected, ask whether we should create a frontend
-    if (!program.frontend) {
-      if (ciInfo.isCI) {
-        program.frontend = getPrefOrDefault("frontend");
-      } else {
-        const styledNextJS = blue("NextJS");
-        const styledBackend = green(
-          program.framework === "express"
-            ? "Express "
-            : program.framework === "fastapi"
-              ? "FastAPI (Python) "
-              : "",
-        );
-        const { frontend } = await prompts({
-          onState: onPromptState,
-          type: "toggle",
-          name: "frontend",
-          message: `Would you like to generate a ${styledNextJS} frontend for your ${styledBackend}backend?`,
-          initial: getPrefOrDefault("frontend"),
-          active: "Yes",
-          inactive: "No",
-        });
-        program.frontend = Boolean(frontend);
-        preferences.frontend = Boolean(frontend);
-      }
-    }
-  }
+  if (program.template === "community") {
+    const rootFolderNames = await getRepoRootFolders(
+      COMMUNITY_OWNER,
+      COMMUNITY_REPO,
+    );
+    const { communityProjectPath } = await prompts(
+      {
+        type: "select",
+        name: "communityProjectPath",
+        message: "Select community templates?",
+        choices: rootFolderNames.map((name) => ({
+          title: name,
+          value: name,
+        })),
+        initial: 0,
+      },
+      {
+        onCancel: () => {
+          console.error("Exiting.");
+          process.exit(1);
+        },
+      },
+    );
 
-  if (program.framework === "nextjs" || program.frontend) {
-    if (!program.ui) {
+    program.communityProjectPath = communityProjectPath;
+    preferences.communityProjectPath = communityProjectPath;
+  } else {
+    if (!program.framework) {
       if (ciInfo.isCI) {
-        program.ui = getPrefOrDefault("ui");
+        program.framework = getPrefOrDefault("framework");
       } else {
-        const { ui } = await prompts(
+        const allChoices = [
+          { title: "NextJS", value: "nextjs" },
+          { title: "Express", value: "express" },
+          { title: "FastAPI (Python)", value: "fastapi" },
+        ];
+        const choiceIndexes =
+          program.template === "simple" ? [1, 2] : [0, 1, 2];
+        const choices = allChoices.filter((_, i) => choiceIndexes.includes(i));
+
+        const { framework } = await prompts(
           {
             type: "select",
-            name: "ui",
-            message: "Which UI would you like to use?",
-            choices: [
-              { title: "Just HTML", value: "html" },
-              { title: "Shadcn", value: "shadcn" },
-            ],
+            name: "framework",
+            message: "Which framework would you like to use?",
+            choices,
             initial: 0,
           },
           handlers,
         );
-        program.ui = ui;
-        preferences.ui = ui;
+        program.framework = framework;
+        preferences.framework = framework;
       }
     }
-  }
 
-  if (program.framework === "nextjs") {
-    if (!program.model) {
-      if (ciInfo.isCI) {
-        program.model = getPrefOrDefault("model");
-      } else {
-        const { model } = await prompts(
-          {
-            type: "select",
-            name: "model",
-            message: "Which model would you like to use?",
-            choices: [
-              { title: "gpt-3.5-turbo", value: "gpt-3.5-turbo" },
-              { title: "gpt-4", value: "gpt-4" },
-              { title: "gpt-4-1106-preview", value: "gpt-4-1106-preview" },
-              { title: "gpt-4-vision-preview", value: "gpt-4-vision-preview" },
-            ],
-            initial: 0,
-          },
-          handlers,
-        );
-        program.model = model;
-        preferences.model = model;
+    if (program.framework === "nextjs") {
+      program.template = "streaming";
+    }
+
+    if (program.framework === "express" || program.framework === "fastapi") {
+      // if a backend-only framework is selected, ask whether we should create a frontend
+      if (!program.frontend) {
+        if (ciInfo.isCI) {
+          program.frontend = getPrefOrDefault("frontend");
+        } else {
+          const styledNextJS = blue("NextJS");
+          const styledBackend = green(
+            program.framework === "express"
+              ? "Express "
+              : program.framework === "fastapi"
+                ? "FastAPI (Python) "
+                : "",
+          );
+          const { frontend } = await prompts({
+            onState: onPromptState,
+            type: "toggle",
+            name: "frontend",
+            message: `Would you like to generate a ${styledNextJS} frontend for your ${styledBackend}backend?`,
+            initial: getPrefOrDefault("frontend"),
+            active: "Yes",
+            inactive: "No",
+          });
+          program.frontend = Boolean(frontend);
+          preferences.frontend = Boolean(frontend);
+        }
       }
     }
-  }
 
-  if (program.framework === "express" || program.framework === "nextjs") {
-    if (!program.engine) {
-      if (ciInfo.isCI) {
-        program.engine = getPrefOrDefault("engine");
-      } else {
-        const { engine } = await prompts(
-          {
-            type: "select",
-            name: "engine",
-            message: "Which chat engine would you like to use?",
-            choices: [
-              { title: "ContextChatEngine", value: "context" },
-              {
-                title: "SimpleChatEngine (no data, just chat)",
-                value: "simple",
-              },
-            ],
-            initial: 0,
-          },
-          handlers,
-        );
-        program.engine = engine;
-        preferences.engine = engine;
+    if (program.framework === "nextjs" || program.frontend) {
+      if (!program.ui) {
+        if (ciInfo.isCI) {
+          program.ui = getPrefOrDefault("ui");
+        } else {
+          const { ui } = await prompts(
+            {
+              type: "select",
+              name: "ui",
+              message: "Which UI would you like to use?",
+              choices: [
+                { title: "Just HTML", value: "html" },
+                { title: "Shadcn", value: "shadcn" },
+              ],
+              initial: 0,
+            },
+            handlers,
+          );
+          program.ui = ui;
+          preferences.ui = ui;
+        }
       }
     }
-  }
 
-  if (!program.openAIKey) {
-    const { key } = await prompts(
-      {
-        type: "text",
-        name: "key",
-        message: "Please provide your OpenAI API key (leave blank to skip):",
-      },
-      handlers,
-    );
-    program.openAIKey = key;
-    preferences.openAIKey = key;
-  }
+    if (program.framework === "nextjs") {
+      if (!program.model) {
+        if (ciInfo.isCI) {
+          program.model = getPrefOrDefault("model");
+        } else {
+          const { model } = await prompts(
+            {
+              type: "select",
+              name: "model",
+              message: "Which model would you like to use?",
+              choices: [
+                { title: "gpt-3.5-turbo", value: "gpt-3.5-turbo" },
+                { title: "gpt-4", value: "gpt-4" },
+                { title: "gpt-4-1106-preview", value: "gpt-4-1106-preview" },
+                {
+                  title: "gpt-4-vision-preview",
+                  value: "gpt-4-vision-preview",
+                },
+              ],
+              initial: 0,
+            },
+            handlers,
+          );
+          program.model = model;
+          preferences.model = model;
+        }
+      }
+    }
 
-  if (
-    program.framework !== "fastapi" &&
-    !process.argv.includes("--eslint") &&
-    !process.argv.includes("--no-eslint")
-  ) {
-    if (ciInfo.isCI) {
-      program.eslint = getPrefOrDefault("eslint");
-    } else {
-      const styledEslint = blue("ESLint");
-      const { eslint } = await prompts({
-        onState: onPromptState,
-        type: "toggle",
-        name: "eslint",
-        message: `Would you like to use ${styledEslint}?`,
-        initial: getPrefOrDefault("eslint"),
-        active: "Yes",
-        inactive: "No",
-      });
-      program.eslint = Boolean(eslint);
-      preferences.eslint = Boolean(eslint);
+    if (program.framework === "express" || program.framework === "nextjs") {
+      if (!program.engine) {
+        if (ciInfo.isCI) {
+          program.engine = getPrefOrDefault("engine");
+        } else {
+          const { engine } = await prompts(
+            {
+              type: "select",
+              name: "engine",
+              message: "Which chat engine would you like to use?",
+              choices: [
+                { title: "ContextChatEngine", value: "context" },
+                {
+                  title: "SimpleChatEngine (no data, just chat)",
+                  value: "simple",
+                },
+              ],
+              initial: 0,
+            },
+            handlers,
+          );
+          program.engine = engine;
+          preferences.engine = engine;
+        }
+      }
+    }
+
+    if (!program.openAIKey) {
+      const { key } = await prompts(
+        {
+          type: "text",
+          name: "key",
+          message: "Please provide your OpenAI API key (leave blank to skip):",
+        },
+        handlers,
+      );
+      program.openAIKey = key;
+      preferences.openAIKey = key;
+    }
+
+    if (
+      program.framework !== "fastapi" &&
+      !process.argv.includes("--eslint") &&
+      !process.argv.includes("--no-eslint")
+    ) {
+      if (ciInfo.isCI) {
+        program.eslint = getPrefOrDefault("eslint");
+      } else {
+        const styledEslint = blue("ESLint");
+        const { eslint } = await prompts({
+          onState: onPromptState,
+          type: "toggle",
+          name: "eslint",
+          message: `Would you like to use ${styledEslint}?`,
+          initial: getPrefOrDefault("eslint"),
+          active: "Yes",
+          inactive: "No",
+        });
+        program.eslint = Boolean(eslint);
+        preferences.eslint = Boolean(eslint);
+      }
     }
   }
 
@@ -381,6 +422,7 @@ async function run(): Promise<void> {
     frontend: program.frontend,
     openAIKey: program.openAIKey,
     model: program.model,
+    communityProjectPath: program.communityProjectPath,
   });
   conf.set("preferences", preferences);
 }
diff --git a/templates/index.ts b/templates/index.ts
index fd5377d8..40102304 100644
--- a/templates/index.ts
+++ b/templates/index.ts
@@ -7,7 +7,9 @@ import path from "path";
 import { bold, cyan } from "picocolors";
 import { version } from "../../core/package.json";
 
+import { COMMUNITY_OWNER, COMMUNITY_REPO } from "../helpers/constant";
 import { PackageManager } from "../helpers/get-pkg-manager";
+import { downloadAndExtractRepo } from "../helpers/repo";
 import {
   InstallTemplateArgs,
   TemplateEngine,
@@ -306,10 +308,29 @@ const installPythonTemplate = async ({
   );
 };
 
+const installCommunityProject = async ({
+  root,
+  communityProjectPath,
+}: Pick<InstallTemplateArgs, "root" | "communityProjectPath">) => {
+  console.log("\nInstalling community project:", communityProjectPath!);
+  await downloadAndExtractRepo(root, {
+    username: COMMUNITY_OWNER,
+    name: COMMUNITY_REPO,
+    branch: "main",
+    filePath: communityProjectPath!,
+  });
+};
+
 export const installTemplate = async (
   props: InstallTemplateArgs & { backend: boolean },
 ) => {
   process.chdir(props.root);
+
+  if (props.communityProjectPath) {
+    await installCommunityProject(props);
+    return;
+  }
+
   if (props.framework === "fastapi") {
     await installPythonTemplate(props);
   } else {
diff --git a/templates/types.ts b/templates/types.ts
index f6af4de0..bcdd37cd 100644
--- a/templates/types.ts
+++ b/templates/types.ts
@@ -19,4 +19,5 @@ export interface InstallTemplateArgs {
   openAIKey?: string;
   forBackend?: string;
   model: string;
+  communityProjectPath?: string;
 }
-- 
GitLab