diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 7aecdf2..3969690 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -7,12 +7,12 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - node_version: [10, 12] + node_version: [16, 18] steps: - - uses: actions/checkout@master + - uses: actions/checkout@v3 - name: Use Node.js ${{ matrix.node_version }} - uses: actions/setup-node@v1 + uses: actions/setup-node@v3 with: node-version: ${{ matrix.node_version }} - run: npm install diff --git a/cli.js b/cli.js index 82bfe8c..50be018 100755 --- a/cli.js +++ b/cli.js @@ -1,8 +1,8 @@ #!/usr/bin/env node -'use strict'; -const path = require('path'); -const meow = require('meow'); -const createInkApp = require('.'); +import process from 'node:process'; +import path from 'node:path'; +import meow from 'meow'; +import createInkApp from './index.js'; const cli = meow( ` @@ -17,30 +17,45 @@ const cli = meow( $ create-ink-app . `, { + importMeta: import.meta, flags: { typescript: { - type: 'boolean' - } - } - } + type: 'boolean', + }, + }, + }, ); const projectDirectoryPath = path.resolve(process.cwd(), cli.input[0] || '.'); -createInkApp(projectDirectoryPath) - .catch(error => { - console.error(error.stack); - process.exit(1); - }) - .then(() => { - const pkgName = path.basename(projectDirectoryPath); - const relativePath = path.relative(process.cwd(), projectDirectoryPath); +try { + console.log(); + await createInkApp(projectDirectoryPath, cli.flags); - console.log(); - console.log('Ink app created! Get started with:'); - console.log(); - if (relativePath) { - console.log(` cd ${relativePath}`); - } - console.log(` ${pkgName}`); - }); + const pkgName = path.basename(projectDirectoryPath); + const relativePath = path.relative(process.cwd(), projectDirectoryPath); + + console.log( + [ + '', + `Ink app created in ${relativePath ?? 'the current directory'}:`, + relativePath ? ` $ cd ${relativePath}` : undefined, + relativePath ? '' : undefined, + 'Build:', + ' $ npm run build', + '', + 'Watch and rebuild:', + ' $ npm run dev', + '', + 'Run:', + ` $ ${pkgName}`, + '', + ] + .filter(line => line !== undefined) + .map(line => ` ${line}`) + .join('\n'), + ); +} catch (error) { + console.error(error.stack); + process.exit(1); +} diff --git a/index.js b/index.js index f67a33b..37711eb 100644 --- a/index.js +++ b/index.js @@ -1,13 +1,14 @@ -'use strict'; -const {promisify} = require('util'); -const path = require('path'); -const fs = require('fs'); -const makeDir = require('make-dir'); -const replaceString = require('replace-string'); -const slugify = require('slugify'); -const execa = require('execa'); -const Listr = require('listr'); -const cpy = require('cpy'); +import process from 'node:process'; +import {fileURLToPath} from 'node:url'; +import {promisify} from 'node:util'; +import path from 'node:path'; +import fs from 'node:fs'; +import makeDir from 'make-dir'; +import replaceString from 'replace-string'; +import slugify from 'slugify'; +import {execa} from 'execa'; +import Listr from 'listr'; +import cpy from 'cpy'; const readFile = promisify(fs.readFile); const writeFile = promisify(fs.writeFile); @@ -26,137 +27,141 @@ const copyWithTemplate = async (from, to, variables) => { await writeFile(to, generatedSource); }; -const useTypeScript = process.argv.includes('--typescript'); -let templatePath = 'templates/js'; - -if (useTypeScript) { - templatePath = 'templates/ts'; -} - -const fromPath = file => path.join(__dirname, templatePath, file); -const toPath = (rootPath, file) => path.join(rootPath, file); - -const copyTasks = (projectDirectoryPath, variables) => { - const commonTasks = [ - copyWithTemplate( - fromPath('_package.json'), - toPath(projectDirectoryPath, 'package.json'), - variables - ), - copyWithTemplate( - fromPath('../_common/readme.md'), - toPath(projectDirectoryPath, 'readme.md'), - variables - ), - cpy( - [ - fromPath('../_common/.editorconfig'), - fromPath('../_common/.gitattributes'), - fromPath('../_common/.gitignore') - ], - projectDirectoryPath - ) - ]; - - return useTypeScript - ? [ - ...commonTasks, - cpy(fromPath('source/ui.tsx'), toPath(projectDirectoryPath, 'source')), - copyWithTemplate( - fromPath('source/cli.tsx'), - toPath(projectDirectoryPath, 'source/cli.tsx'), - variables - ), - cpy( - fromPath('source/test.tsx'), - toPath(projectDirectoryPath, 'source') - ), - cpy(fromPath('tsconfig.json'), projectDirectoryPath) - ] - : [ - ...commonTasks, - copyWithTemplate( - fromPath('cli.js'), - toPath(projectDirectoryPath, 'cli.js'), - variables - ), - cpy(fromPath('ui.js'), projectDirectoryPath), - cpy(fromPath('test.js'), projectDirectoryPath) - ]; -}; - -const dependencies = useTypeScript ? [''] : ['import-jsx']; - -const devDependencies = useTypeScript - ? ['@ava/typescript', '@sindresorhus/tsconfig', '@types/react', 'typescript'] - : [ - '@ava/babel', - '@babel/preset-env', - '@babel/preset-react', - '@babel/register' - ]; - -module.exports = (projectDirectoryPath = process.cwd()) => { +const createInkApp = ( + projectDirectoryPath = process.cwd(), + {typescript, silent}, +) => { const pkgName = slugify(path.basename(projectDirectoryPath)); + const execaInDirectory = (file, args, options = {}) => execa(file, args, { ...options, - cwd: projectDirectoryPath + cwd: projectDirectoryPath, }); - const tasks = new Listr([ - { - title: 'Copy files', - task: async () => { - const variables = { - name: pkgName - }; - - return Promise.all(copyTasks(projectDirectoryPath, variables)); - } - }, + const __dirname = path.dirname(fileURLToPath(import.meta.url)); + const templatePath = typescript ? 'templates/ts' : 'templates/js'; + + const fromPath = file => + path.join(path.resolve(__dirname, templatePath), file); + + const toPath = (rootPath, file) => path.join(rootPath, file); + + const tasks = new Listr( + [ + { + title: 'Copy files', + task() { + const variables = { + name: pkgName, + }; + + return new Listr([ + { + title: 'Common files', + async task() { + return Promise.all([ + copyWithTemplate( + fromPath('_package.json'), + toPath(projectDirectoryPath, 'package.json'), + variables, + ), + copyWithTemplate( + fromPath('../_common/readme.md'), + toPath(projectDirectoryPath, 'readme.md'), + variables, + ), + cpy( + [ + fromPath('../_common/.editorconfig'), + fromPath('../_common/.gitattributes'), + fromPath('../_common/.gitignore'), + fromPath('../_common/.prettierignore'), + ], + projectDirectoryPath, + {flat: true}, + ), + ]); + }, + }, + { + title: 'JavaScript files', + enabled: () => !typescript, + async task() { + return Promise.all([ + cpy( + fromPath('source/app.js'), + toPath(projectDirectoryPath, 'source'), + ), + copyWithTemplate( + fromPath('source/cli.js'), + toPath(projectDirectoryPath, 'source/cli.js'), + variables, + ), + cpy(fromPath('test.js'), projectDirectoryPath, {flat: true}), + ]); + }, + }, + { + title: 'TypeScript files', + enabled: () => typescript, + async task() { + return Promise.all([ + cpy( + fromPath('source/app.tsx'), + toPath(projectDirectoryPath, 'source'), + ), + copyWithTemplate( + fromPath('source/cli.tsx'), + toPath(projectDirectoryPath, 'source/cli.tsx'), + variables, + ), + cpy( + [fromPath('test.tsx'), fromPath('tsconfig.json')], + projectDirectoryPath, + {flat: true}, + ), + ]); + }, + }, + ]); + }, + }, + { + title: 'Install dependencies', + async task() { + await execaInDirectory('npm', ['install']); + }, + }, + { + title: 'Format code', + task() { + return execaInDirectory('npx', ['prettier', '--write', '.']); + }, + }, + { + title: 'Build', + task() { + return execaInDirectory('npm', ['run', 'build']); + }, + }, + { + title: 'Link executable', + async task(_, task) { + try { + await execaInDirectory('npm', ['link']); + } catch { + task.skip('`npm link` failed, try running it yourself'); + } + }, + }, + ], { - title: 'Install dependencies', - task: async () => { - await execaInDirectory('npm', [ - 'install', - 'meow@9', - 'ink@3', - 'react', - ...dependencies - ]); - - return execaInDirectory('npm', [ - 'install', - '--save-dev', - 'xo@0.39.1', - 'ava', - 'ink-testing-library', - 'chalk@4', - 'eslint-config-xo-react', - 'eslint-plugin-react', - 'eslint-plugin-react-hooks', - ...devDependencies - ]); - } + renderer: silent ? 'silent' : 'default', }, - { - title: 'Link executable', - task: async (_, task) => { - if (useTypeScript) { - await execaInDirectory('npm', ['run', 'build']); - } + ); - try { - await execaInDirectory('npm', ['link']); - // eslint-disable-next-line unicorn/prefer-optional-catch-binding - } catch (_) { - task.skip('npm link failed, please try running with sudo'); - } - } - } - ]); - - console.log(); return tasks.run(); }; + +export default createInkApp; diff --git a/license b/license index d0aa8c4..613f9c5 100644 --- a/license +++ b/license @@ -1,6 +1,6 @@ MIT License -Copyright (c) Vadim Demedes (vadimdemedes.com) +Copyright (c) Vadym Demedes (vadimdemedes.com) Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: diff --git a/package.json b/package.json index b434c06..f0624f3 100644 --- a/package.json +++ b/package.json @@ -6,15 +6,16 @@ "repository": "vadimdemedes/create-ink-app", "author": { "name": "Vadim Demedes", - "email": "vdemedes@gmail.com", + "email": "vadimdemedes@hey.com", "url": "vadimdemedes.com" }, "bin": "cli.js", + "type": "module", "engines": { - "node": ">=8" + "node": ">=16" }, "scripts": { - "test": "xo" + "test": "xo && ava" }, "files": [ "index.js", @@ -29,18 +30,24 @@ "cli" ], "dependencies": { - "cpy": "^7.2.0", - "execa": "^1.0.0", + "cpy": "^9.0.1", + "execa": "^7.1.1", "listr": "^0.14.3", "make-dir": "^3.1.0", - "meow": "^5.0.0", - "replace-string": "^3.0.0", - "slugify": "^1.3.4" + "meow": "^11.0.0", + "replace-string": "^4.0.0", + "slugify": "^1.6.6" }, "devDependencies": { - "@vdemedes/prettier-config": "^1.0.1", - "prettier": "^2.0.5", - "xo": "^0.21.0" + "@vdemedes/prettier-config": "^2.0.1", + "ava": "^5.2.0", + "prettier": "^2.8.7", + "strip-ansi": "^7.0.1", + "tempy": "^3.0.0", + "xo": "^0.53.1" + }, + "ava": { + "timeout": "5m" }, "xo": { "prettier": true, diff --git a/readme.md b/readme.md index 66127dc..9706976 100644 --- a/readme.md +++ b/readme.md @@ -7,9 +7,12 @@ This helper tool scaffolds out basic project structure for Ink apps and lets you avoid the boilerplate and get to building beautiful CLIs in no time. ```bash -$ npx create-ink-app my-fancy-cli -# Or create with TypeScript React -$ npx create-ink-app --typescript my-fancy-ts-cli +$ npx create-ink-app js-app +$ js-app + +# Or create with TypeScript +$ npx create-ink-app --typescript ts-app +$ ts-app ``` ![](media/demo.gif) diff --git a/templates/_common/.prettierignore b/templates/_common/.prettierignore new file mode 100644 index 0000000..1521c8b --- /dev/null +++ b/templates/_common/.prettierignore @@ -0,0 +1 @@ +dist diff --git a/templates/_common/readme.md b/templates/_common/readme.md index e7a1084..03f95a0 100644 --- a/templates/_common/readme.md +++ b/templates/_common/readme.md @@ -2,14 +2,12 @@ > This readme is automatically generated by [create-ink-app](https://github.com/vadimdemedes/create-ink-app) - ## Install ```bash $ npm install --global %NAME% ``` - ## CLI ``` diff --git a/templates/js/_package.json b/templates/js/_package.json index 56973cf..604fcc6 100644 --- a/templates/js/_package.json +++ b/templates/js/_package.json @@ -2,35 +2,51 @@ "name": "%NAME%", "version": "0.0.0", "license": "MIT", - "bin": "cli.js", + "bin": "dist/cli.js", + "type": "module", "engines": { - "node": ">=10" + "node": ">=16" }, "scripts": { - "test": "xo && ava" + "build": "babel --out-dir=dist source", + "dev": "babel --out-dir=dist --watch source", + "test": "prettier --check . && xo && ava" }, - "files": [ - "cli.js", - "ui.js" - ], - "dependencies": {}, - "devDependencies": {}, - "ava": { - "babel": true, - "require": [ - "@babel/register" - ] + "files": ["dist"], + "dependencies": { + "ink": "^4.1.0", + "meow": "^11.0.0", + "react": "^18.2.0" }, - "babel": { - "presets": [ - "@babel/preset-env", - "@babel/preset-react" - ] + "devDependencies": { + "@babel/cli": "^7.21.0", + "@babel/preset-react": "^7.18.6", + "@vdemedes/prettier-config": "^2.0.1", + "ava": "^5.2.0", + "chalk": "^5.2.0", + "eslint-config-xo-react": "^0.27.0", + "eslint-plugin-react": "^7.32.2", + "eslint-plugin-react-hooks": "^4.6.0", + "import-jsx": "^5.0.0", + "ink-testing-library": "^3.0.0", + "prettier": "^2.8.7", + "xo": "^0.53.1" + }, + "ava": { + "environmentVariables": { + "NODE_NO_WARNINGS": "1" + }, + "nodeArguments": ["--loader=import-jsx"] }, "xo": { "extends": "xo-react", + "prettier": true, "rules": { "react/prop-types": "off" } + }, + "prettier": "@vdemedes/prettier-config", + "babel": { + "presets": ["@babel/preset-react"] } } diff --git a/templates/js/cli.js b/templates/js/cli.js deleted file mode 100644 index 6305075..0000000 --- a/templates/js/cli.js +++ /dev/null @@ -1,22 +0,0 @@ -#!/usr/bin/env node -'use strict'; -const React = require('react'); -const importJsx = require('import-jsx'); -const {render} = require('ink'); -const meow = require('meow'); - -const ui = importJsx('./ui'); - -const cli = meow(` - Usage - $ %NAME% - - Options - --name Your name - - Examples - $ %NAME% --name=Jane - Hello, Jane -`); - -render(React.createElement(ui, cli.flags)); diff --git a/templates/js/source/app.js b/templates/js/source/app.js new file mode 100644 index 0000000..dd98339 --- /dev/null +++ b/templates/js/source/app.js @@ -0,0 +1,10 @@ +import React from 'react'; +import {Text} from 'ink'; + +export default function App({name = 'Stranger'}) { + return ( + + Hello, {name} + + ); +} diff --git a/templates/js/source/cli.js b/templates/js/source/cli.js new file mode 100644 index 0000000..7207bc9 --- /dev/null +++ b/templates/js/source/cli.js @@ -0,0 +1,24 @@ +#!/usr/bin/env node +import React from 'react'; +import {render} from 'ink'; +import meow from 'meow'; +import App from './app.js'; + +const cli = meow( + ` + Usage + $ %NAME% + + Options + --name Your name + + Examples + $ %NAME% --name=Jane + Hello, Jane + `, + { + importMeta: import.meta, + }, +); + +render(); diff --git a/templates/js/test.js b/templates/js/test.js index 9b3070d..43d8743 100644 --- a/templates/js/test.js +++ b/templates/js/test.js @@ -2,16 +2,16 @@ import React from 'react'; import chalk from 'chalk'; import test from 'ava'; import {render} from 'ink-testing-library'; -import App from './ui'; +import App from './source/app.js'; test('greet unknown user', t => { - const {lastFrame} = render(); + const {lastFrame} = render(); - t.is(lastFrame(), chalk`Hello, {green Stranger}`); + t.is(lastFrame(), `Hello, ${chalk.green('Stranger')}`); }); test('greet user with a name', t => { - const {lastFrame} = render(); + const {lastFrame} = render(); - t.is(lastFrame(), chalk`Hello, {green Jane}`); + t.is(lastFrame(), `Hello, ${chalk.green('Jane')}`); }); diff --git a/templates/js/ui.js b/templates/js/ui.js deleted file mode 100644 index 779c5be..0000000 --- a/templates/js/ui.js +++ /dev/null @@ -1,11 +0,0 @@ -'use strict'; -const React = require('react'); -const {Text} = require('ink'); - -const App = ({name = 'Stranger'}) => ( - - Hello, {name} - -); - -module.exports = App; diff --git a/templates/ts/_package.json b/templates/ts/_package.json index 32aa337..89a469b 100644 --- a/templates/ts/_package.json +++ b/templates/ts/_package.json @@ -3,32 +3,48 @@ "version": "0.0.0", "license": "MIT", "bin": "dist/cli.js", + "type": "module", "engines": { - "node": ">=10" + "node": ">=16" }, "scripts": { - "build": "tsc && chmod +x dist/cli.js", - "start": "npm run build && dist/cli.js", - "pretest": "npm run build", - "test": "xo && ava" + "build": "tsc", + "test": "prettier --check . && xo && ava" + }, + "files": ["dist"], + "dependencies": { + "ink": "^4.1.0", + "meow": "^11.0.0", + "react": "^18.2.0" + }, + "devDependencies": { + "@sindresorhus/tsconfig": "^3.0.1", + "@types/react": "^18.0.32", + "@vdemedes/prettier-config": "^2.0.1", + "ava": "^5.2.0", + "chalk": "^5.2.0", + "eslint-config-xo-react": "^0.27.0", + "eslint-plugin-react": "^7.32.2", + "eslint-plugin-react-hooks": "^4.6.0", + "ink-testing-library": "^3.0.0", + "prettier": "^2.8.7", + "ts-node": "^10.9.1", + "typescript": "^5.0.3", + "xo": "^0.53.1" }, - "files": [ - "dist/cli.js" - ], - "dependencies": {}, - "devDependencies": {}, "ava": { - "typescript": { - "extensions": ["tsx"], - "rewritePaths": { - "source/": "dist/" - } - } + "extensions": { + "ts": "module", + "tsx": "module" + }, + "nodeArguments": ["--loader=ts-node/esm"] }, "xo": { "extends": "xo-react", + "prettier": true, "rules": { "react/prop-types": "off" } - } + }, + "prettier": "@vdemedes/prettier-config" } diff --git a/templates/ts/readme.md b/templates/ts/readme.md index e7a1084..03f95a0 100644 --- a/templates/ts/readme.md +++ b/templates/ts/readme.md @@ -2,14 +2,12 @@ > This readme is automatically generated by [create-ink-app](https://github.com/vadimdemedes/create-ink-app) - ## Install ```bash $ npm install --global %NAME% ``` - ## CLI ``` diff --git a/templates/ts/source/app.tsx b/templates/ts/source/app.tsx new file mode 100644 index 0000000..98381a7 --- /dev/null +++ b/templates/ts/source/app.tsx @@ -0,0 +1,14 @@ +import React from 'react'; +import {Text} from 'ink'; + +type Props = { + name: string | undefined; +}; + +export default function App({name = 'Stranger'}: Props) { + return ( + + Hello, {name} + + ); +} diff --git a/templates/ts/source/cli.tsx b/templates/ts/source/cli.tsx index 101f23b..2d5ff1f 100644 --- a/templates/ts/source/cli.tsx +++ b/templates/ts/source/cli.tsx @@ -2,9 +2,10 @@ import React from 'react'; import {render} from 'ink'; import meow from 'meow'; -import App from './ui'; +import App from './app.js'; -const cli = meow(` +const cli = meow( + ` Usage $ %NAME% @@ -14,12 +15,15 @@ const cli = meow(` Examples $ %NAME% --name=Jane Hello, Jane -`, { - flags: { - name: { - type: 'string' - } - } -}); +`, + { + importMeta: import.meta, + flags: { + name: { + type: 'string', + }, + }, + }, +); -render(); +render(); diff --git a/templates/ts/source/test.tsx b/templates/ts/source/test.tsx deleted file mode 100644 index 9b3070d..0000000 --- a/templates/ts/source/test.tsx +++ /dev/null @@ -1,17 +0,0 @@ -import React from 'react'; -import chalk from 'chalk'; -import test from 'ava'; -import {render} from 'ink-testing-library'; -import App from './ui'; - -test('greet unknown user', t => { - const {lastFrame} = render(); - - t.is(lastFrame(), chalk`Hello, {green Stranger}`); -}); - -test('greet user with a name', t => { - const {lastFrame} = render(); - - t.is(lastFrame(), chalk`Hello, {green Jane}`); -}); diff --git a/templates/ts/source/ui.tsx b/templates/ts/source/ui.tsx deleted file mode 100644 index 16f764c..0000000 --- a/templates/ts/source/ui.tsx +++ /dev/null @@ -1,11 +0,0 @@ -import React, {FC} from 'react'; -import {Text} from 'ink'; - -const App: FC<{name?: string}> = ({name = 'Stranger'}) => ( - - Hello, {name} - -); - -module.exports = App; -export default App; diff --git a/templates/ts/test.tsx b/templates/ts/test.tsx new file mode 100644 index 0000000..f7b3f99 --- /dev/null +++ b/templates/ts/test.tsx @@ -0,0 +1,17 @@ +import React from 'react'; +import chalk from 'chalk'; +import test from 'ava'; +import {render} from 'ink-testing-library'; +import App from './source/app.js'; + +test('greet unknown user', t => { + const {lastFrame} = render(); + + t.is(lastFrame(), `Hello, ${chalk.green('Stranger')}`); +}); + +test('greet user with a name', t => { + const {lastFrame} = render(); + + t.is(lastFrame(), `Hello, ${chalk.green('Jane')}`); +}); diff --git a/templates/ts/tsconfig.json b/templates/ts/tsconfig.json index 7e056e1..c6bdb6e 100644 --- a/templates/ts/tsconfig.json +++ b/templates/ts/tsconfig.json @@ -1,9 +1,6 @@ { "extends": "@sindresorhus/tsconfig", "compilerOptions": { - "module": "commonjs", - "jsx": "react", - "esModuleInterop": true, "outDir": "dist" }, "include": ["source"] diff --git a/test.js b/test.js new file mode 100644 index 0000000..e1f91ca --- /dev/null +++ b/test.js @@ -0,0 +1,56 @@ +import path from 'node:path'; +import test from 'ava'; +import {execa} from 'execa'; +import stripAnsi from 'strip-ansi'; +import {temporaryDirectoryTask} from 'tempy'; +import createInkApp from './index.js'; + +const temporaryProjectTask = async (type, callback) => { + await temporaryDirectoryTask(async temporaryDirectory => { + const projectDirectory = path.join(temporaryDirectory, `test-${type}-app`); + + try { + await callback(projectDirectory); + } finally { + await execa('npm', ['unlink', '--global', `test-${type}-app`], { + cwd: projectDirectory, + }); + } + }); +}; + +test.serial('javascript app', async t => { + await temporaryProjectTask('js', async projectDirectory => { + await createInkApp(projectDirectory, { + typescript: false, + silent: true, + }); + + const result = await execa('test-js-app'); + t.is(stripAnsi(result.stdout).trim(), 'Hello, Stranger'); + + await t.notThrowsAsync( + execa('npm', ['test'], { + cwd: projectDirectory, + }), + ); + }); +}); + +test.serial('typescript app', async t => { + await temporaryProjectTask('ts', async projectDirectory => { + await createInkApp(projectDirectory, { + typescript: false, + silent: true, + }); + + const result = await execa('test-ts-app'); + t.is(stripAnsi(result.stdout).trim(), 'Hello, Stranger'); + + await t.notThrowsAsync( + execa('npm', ['test'], { + cwd: projectDirectory, + }), + ); + }); +});