Repository: o1-labs/zkapp-cli
Pull request #266 (2022-10-10 17:47:24)

Working mvp of UI monorepo
Adds ability for a dev to create a UI project that lives alongside their smart contract, in the same directory and git repo.

A dev can do this by providing a `--ui` arg to the `zk project <name>` command:
```
zk project foo --ui svelte     // SvelteKit
zk project foo --ui next       // NextJS
zk project foo --ui vue        // VueJS
zk project foo --ui empty      // empty ui directory
zk project foo --ui none       // no ui directory; only the smart contract (similar to old behavior of `zk project foo`)
zk project foo --ui unsupported // example invalid value; shows an error and tells user to choose one of the supported choices a, b, c.
```
This also allow this method to be called non-interactively inside a shell script.

If no parameter is provided, a dropdown is shown:
```
❯ zk project foo
? Create an accompanying UI project? …
❯ svelte
  next
  vue
  empty
  none
```

Closes https://github.com/o1-labs/zkapp-cli/issues/259 and closes https://github.com/o1-labs/zkapp-cli/issues/269 

_Update: We've decided to use NuxtJS (a Vue metaframework) instead of Vue, since we use NextJS instead of React. See comment below for details._

jasongitmail: What prompt text is most clear?

```
Create a UI for your smart contract too?
Create an accompanying UI project too?
Create an accompanying UI too?
Create a UI project too?
Create a UI too?
Create a UI as well?
...?
```
ymekuria: 
> Create an accompanying UI project too?
> Create an accompanying UI too?

One of these two sound the best to me. I think the word accompanying is important here.



jasongitmail: @MartinMinkov I integrated your suggestions. Thanks!

Patch:
diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml
new file mode 100644
index 0000000..45fb574
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/bug_report.yml
@@ -0,0 +1,43 @@
+name: "\U0001F41E Bug report"
+description: Report an issue with the Mina zkApp CLI
+body:
+  - type: markdown
+    attributes:
+      value: |
+        Thanks for taking the time to fill out this bug report! Before you start, make sure you have the latest version of the [Mina zkApp CLI](https://www.npmjs.com/package/zkapp-cli).
+  - type: textarea
+    id: bug-description
+    attributes:
+      label: Describe the bug
+      description: Please describe the expected and actual behavior.
+    validations:
+      required: true
+  - type: textarea
+    id: reproduction
+    attributes:
+      label: Steps to Reproduce
+      description: Please provide steps for how to reproduce this issue.
+    validations:
+      required: true
+  - type: textarea
+    id: system-info
+    attributes:
+      label: System Info
+      description: Please run `zk system` in your contracts directory and paste the output here.
+      render: shell
+    validations:
+      required: true
+  - type: dropdown
+    id: severity
+    attributes:
+      label: Severity
+      options:
+        - annoyance
+        - serious, but I can work around it
+        - blocking all usage
+    validations:
+      required: true
+  - type: textarea
+    id: additional-context
+    attributes:
+      label: Additional Information
diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml
new file mode 100644
index 0000000..532cf25
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/config.yml
@@ -0,0 +1,5 @@
+blank_issues_enabled: false
+contact_links:
+  - name: Discord Chat
+    url: https://discord.com/invite/v3W4YsuBCw
+    about: Ask questions and discuss with other devs in the zkapps-developers channel on Mina's Discord.
diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml
new file mode 100644
index 0000000..4376f81
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/feature_request.yml
@@ -0,0 +1,44 @@
+name: 'Feature Request'
+description: Suggest an idea for this project
+body:
+  - type: markdown
+    attributes:
+      value: |
+        Thanks for taking the time to request this feature!
+  - type: textarea
+    id: problem
+    attributes:
+      label: Describe the problem
+      description: Please provide a clear and concise description of the problem this feature would solve. The more information you can provide here, the better.
+      placeholder: I'm always frustrated when...
+    validations:
+      required: true
+  - type: textarea
+    id: solution
+    attributes:
+      label: Describe the proposed solution
+      description: Please provide a clear and concise description of what you would like to happen.
+      placeholder: I would like to see...
+    validations:
+      required: true
+  - type: textarea
+    id: alternatives
+    attributes:
+      label: Alternatives considered
+      description: "Please provide a clear and concise description of any alternative solutions or features you've considered."
+  - type: dropdown
+    id: importance
+    attributes:
+      label: Importance
+      description: How important is this feature to you?
+      options:
+        - nice to have
+        - would make my life easier
+        - I cannot use the Mina zkApp CLI without it
+    validations:
+      required: true
+  - type: textarea
+    id: additional-context
+    attributes:
+      label: Additional Information
+      description: Add any other context or screenshots about the feature request here.
diff --git a/src/bin/index.js b/src/bin/index.js
index 8545404..77339a0 100755
--- a/src/bin/index.js
+++ b/src/bin/index.js
@@ -42,8 +42,17 @@ yargs(hideBin(process.argv))
   .command(
     ['project [name]', 'proj [name]', 'p [name]'],
     'Create a new project',
-    { name: { demand: true, string: true, hidden: true } },
-    async (argv) => await project(argv.name)
+    {
+      name: { demand: true, string: true, hidden: true },
+      ui: {
+        demand: false,
+        string: true,
+        hidden: false,
+        choices: ['svelte', 'next', 'nuxt', 'empty', 'none'],
+        description: 'Creates an accompanying UI',
+      },
+    },
+    async (argv) => await project(argv)
   )
   .command(
     ['file [name]', 'f [name]'],
diff --git a/src/lib/project.js b/src/lib/project.js
index 258bb56..b421c63 100644
--- a/src/lib/project.js
+++ b/src/lib/project.js
@@ -1,28 +1,28 @@
-const chalk = require('chalk');
 const fs = require('fs-extra');
 const path = require('path');
 const ora = require('ora');
 const sh = require('shelljs');
 const util = require('util');
 const gittar = require('gittar');
+const { prompt } = require('enquirer');
+const { spawnSync } = require('child_process');
+const { red, green, reset } = require('chalk');
 
-const _green = chalk.green;
-const _red = chalk.red;
 const shExec = util.promisify(sh.exec);
 
 /**
  * Create a new zkApp project with recommended dir structure, Prettier config,
  * testing lib, etc. Warns if already exists and does NOT overwrite.
- * @param {string} name  Desired dir name or path. Will recursively create
- *                       dirs without overwriting existing content, if needed.
+ * @param {object} argv - The arguments object provided by yargs.
+ * @param {string} argv.name - The user's desired project name.
+ * @param {string} argv.ui - The name of the UI framework to use.
  * @return {Promise<void>}
  */
-async function project(name) {
-  const lang = 'ts';
+async function project({ name, ui }) {
   const isWindows = process.platform === 'win32';
 
   if (fs.existsSync(name)) {
-    console.error(_red(`Directory already exists. Not proceeding`));
+    console.error(red(`Directory already exists. Not proceeding`));
     return;
   }
 
@@ -30,28 +30,141 @@ async function project(name) {
   // NPM `prepare` script to set up its pre-commit hook within `.git`.
   // Check before fetching project template, to not leave crud on user's system.
   if (!sh.which('git')) {
-    console.error(_red('Please ensure Git is installed, then try again.'));
+    console.error(red('Please ensure Git is installed, then try again.'));
     return;
   }
 
-  if (!(await fetchProjectTemplate(name, lang))) return;
+  let res;
+  if (!ui) {
+    try {
+      res = await prompt({
+        type: 'select',
+        name: 'ui',
+        choices: ['svelte', 'next', 'nuxt', 'empty', 'none'],
+        message: (state) => {
+          // Make the step text green upon success, else use the reset color.
+          const style =
+            state.submitted && !state.cancelled ? state.styles.success : reset;
+          return style('Create an accompanying UI project too?');
+        },
+        prefix: (state) => {
+          // Show a cyan question mark when not submitted.
+          // Show a green check mark if submitted.
+          // Show a red "x" if ctrl+C is pressed (default is a magenta).
+          if (!state.submitted) return state.symbols.question;
+          return !state.cancelled
+            ? state.symbols.check
+            : red(state.symbols.cross);
+        },
+      });
+    } catch (err) {
+      // If ctrl+c is pressed it will throw.
+      return;
+    }
 
-  // Create a keys dir because we excluded it from Git
-  fs.ensureDirSync(`${name}/keys`);
+    ui = res.ui;
+  }
+
+  sh.mkdir('-p', name); // Create path/to/dir with their desired name
+  sh.cd(name); // Set dir for shell commands. Doesn't change user's dir in their CLI.
 
-  // Set dir for shell commands. Doesn't change user's dir in their CLI.
-  sh.cd(name);
+  // If user wants a UI framework installed alongside their smart contract,
+  // we'll create this dir structure:
+  //   /<name>     (with .git)
+  //     ui/
+  //     contracts/
+  // - We use NPM for the UI projects for consistency with our smart contract
+  //   project, as opposed to Yarn or PNPM.
+  // - spawnSync with stdio:inherit allows the child process to be interactive.
+  if (ui) {
+    switch (ui) {
+      case 'svelte':
+        // `-y` installs the latest version of create-svelte without prompting.
+        spawnSync('npm', ['create', 'svelte@latest', '-y', 'ui'], {
+          stdio: 'inherit',
+          shell: true,
+        });
+        break;
+      case 'next':
+        // https://nextjs.org/docs/api-reference/create-next-app#options
+        spawnSync('npx', ['create-next-app@latest', 'ui', '--use-npm'], {
+          stdio: 'inherit',
+          shell: true,
+        });
+        sh.rm('-rf', path.join('ui', 'git')); // Remove NextJS' .git; we will init .git in our monorepo's root.
+        break;
+      case 'nuxt':
+        console.log("  Choose 'no version control' when prompted.");
+        spawnSync('npx', ['create-nuxt-app', 'ui'], {
+          stdio: 'inherit',
+          shell: true,
+        });
+        break;
+      case 'empty':
+        sh.mkdir('ui');
+        break;
+      case 'none':
+        // `zk project <name>` now shows a dropdown to allow users to select
+        // from available UI project options. Because of this, we also need
+        // `--ui none` in order to allow devs to create a project w/o a UI.
+        ui = false;
+        break;
+    }
+    ora(green(`UI: Set up project`)).succeed();
+
+    if (ui && ui !== 'empty') {
+      // Use `install`, not `ci`, b/c these won't have package-lock.json yet.
+      sh.cd('ui');
+      await step(
+        'UI: NPM install',
+        `npm install --silent > ${isWindows ? 'NUL' : '"/dev/null" 2>&1'}`
+      );
+      sh.cd('..');
+    }
+  }
 
+  // Initialize .git in the root, whether monorepo or not.
   await step('Initialize Git repo', 'git init -q');
 
-  // `/dev/null` on linux or 'NUL' on windows is the only way to silence Husky's install log msg.
+  // Scaffold smart contract project
+  if (ui) {
+    sh.mkdir('contracts');
+    sh.cd('contracts');
+  }
+  if (!(await fetchProjectTemplate())) return;
+
+  // Make Husky work if using a monorepo. It needs some changes to work when
+  // .git lives one dir level above package.json. Note that Husky's pre-commit
+  // checks only apply to the contracts project, not to the UI, unless the dev
+  // set that up themselves. It's more valuable for the smart contract.
+  // Source: https://github.com/typicode/husky/issues/348#issuecomment-899344732
+  if (ui) {
+    // https://github.com/o1-labs/zkapp-cli/blob/main/templates/project-ts/package.json#L20
+    let x = fs.readJSONSync(`package.json`);
+    x.scripts.prepare = `cd .. && husky install ${path.join(
+      'contracts',
+      '.husky'
+    )}`;
+    fs.writeJSONSync(`package.json`, x, { spaces: 2 });
+
+    // https://github.com/o1-labs/zkapp-cli/blob/main/templates/project-ts/.husky/pre-commit#L3
+    let y = fs.readFileSync(`${path.join('.husky', 'pre-commit')}`, 'utf-8');
+    const targetStr = 'husky.sh"\n';
+    y = y.replace(targetStr, targetStr + '\ncd contracts');
+    fs.writeFileSync(`${path.join('.husky', 'pre-commit')}`, y, 'utf-8');
+  }
+
+  // `/dev/null` on Mac or Linux and 'NUL' on Windows is the only way to silence
+  // Husky's install log msg. (Note: The contract project template commits
+  // package-lock.json so we can use `npm ci` for faster installation.)
   await step(
     'NPM install',
     `npm ci --silent > ${isWindows ? 'NUL' : '"/dev/null" 2>&1'}`
   );
 
-  // process.cwd() is full path to user's terminal + path/to/name.
-  await setProjectName(process.cwd());
+  await setProjectName('.', name.split(path.sep).pop());
+
+  if (ui) sh.cd('..'); // back to project root
 
   // `-n` (no verify) skips Husky's pre-commit hooks.
   await step(
@@ -66,18 +179,17 @@ async function project(name) {
     `\n  git remote add origin <your-repo-url>` +
     `\n  git push -u origin main`;
 
-  console.log(_green(str));
+  console.log(green(str));
 }
 
 /**
  * Fetch project template.
- * @param {string} example     Name of the destination dir.
- * @param {string} lang        ts or js
- * @returns {Promise<boolean>} True if successful; false if not.
+ * @returns {Promise<boolean>} - True if successful; false if not.
  */
-async function fetchProjectTemplate(name, lang) {
-  const projectName = lang === 'ts' ? 'project-ts' : 'project';
-  const step = 'Fetch project template';
+async function fetchProjectTemplate() {
+  const projectName = 'project-ts';
+
+  const step = 'Set up project';
   const spin = ora({ text: `${step}...`, discardStdin: true }).start();
 
   try {
@@ -93,14 +205,19 @@ async function fetchProjectTemplate(name, lang) {
       },
     });
 
-    sh.mkdir('-p', name); // create path/to/dir if needed.
+    // Copy files into current working dir
     sh.cp(
       '-r',
       `${path.join(TEMP, 'templates', projectName)}${path.sep}.`,
-      name
+      '.'
     );
     sh.rm('-r', TEMP);
-    spin.succeed(_green(step));
+
+    // Create a keys dir because it's not part of the project scaffolding given
+    // we have `keys` in our .gitignore.
+    sh.mkdir('keys');
+
+    spin.succeed(green(step));
     return true;
   } catch (err) {
     spin.fail(step);
@@ -111,15 +228,15 @@ async function fetchProjectTemplate(name, lang) {
 
 /**
  * Helper for any steps that need to call a shell command.
- * @param {string} step Name of step to show user
- * @param {string} cmd  Shell command to execute.
+ * @param {string} step - Name of step to show user
+ * @param {string} cmd - Shell command to execute.
  * @returns {Promise<void>}
  */
 async function step(step, cmd) {
   const spin = ora({ text: `${step}...`, discardStdin: true }).start();
   try {
     await shExec(cmd);
-    spin.succeed(_green(step));
+    spin.succeed(green(step));
   } catch (err) {
     spin.fail(step);
   }
@@ -128,33 +245,29 @@ async function step(step, cmd) {
 /**
  * Step to replace placeholder names in the project with the properly-formatted
  * version of the user-supplied name as specified via `zk project <name>`
- * @param {string} projDir Full path to terminal dir + path/to/name
+ * @param {string} dir - Path to the dir containing target files to be changed.
+ * @param {string} name - User-provided project name.
  * @returns {Promise<void>}
  */
-async function setProjectName(projDir) {
+async function setProjectName(dir, name) {
   const step = 'Set project name';
   const spin = ora(`${step}...`).start();
 
-  const name = projDir.split(path.sep).pop();
-  replaceInFile(
-    path.join(projDir, 'README.md'),
-    'PROJECT_NAME',
-    titleCase(name)
-  );
+  replaceInFile(path.join(dir, 'README.md'), 'PROJECT_NAME', titleCase(name));
   replaceInFile(
-    path.join(projDir, 'package.json'),
+    path.join(dir, 'package.json'),
     'package-name',
     kebabCase(name)
   );
 
-  spin.succeed(_green(step));
+  spin.succeed(green(step));
 }
 
 /**
  * Helper to replace text in a file.
- * @param {string} file Path to file
- * @param {string} a    Old text.
- * @param {string} b    New text.
+ * @param {string} file - Path to file
+ * @param {string} a - Old text.
+ * @param {string} b - New text.
  */
 function replaceInFile(file, a, b) {
   let content = fs.readFileSync(file, 'utf8');


Test patch:
diff --git a/src/lib/project.test.js b/src/lib/project.test.js
index 9f430cb..84dbf2e 100644
--- a/src/lib/project.test.js
+++ b/src/lib/project.test.js
@@ -1,8 +1,6 @@
 const fs = require('fs');
 const path = require('path');
 let {
-  project,
-  step,
   setProjectName,
   replaceInFile,
   titleCase,
@@ -35,20 +33,24 @@ describe('project.js', () => {
 
     it('should replace text in README.md & package.json', () => {
       const DIR = 'temp-fixture-proj';
+      const NAME = 'my-cool-zkapp';
+
       const README = '# Mina zkApp: PROJECT_NAME\n more stuff\n and more';
       const PKG = `{"name": "package-name","version": "0.1.0"}`;
       fs.mkdirSync('temp-fixture-proj', { recursive: true });
       fs.writeFileSync(DIR + '/README.md', README);
       fs.writeFileSync(DIR + '/package.json', PKG);
-      setProjectName(DIR);
+
+      setProjectName(DIR, NAME);
+
       const readmeAfter = fs.readFileSync(path.join(DIR, 'README.md'), 'utf8');
-      expect(readmeAfter.includes('Temp Fixture Proj')).toBeTruthy();
+      expect(readmeAfter.includes('My Cool Zkapp')).toBeTruthy();
       expect(readmeAfter.includes('PROJECT_NAME')).toBeFalsy();
       const packageAfter = fs.readFileSync(
         path.join(DIR, 'package.json'),
         'utf8'
       );
-      expect(packageAfter.includes('temp-fixture-proj')).toBeTruthy();
+      expect(packageAfter.includes('my-cool-zkapp')).toBeTruthy();
       expect(packageAfter.includes('package-name')).toBeFalsy();
       fs.rmSync(DIR, { recursive: true });
     });