├── bin └── pkfire ├── .npmignore ├── .prettierrc ├── src ├── @types │ └── utility │ │ └── resolvedPromise.d.ts ├── index.ts ├── repositories │ ├── core │ │ └── toolchain.ts │ ├── prettier.ts │ ├── jest.spec.ts │ ├── dependabot.ts │ ├── dependabot.spec.ts │ ├── prettier.spec.ts │ ├── packageInstaller.ts │ ├── gha.ts │ ├── gha.spec.ts │ ├── tsconfig.spec.ts │ ├── tsconfig.ts │ ├── jest.ts │ ├── eslint.spec.ts │ └── eslint.ts ├── helper │ ├── isFileExist.ts │ ├── checkObjectContainTrue.ts │ ├── pkgScripts.spec.ts │ ├── pkgScripts.ts │ └── ghaConfigs.ts ├── commands │ ├── index.ts │ └── general.ts └── questions │ ├── packageManager.ts │ ├── CITools.ts │ ├── toolchains.ts │ ├── detectFrontConfigFile.ts │ └── useTypeScript.ts ├── jest.config.js.template ├── .github ├── issue_template.md ├── pull_request_template.md ├── workflows │ ├── jest.yaml │ ├── typecheck.yaml │ ├── lint.yaml │ └── publish.yaml └── dependabot.yml ├── .editorconfig ├── jest.config.ts ├── tsconfig.json ├── .eslintrc.yaml ├── LICENSE ├── README.md ├── package.json └── .gitignore /bin/pkfire: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | require('../dist/index.js'); 3 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | .github 2 | coverage 3 | .editorconfig 4 | .eslintrc.yaml 5 | .prettierrc 6 | tsconfig.json 7 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "semi": true, 3 | "singleQuote": true, 4 | "tabWidth": 2, 5 | "bracketSpacing": true, 6 | "arrowParens": "always", 7 | "endOfLine": "lf" 8 | } 9 | -------------------------------------------------------------------------------- /src/@types/utility/resolvedPromise.d.ts: -------------------------------------------------------------------------------- 1 | declare module 'pkfire' { 2 | export type ResolvedPromiseType> = 3 | T extends Promise ? P : never; 4 | } 5 | -------------------------------------------------------------------------------- /jest.config.js.template: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | testMatch: ["/**/*.spec.js"], 3 | collectCoverage: true, 4 | coverageDirectory: "coverage", 5 | moduleNameMapper: { 6 | "^@/(.*)$": "/src/$1", 7 | }, 8 | }; 9 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import { cac } from 'cac'; 2 | import { subscribeCommands } from '@/commands'; 3 | 4 | const main = async () => { 5 | const cli = subscribeCommands(cac('pkfire')); 6 | 7 | cli.parse(); 8 | cli.help(); 9 | }; 10 | 11 | main(); 12 | -------------------------------------------------------------------------------- /.github/issue_template.md: -------------------------------------------------------------------------------- 1 | # summary 2 | 3 | 4 | 5 | # 問診票 6 | 7 | 8 | 9 | - どのようなケースで発生しましたか? 10 | - 期待する動作はどのようなものですか? 11 | - 動作させていた環境を教えてください 12 | - node のバージョン: vXXXX 13 | - npm, yarn のバージョン: vXXXX 14 | -------------------------------------------------------------------------------- /.github/pull_request_template.md: -------------------------------------------------------------------------------- 1 | # Referenced issue 2 | 3 | 4 | 5 | - close #XX 6 | 7 | # やったこと 8 | 9 | 10 | 11 | - 12 | 13 | # その他 14 | 15 | 16 | 17 | # チェック項目 18 | 19 | - [ ] ちゃんとテストを書きましたか? 20 | - [ ] linter の警告はありませんか? 21 | - [ ] `yarn lint:fix` でコードフォーマットを修正しましたか? 22 | -------------------------------------------------------------------------------- /src/repositories/core/toolchain.ts: -------------------------------------------------------------------------------- 1 | export type Dependencies = { 2 | [key: 'always' | string]: string | string[]; 3 | }; 4 | 5 | /** 6 | * 依存ツールを管轄するクラスの interface 7 | */ 8 | export interface Toolchain { 9 | // 依存プラグインの定義 10 | // always というキーは最低限必須,あとは子のクラスによる 11 | dependencies: Dependencies; 12 | 13 | // 設定のデータ 14 | config?: unknown; 15 | } 16 | -------------------------------------------------------------------------------- /src/helper/isFileExist.ts: -------------------------------------------------------------------------------- 1 | import fs from 'fs/promises'; 2 | 3 | /** 4 | * ファイルの存在を確認する 5 | * @param filepath 確認対象のファイルパス 実行時の環境の相対パスであることに注意 6 | * @return 存在すれば true それ以外が false 7 | */ 8 | export const isFileExists = async (filepath: string): Promise => { 9 | try { 10 | return Boolean(await fs.lstat(filepath)); 11 | } catch (_) { 12 | return false; 13 | } 14 | }; 15 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # EditorConfig is awesome: https://EditorConfig.org 2 | 3 | # top-most EditorConfig file 4 | root = true 5 | 6 | [*] 7 | indent_style = space 8 | indent_size = 2 9 | end_of_line = lf 10 | charset = utf-8 11 | trim_trailing_whitespace = true 12 | insert_final_newline = true 13 | 14 | [*.md] 15 | trim_trailing_whitespace = false 16 | 17 | [*.{json,yaml}] 18 | trim_trailing_whitespace = false 19 | insert_final_newline = false 20 | -------------------------------------------------------------------------------- /src/commands/index.ts: -------------------------------------------------------------------------------- 1 | import { CAC } from 'cac'; 2 | import { runGeneralCommandJob } from '@/commands/general'; 3 | 4 | /** 5 | * CAC のインスタンスに対してコマンド、及び実行内容を登録する 6 | * @param cli 生の CAC インスタンス 7 | * @return コマンドを登録した CAC インスタンス 8 | */ 9 | export const subscribeCommands = (cli: CAC): CAC => { 10 | cli 11 | .command('', 'generate node toolChain files, install modules') 12 | .action(async () => { 13 | await runGeneralCommandJob(); 14 | }); 15 | return cli; 16 | }; 17 | -------------------------------------------------------------------------------- /jest.config.ts: -------------------------------------------------------------------------------- 1 | import { Config } from '@jest/types'; 2 | 3 | const config: Config.InitialOptions = { 4 | preset: 'ts-jest', 5 | testEnvironment: 'node', 6 | moduleFileExtensions: ['ts', 'js'], 7 | moduleNameMapper: { 8 | '^@/(.*)$': '/src/$1', 9 | }, 10 | transform: { 11 | '^.+//ts$': 'ts-jest', 12 | }, 13 | testMatch: ['/**/*.spec.ts'], 14 | collectCoverage: true, 15 | collectCoverageFrom: ['/**/*.ts'], 16 | }; 17 | 18 | export default config; 19 | -------------------------------------------------------------------------------- /src/helper/checkObjectContainTrue.ts: -------------------------------------------------------------------------------- 1 | import { ResolvedPromiseType } from 'pkfire'; 2 | import { detectFrontConfigFile } from '@/questions/detectFrontConfigFile'; 3 | 4 | /** 5 | * 引数のオブジェクトの値に一つでも true の値が入っているかどうかを返す 6 | * @param param 原則としてはフロントエンド環境のチェックをする関数の返り値を入力として期待 7 | * @returns true が入っていれば true を返す 8 | */ 9 | export const checkObjectContainTrue = ( 10 | param: ResolvedPromiseType> 11 | ): boolean => { 12 | return Object.values(param).some((value) => value === true); 13 | }; 14 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "commonjs", 4 | "target": "ES2018", 5 | "sourceMap": true, 6 | "outDir": "./dist", 7 | "strict": true, 8 | "esModuleInterop": true, 9 | "skipLibCheck": true, 10 | "forceConsistentCasingInFileNames": true, 11 | "rootDir": "./", 12 | "baseUrl": "./src", 13 | "paths": { 14 | "@/*": ["./*"] 15 | }, 16 | "types": ["@types/jest", "@types/node"] 17 | }, 18 | "include": ["src/**/*"], 19 | "exclude": [ 20 | "node_modules" 21 | ] 22 | } -------------------------------------------------------------------------------- /.github/workflows/jest.yaml: -------------------------------------------------------------------------------- 1 | name: test 2 | on: 3 | pull_request: 4 | paths: 5 | - src/**/* 6 | push: 7 | branches: 8 | - master 9 | paths: 10 | - src/**/* 11 | 12 | jobs: 13 | test: 14 | name: test 15 | runs-on: ubuntu-latest 16 | steps: 17 | - uses: actions/checkout@v1 18 | 19 | - name: using node 16.13.0 20 | uses: actions/setup-node@v2 21 | with: 22 | node-version: '16.13.0' 23 | 24 | - name: yarn install 25 | run: yarn --frozen-lockfile 26 | 27 | - name: running test 28 | run: yarn test 29 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # To get started with Dependabot version updates, you'll need to specify which 2 | # package ecosystems to update and where the package manifests are located. 3 | # Please see the documentation for all configuration options: 4 | # https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates 5 | 6 | version: 2 7 | updates: 8 | - package-ecosystem: 'npm' # See documentation for possible values 9 | directory: "/" # Location of package manifests 10 | schedule: 11 | interval: 'daily' 12 | assignees: 13 | - 'huequica' 14 | - 'say-ya-sigma' 15 | -------------------------------------------------------------------------------- /.github/workflows/typecheck.yaml: -------------------------------------------------------------------------------- 1 | name: typeCheck 2 | on: 3 | pull_request: 4 | paths: 5 | - src/**/* 6 | push: 7 | branches: 8 | - master 9 | paths: 10 | - src/**/* 11 | 12 | jobs: 13 | typeCheck: 14 | name: typecheck 15 | runs-on: ubuntu-latest 16 | steps: 17 | - uses: actions/checkout@v1 18 | 19 | - name: using node 16.13.0 20 | uses: actions/setup-node@v2 21 | with: 22 | node-version: '16.13.0' 23 | 24 | - name: yarn install 25 | run: yarn --frozen-lockfile 26 | 27 | - name: type check 28 | run: yarn typeCheck 29 | -------------------------------------------------------------------------------- /.eslintrc.yaml: -------------------------------------------------------------------------------- 1 | root: true 2 | env: 3 | es6: true 4 | node: true 5 | 6 | extends: 7 | - eslint:recommended 8 | - plugin:@typescript-eslint/recommended 9 | - prettier 10 | 11 | parser: '@typescript-eslint/parser' 12 | 13 | plugins: 14 | - '@typescript-eslint' 15 | 16 | rules: 17 | # `var`を使ったら怒るよ 18 | no-var: error 19 | 20 | # `==` とかを使わないでね 21 | eqeqeq: error 22 | 23 | # ブロック演算子はスペース入れてね 24 | block-spacing: error 25 | 26 | # `{}` の中の前後はスペースを入れてね 27 | object-curly-spacing: 28 | - error 29 | - always 30 | 31 | # 演算子の前後のスペースは絶対抜かないで 32 | space-infix-ops: error 33 | 34 | # アロー演算子前後のスペース強制 35 | arrow-spacing: error 36 | 37 | # タブとスペースを混ぜないで 38 | no-mixed-spaces-and-tabs: error 39 | -------------------------------------------------------------------------------- /.github/workflows/lint.yaml: -------------------------------------------------------------------------------- 1 | name: lint 2 | 3 | on: 4 | pull_request: 5 | paths: 6 | - src/**/* 7 | push: 8 | branches: 9 | - master 10 | paths: 11 | - src/**/* 12 | 13 | jobs: 14 | lint: 15 | name: lint 16 | runs-on: ubuntu-latest 17 | steps: 18 | - uses: actions/checkout@v1 19 | 20 | - name: using node 16.13.0 21 | uses: actions/setup-node@v2 22 | with: 23 | node-version: '16.13.0' 24 | 25 | - name: yarn install 26 | run: yarn --frozen-lockfile 27 | 28 | - name: lint check 29 | uses: reviewdog/action-eslint@v1 30 | with: 31 | repoter: github-check 32 | eslint_flags: --ext .js,.ts src 33 | -------------------------------------------------------------------------------- /.github/workflows/publish.yaml: -------------------------------------------------------------------------------- 1 | name: release to npmjs.com 2 | on: 3 | push: 4 | branches: 5 | - master 6 | tags: 7 | - '!*' 8 | 9 | jobs: 10 | release: 11 | name: check version, add tag and release 12 | runs-on: ubuntu-latest 13 | steps: 14 | - name: checkout 15 | uses: actions/checkout@v1 16 | 17 | - name: setup node 18 | uses: actions/setup-node@v3 19 | with: 20 | node-version: '16.x' 21 | registry-url: 'https://registry.npmjs.org' 22 | 23 | - name: install modules 24 | run: yarn --frozen-lockfile 25 | 26 | - name: publish 27 | run: yarn publish 28 | env: 29 | NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} 30 | -------------------------------------------------------------------------------- /src/questions/packageManager.ts: -------------------------------------------------------------------------------- 1 | import inquirer from 'inquirer'; 2 | import { supportPackageManagers } from '@/repositories/packageInstaller'; 3 | 4 | interface PromptAnswer { 5 | packageManager: keyof typeof supportPackageManagers; 6 | } 7 | 8 | /** 9 | * どのパッケージマネージャを使うかを質問する 10 | * @return npm か yarn のどっちか 11 | */ 12 | export const askWhichPackageManager = async (): Promise< 13 | keyof typeof supportPackageManagers 14 | > => { 15 | const { packageManager } = await inquirer.prompt([ 16 | { 17 | type: 'list', 18 | name: 'packageManager', 19 | message: 'Which package manager do you use?', 20 | choices: [ 21 | { 22 | name: 'npm', 23 | }, 24 | { 25 | name: 'yarn', 26 | }, 27 | { 28 | name: 'pnpm', 29 | }, 30 | ], 31 | }, 32 | ]); 33 | 34 | return packageManager; 35 | }; 36 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 huequica 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/repositories/prettier.ts: -------------------------------------------------------------------------------- 1 | import type { Options } from 'prettier'; 2 | import fs from 'fs/promises'; 3 | import { isFileExists } from '@/helper/isFileExist'; 4 | import { Dependencies, Toolchain } from '@/repositories/core/toolchain'; 5 | 6 | /** 7 | * .prettierrc にまつわるものを管理する class 8 | */ 9 | export class PrettierRc implements Toolchain { 10 | dependencies: Readonly = { 11 | always: 'prettier', 12 | }; 13 | 14 | public config: Options = { 15 | semi: true, 16 | singleQuote: true, 17 | tabWidth: 2, 18 | bracketSpacing: true, 19 | arrowParens: 'always', 20 | endOfLine: 'lf', 21 | }; 22 | 23 | /** 24 | * コンフィグ情報を .prettierrc に書き出す 25 | */ 26 | async save() { 27 | // 第3引数が pretty にする設定, 2 を渡すとスペース2つで見やすくなる 28 | const stringifyOptions = JSON.stringify(this.config, null, 2) + '\n'; 29 | const isPrettierRcExist = await isFileExists('.prettierrc'); 30 | 31 | // ファイルが存在しなければ writeFile で生成 32 | if (!isPrettierRcExist) { 33 | await fs.writeFile('.prettierrc', stringifyOptions); 34 | } else { 35 | // TODO: prompt で上書きしていいか確認 36 | // 上書きしてOKなら writeFile で書き込み 37 | throw new Error('.prettierrc file exist!'); 38 | } 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

pkfire

2 | 3 | ![NPM](https://img.shields.io/npm/l/pkfire?style=flat-square) 4 | [![test](https://github.com/node-jeneralize/pkfire/actions/workflows/jest.yaml/badge.svg)](https://github.com/node-jeneralize/pkfire/actions/workflows/jest.yaml) 5 | 6 | > pkfire -> project kicking firely 7 | 8 | The CLI toolchain installer for Node application developers 9 | 10 | ![pkfire](https://user-images.githubusercontent.com/40014236/175309582-d9471b8e-a0c7-4ca0-a424-61449a14318e.gif) 11 | 12 | # ✨ FEATURES 13 | 14 | - 🖨️ Generate configuration files 15 | - 💼 Install required packages automatically 16 | - 👷 Generate toolchain runner of GitHub Actions 17 | - 🛠️ Support Frontend Toolchain Plugins 18 | 19 | # 📦️ SUPPORT PACKAGES 20 | 21 | - ESLint 22 | - @typescript-eslint/eslint-plugin 23 | - @typescript-eslint/parser 24 | - eslint-config-prettier 25 | - Prettier 26 | - TypeScript 27 | - jest 28 | - ts-node 29 | - ts-jest 30 | - @types/jest 31 | 32 | # 🛠️ SUPPORT FRAMEWORKS 33 | - Nuxt.js(with js, with ts) 34 | - Next.js 35 | 36 | # 🧑‍💻 GET STARTED 37 | 38 | ```bash 39 | $ npx pkfire 40 | ``` 41 | 42 | ```bash 43 | # for project maintainers 44 | $ yarn 45 | $ yarn start 46 | ``` 47 | 48 | # 📄 LICENSE 49 | 50 | MIT 51 | -------------------------------------------------------------------------------- /src/repositories/jest.spec.ts: -------------------------------------------------------------------------------- 1 | import fs from 'fs/promises'; 2 | import path from 'path'; 3 | import { Jest } from '@/repositories/jest'; 4 | 5 | describe('🚓 Jest', () => { 6 | describe('🚓 save', () => { 7 | it('👮 TypeScript を使う場合は jest.config.ts をコピーして使わせる', async () => { 8 | const spyOfCopyFile = jest 9 | .spyOn(fs, 'copyFile') 10 | .mockReturnValue(Promise.resolve()); 11 | 12 | const jestConfig = new Jest(); 13 | jestConfig.enableTypeScript(); 14 | const configTemplatePath = path.resolve(__dirname, '../jest.config.ts'); 15 | 16 | await jestConfig.save(); 17 | 18 | expect(spyOfCopyFile).toBeCalledWith( 19 | configTemplatePath, 20 | 'jest.config.ts' 21 | ); 22 | }); 23 | 24 | it('👮 生の JS を使う場合は jest.config.js.template をコピーして使わせる', async () => { 25 | const spyOfCopyFile = jest 26 | .spyOn(fs, 'copyFile') 27 | .mockReturnValue(Promise.resolve()); 28 | 29 | const jestConfig = new Jest(); 30 | const configTemplatePath = path.resolve( 31 | __dirname, 32 | '../jest.config.js.template' 33 | ); 34 | 35 | await jestConfig.save(); 36 | 37 | expect(spyOfCopyFile).toBeCalledWith( 38 | configTemplatePath, 39 | 'jest.config.js' 40 | ); 41 | }); 42 | }); 43 | }); 44 | -------------------------------------------------------------------------------- /src/repositories/dependabot.ts: -------------------------------------------------------------------------------- 1 | import { stringify } from 'yaml'; 2 | import mkdirp from 'mkdirp'; 3 | import fs from 'fs/promises'; 4 | import { isFileExists } from '@/helper/isFileExist'; 5 | 6 | interface Update { 7 | 'package-ecosystem': 'npm'; 8 | directory: string; 9 | schedule: { 10 | interval: 'daily' | 'weekly' | 'monthly'; 11 | }; 12 | } 13 | 14 | interface DependabotConfig { 15 | version: number; 16 | updates: Update[]; 17 | } 18 | 19 | /** 20 | * dependabot.yaml の情報を管轄する 21 | * @see https://docs.github.com/ja/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file 22 | */ 23 | export class Dependabot { 24 | config: DependabotConfig; 25 | 26 | constructor(config: DependabotConfig) { 27 | this.config = config; 28 | } 29 | 30 | /** 31 | * 設定ファイルの保存をする 32 | * @param fileName ファイル名 基本は `dependabot.yml` を入れる 33 | */ 34 | async save(fileName: 'dependabot.yml' | string) { 35 | const stringifyYaml = stringify(this.config, { singleQuote: true }); 36 | 37 | await mkdirp('.github'); 38 | 39 | if (!(await isFileExists(`.github/${fileName}`))) { 40 | await fs.writeFile(`.github/${fileName}`, stringifyYaml); 41 | } else { 42 | // TODO: prompt で上書きしていいか確認 43 | // 上書きしてOKなら writeFile で書き込み 44 | throw new Error(`.github/${fileName} already exists!`); 45 | } 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /src/repositories/dependabot.spec.ts: -------------------------------------------------------------------------------- 1 | import fs from 'fs/promises'; 2 | import { Dependabot } from '@/repositories/dependabot'; 3 | import { stringify } from 'yaml'; 4 | 5 | describe('🚓 Dependabot', () => { 6 | const config: ConstructorParameters[0] = { 7 | version: 3, 8 | updates: [ 9 | { 10 | 'package-ecosystem': 'npm', 11 | directory: '/', 12 | schedule: { 13 | interval: 'weekly', 14 | }, 15 | }, 16 | ], 17 | }; 18 | 19 | describe('🚓 constructor', () => { 20 | it('👮 constructor の引数が config に渡される', () => { 21 | const dependabot = new Dependabot(config); 22 | 23 | expect(dependabot.config).toStrictEqual(config); 24 | }); 25 | }); 26 | 27 | describe('🚓 save', () => { 28 | it('👮 ファイルが存在しないときは yaml にパース, 改行文字を付加して出力', async () => { 29 | jest.spyOn(fs, 'lstat').mockImplementation(() => Promise.reject()); 30 | const spyOnWriteFile = jest 31 | .spyOn(fs, 'writeFile') 32 | .mockImplementation(() => Promise.resolve()); 33 | 34 | const dependabot = new Dependabot(config); 35 | await dependabot.save('dependabot.yml'); 36 | 37 | const expectedYaml = stringify(dependabot.config, { singleQuote: true }); 38 | expect(spyOnWriteFile).toHaveBeenCalledWith( 39 | '.github/dependabot.yml', 40 | expectedYaml 41 | ); 42 | }); 43 | }); 44 | }); 45 | -------------------------------------------------------------------------------- /src/repositories/prettier.spec.ts: -------------------------------------------------------------------------------- 1 | import { PrettierRc } from '@/repositories/prettier'; 2 | import fs from 'fs/promises'; 3 | import { Stats } from 'fs'; 4 | 5 | describe('🚓 PrettierRc', () => { 6 | describe('🚓 save', () => { 7 | it('👮 改行文字を加えてファイルに出力する', async () => { 8 | // lstat がファイルが存在しないと解釈するように reject させる挙動でモック 9 | jest.spyOn(fs, 'lstat').mockImplementation(() => Promise.reject()); 10 | 11 | const spyOfWriteFile = jest 12 | .spyOn(fs, 'writeFile') 13 | .mockImplementation(() => Promise.resolve()); 14 | 15 | const prettierrc = new PrettierRc(); 16 | await prettierrc.save(); 17 | 18 | const expectedJSON = 19 | JSON.stringify( 20 | { 21 | semi: true, 22 | singleQuote: true, 23 | tabWidth: 2, 24 | bracketSpacing: true, 25 | arrowParens: 'always', 26 | endOfLine: 'lf', 27 | }, 28 | null, 29 | 2 30 | ) + '\n'; 31 | 32 | expect(spyOfWriteFile).toHaveBeenCalledWith('.prettierrc', expectedJSON); 33 | }); 34 | 35 | it('👮 ファイルが存在する場合はエラーを返す', async () => { 36 | // lstat がファイルが存在すると解釈するようにモック 37 | jest 38 | .spyOn(fs, 'lstat') 39 | .mockImplementation(() => Promise.resolve({} as Stats)); 40 | 41 | const prettierrc = new PrettierRc(); 42 | 43 | await expect(prettierrc.save()).rejects.toThrowError( 44 | new Error('.prettierrc file exist!') 45 | ); 46 | }); 47 | }); 48 | }); 49 | -------------------------------------------------------------------------------- /src/repositories/packageInstaller.ts: -------------------------------------------------------------------------------- 1 | import { execa } from 'execa'; 2 | 3 | export const supportPackageManagers = { 4 | npm: 'npm', 5 | yarn: 'yarn', 6 | pnpm: 'pnpm', 7 | } as const; 8 | 9 | export class PackageInstaller { 10 | private installPackages: string[] = []; 11 | 12 | /** 13 | * @param userSelectedPackageManager packageManager としてどれを使うのかを指定 14 | */ 15 | constructor( 16 | readonly userSelectedPackageManager: keyof typeof supportPackageManagers 17 | ) {} 18 | 19 | /** 20 | * インストールする必要があるパッケージを追加する 21 | * @param installingPackages 22 | */ 23 | addInstallPackage(installingPackages: string | string[]) { 24 | if (Array.isArray(installingPackages)) { 25 | installingPackages.forEach((installPackage) => 26 | this.installPackages.push(installPackage) 27 | ); 28 | } else { 29 | this.installPackages.push(installingPackages); 30 | } 31 | } 32 | 33 | /** 34 | * パッケージのインストールを実行 35 | */ 36 | async install() { 37 | if (this.installPackages.length === 0) { 38 | return; 39 | } 40 | 41 | const installCommands = { 42 | npm: ['install', '-D', ...this.installPackages], 43 | yarn: ['add', '-D', ...this.installPackages], 44 | pnpm: ['add', '-D', ...this.installPackages], 45 | }; 46 | 47 | return execa( 48 | this.userSelectedPackageManager, 49 | installCommands[this.userSelectedPackageManager], 50 | { 51 | encoding: 'utf8', 52 | stdio: 'inherit', 53 | } 54 | ); 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /src/questions/CITools.ts: -------------------------------------------------------------------------------- 1 | import inquirer, { QuestionCollection } from 'inquirer'; 2 | import { Dependabot } from '@/repositories/dependabot'; 3 | 4 | interface UserResponse { 5 | usingCITools: Array<'GitHubActions' | 'dependabot'>; 6 | } 7 | 8 | interface ReturnObject { 9 | GitHubActions: boolean; 10 | dependabot: Dependabot | undefined; 11 | } 12 | 13 | const dependabotConfig: ConstructorParameters[0] = { 14 | version: 2, 15 | updates: [ 16 | { 17 | 'package-ecosystem': 'npm', 18 | directory: '/', 19 | schedule: { 20 | interval: 'weekly', 21 | }, 22 | }, 23 | ], 24 | }; 25 | 26 | /** 27 | * CITools でどれを使うかを質問する 28 | * @returns GitHubActions は boolean, dependabot はインスタンスを返す 29 | */ 30 | export const askUsingCITools = async (): Promise => { 31 | const question: QuestionCollection = [ 32 | { 33 | type: 'checkbox', 34 | name: 'usingCITools', 35 | message: 'What do you want to use CI tools?', 36 | choices: [ 37 | { 38 | name: 'GitHubActions', 39 | value: 'GitHubActions', 40 | }, 41 | { 42 | name: 'dependabot', 43 | value: 'dependabot', 44 | }, 45 | ], 46 | }, 47 | ]; 48 | 49 | const userResponse = (await inquirer.prompt(question)) 50 | .usingCITools; 51 | 52 | return { 53 | GitHubActions: userResponse.includes('GitHubActions'), 54 | dependabot: userResponse.includes('dependabot') 55 | ? new Dependabot(dependabotConfig) 56 | : undefined, 57 | }; 58 | }; 59 | -------------------------------------------------------------------------------- /src/repositories/gha.ts: -------------------------------------------------------------------------------- 1 | import { stringify } from 'yaml'; 2 | import fs from 'fs/promises'; 3 | import { isFileExists } from '@/helper/isFileExist'; 4 | import mkdirp from 'mkdirp'; 5 | 6 | interface Step { 7 | uses?: string; 8 | name?: string; 9 | run?: string; 10 | with?: { 11 | [key: string]: string; 12 | }; 13 | } 14 | 15 | interface Trigger { 16 | pull_request?: { 17 | paths: string[]; 18 | }; 19 | 20 | push?: { 21 | branches: string[]; 22 | paths: string[]; 23 | }; 24 | } 25 | 26 | interface Jobs { 27 | [key: string]: { 28 | name: string; 29 | 'runs-on': string; 30 | steps: Step[]; 31 | }; 32 | } 33 | 34 | export interface GHAConfigDetails { 35 | name: string; 36 | on: Trigger; 37 | jobs: Jobs; 38 | } 39 | 40 | export class GitHubActionsConfig { 41 | config: GHAConfigDetails = { 42 | name: '', 43 | on: {}, 44 | jobs: {}, 45 | }; 46 | 47 | constructor(config: GHAConfigDetails) { 48 | // save() の中で `this.config` が undefined と言われるので明示拘束 49 | this.save = this.save.bind(this); 50 | this.config = config; 51 | } 52 | 53 | /** 54 | * GitHub Actions の設定を `.github/workflows` の下に保存する 55 | */ 56 | async save(fileName: string) { 57 | const stringifyYaml = stringify(this.config, { singleQuote: true }); 58 | 59 | await mkdirp('.github/workflows'); 60 | 61 | // ファイルがなければ writeFile で生成 62 | if (!(await isFileExists(`.github/workflows/${fileName}`))) { 63 | await fs.writeFile(`.github/workflows/${fileName}`, stringifyYaml); 64 | } else { 65 | // TODO: prompt で上書きしていいか確認 66 | // 上書きしてOKなら writeFile で書き込み 67 | throw new Error(`.github/workflows/${fileName} already exists!`); 68 | } 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /src/repositories/gha.spec.ts: -------------------------------------------------------------------------------- 1 | import fs from 'fs/promises'; 2 | import { stringify } from 'yaml'; 3 | import { GitHubActionsConfig } from '@/repositories/gha'; 4 | import { Stats } from 'fs'; 5 | jest.mock('mkdirp'); 6 | 7 | describe('🚓 GitHubActionsConfig', () => { 8 | const config = { 9 | name: 'hoge', 10 | on: { 11 | pull_request: { 12 | paths: ['src/**/*'], 13 | }, 14 | }, 15 | jobs: { 16 | hogeAction: { 17 | name: 'hoge', 18 | 'runs-on': 'ubuntu-latest', 19 | steps: [ 20 | { 21 | run: 'hogeCommand', 22 | }, 23 | ], 24 | }, 25 | }, 26 | }; 27 | 28 | describe('🚓 constructor', () => { 29 | it('👮 引数に設定した値が config にセットされる', () => { 30 | const gha = new GitHubActionsConfig(config); 31 | expect(gha.config).toStrictEqual(config); 32 | }); 33 | }); 34 | 35 | describe('🚓 save', () => { 36 | it('👮 yaml にして指定のファイルで保存する', async () => { 37 | jest.spyOn(fs, 'lstat').mockReturnValue(Promise.reject()); 38 | const expectYaml = stringify(config, { singleQuote: true }); 39 | 40 | const spyOfWriteFile = jest 41 | .spyOn(fs, 'writeFile') 42 | .mockReturnValue(Promise.resolve()); 43 | 44 | const gha = new GitHubActionsConfig(config); 45 | await gha.save('hoge.yaml'); 46 | 47 | expect(spyOfWriteFile).toBeCalledWith( 48 | '.github/workflows/hoge.yaml', 49 | expectYaml 50 | ); 51 | }); 52 | 53 | it('👮ファイルが存在する場合は Error を吐く', async () => { 54 | jest.spyOn(fs, 'lstat').mockReturnValue(Promise.resolve({} as Stats)); 55 | 56 | const gha = new GitHubActionsConfig(config); 57 | await expect(gha.save('hoge.yaml')).rejects.toThrowError(); 58 | }); 59 | }); 60 | }); 61 | -------------------------------------------------------------------------------- /src/questions/toolchains.ts: -------------------------------------------------------------------------------- 1 | import inquirer, { QuestionCollection, Separator } from 'inquirer'; 2 | import { ESLintRc } from '@/repositories/eslint'; 3 | import { PrettierRc } from '@/repositories/prettier'; 4 | import { Jest } from '@/repositories/jest'; 5 | 6 | interface PromptAnswer { 7 | LinterAndFormatter: Array<'eslint' | 'prettier' | 'jest'>; 8 | } 9 | 10 | interface ReturnObject { 11 | ESLint: ESLintRc | undefined; 12 | Prettier: PrettierRc | undefined; 13 | Jest: Jest | undefined; 14 | } 15 | /** 16 | * Linter や Formatter を使うかどうか質問する 17 | * @return ESLint や Prettier のどれを使うか, 使う場合はインスタンスが内包される 18 | */ 19 | export const askToolchains = async (): Promise => { 20 | const question: QuestionCollection = [ 21 | { 22 | type: 'checkbox', 23 | name: 'LinterAndFormatter', 24 | message: 'What do you want to use?', 25 | choices: [ 26 | new Separator(' == Code Styles, Linting'), 27 | { 28 | name: 'ESLint', 29 | value: 'eslint', 30 | }, 31 | { 32 | name: 'Prettier', 33 | value: 'prettier', 34 | }, 35 | new Separator(' == Unit(E2E) Testing'), 36 | { 37 | name: 'Jest', 38 | value: 'jest', 39 | }, 40 | ], 41 | }, 42 | ]; 43 | 44 | const userResponse = (await inquirer.prompt(question)) 45 | .LinterAndFormatter; 46 | 47 | // ESLint の選択が有効であれば eslintrc の設定クラスを生成 48 | const eslintrc = userResponse.includes('eslint') ? new ESLintRc() : undefined; 49 | 50 | // Prettier の選択が有効であれば prettierrc の設定クラスを生成 51 | const prettierrc = userResponse.includes('prettier') 52 | ? new PrettierRc() 53 | : undefined; 54 | 55 | // Jest の選択肢が有効であれば jest の設定クラスを生成 56 | const jest = userResponse.includes('jest') ? new Jest() : undefined; 57 | 58 | return { 59 | ESLint: eslintrc, 60 | Prettier: prettierrc, 61 | Jest: jest, 62 | }; 63 | }; 64 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "pkfire", 3 | "version": "0.8.0", 4 | "main": "dist/index.js", 5 | "license": "MIT", 6 | "description": "Node application developer toolchains installer CLI", 7 | "homepage": "https://github.com/node-jeneralize/pkfire", 8 | "private": false, 9 | "author": { 10 | "email": "dev@huequica.xyz", 11 | "name": "huequica", 12 | "url": "https://github.com/huequica" 13 | }, 14 | "repository": { 15 | "type": "git", 16 | "url": "https://github.com/node-jeneralize/pkfire" 17 | }, 18 | "bin": { 19 | "pkfire": "./bin/pkfire" 20 | }, 21 | "engines": { 22 | "node": ">=16.0.0" 23 | }, 24 | "scripts": { 25 | "start": "tsx src/index.ts", 26 | "test": "jest", 27 | "typeCheck": "tsc --noEmit", 28 | "lint:js": "eslint --ext .js,.ts src", 29 | "lint:code": "prettier .", 30 | "lint:js:fix": "eslint --fix --ext .js,.ts src", 31 | "lint:code:fix": "prettier --write .", 32 | "lint": "npm run lint:code && npm run lint:js", 33 | "lint:fix": "npm run lint:code:fix && npm run lint:js:fix", 34 | "build": "yarn typeCheck && esbuild --minify --bundle --outdir=dist --platform=node src/index.ts", 35 | "prepare": "yarn build" 36 | }, 37 | "dependencies": { 38 | "cac": "^6.7.12", 39 | "execa": "^6.1.0", 40 | "inquirer": "^8.2.4", 41 | "mkdirp": "^2.1.3", 42 | "yaml": "^2.0.1" 43 | }, 44 | "devDependencies": { 45 | "@types/eslint": "^8.4.2", 46 | "@types/inquirer": "^8.2.1", 47 | "@types/jest": "^28.1.7", 48 | "@types/node": "^18.6.2", 49 | "@typescript-eslint/eslint-plugin": "^5.22.0", 50 | "@typescript-eslint/parser": "^5.22.0", 51 | "esbuild": "^0.18.17", 52 | "eslint": "^8.14.0", 53 | "eslint-config-prettier": "^8.5.0", 54 | "jest": "^28.1.0", 55 | "pkg-types": "^1.0.1", 56 | "prettier": "^2.6.2", 57 | "ts-jest": "^28.0.1", 58 | "ts-node": "^10.7.0", 59 | "tsx": "^3.12.3", 60 | "typescript": "^5.1.6" 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /src/questions/detectFrontConfigFile.ts: -------------------------------------------------------------------------------- 1 | import inquirer from 'inquirer'; 2 | import { isFileExists } from '@/helper/isFileExist'; 3 | 4 | interface DetectedFrontendConfig { 5 | nuxt: boolean; 6 | next: boolean; 7 | } 8 | 9 | type AskUseResponse = Pick< 10 | DetectedFrontendConfig, 11 | K 12 | >; 13 | 14 | /** 15 | * フロントエンドのコンフィグファイルが存在するかどうかを検査、質問する 16 | * @returns それぞれのフレームワークのサポートを入れるかどうかを返す 17 | */ 18 | export const detectFrontConfigFile = 19 | async (): Promise => { 20 | const nuxtConfigFileCheckingResults = await Promise.all([ 21 | isFileExists('nuxt.config.js'), 22 | isFileExists('nuxt.config.ts'), 23 | ]); 24 | 25 | const nextConfigFileCheckingResults = await isFileExists('next.config.js'); 26 | 27 | const isUses = { 28 | nuxt: nuxtConfigFileCheckingResults.includes(true), 29 | next: nextConfigFileCheckingResults, 30 | }; 31 | 32 | if (isUses.nuxt) { 33 | // Nuxt のツールサポートを使うかどうか質問 34 | const { nuxt } = await inquirer.prompt>([ 35 | { 36 | type: 'confirm', 37 | name: 'nuxt', 38 | message: 39 | 'Detected Nuxt.js configuration file. Do you use toolchains support for it?', 40 | }, 41 | ]); 42 | 43 | return { 44 | nuxt, 45 | next: false, 46 | }; 47 | } 48 | 49 | if (isUses.next) { 50 | // Next のツールサポートを使うかどうか質問 51 | const { next } = await inquirer.prompt>([ 52 | { 53 | type: 'confirm', 54 | name: 'next', 55 | message: 56 | 'Detected Next.js configuration file. Do you use toolchains support for it?', 57 | }, 58 | ]); 59 | 60 | return { 61 | nuxt: false, 62 | next, 63 | }; 64 | } 65 | 66 | // コンフィグが存在しなければ全部 false で返す 67 | return { 68 | nuxt: false, 69 | next: false, 70 | }; 71 | }; 72 | -------------------------------------------------------------------------------- /src/repositories/tsconfig.spec.ts: -------------------------------------------------------------------------------- 1 | import fs from 'fs/promises'; 2 | import { Stats } from 'fs'; 3 | import { TSConfigJson } from '@/repositories/tsconfig'; 4 | 5 | describe('🚓 TSConfigRepository', () => { 6 | describe('🚓 save', () => { 7 | it('👮 改行文字を加えてファイルに出力する', async () => { 8 | // lstat がファイルが存在しないと解釈するように reject させる挙動でモック 9 | jest.spyOn(fs, 'lstat').mockImplementation(() => Promise.reject()); 10 | 11 | const spyOfWriteFile = jest 12 | .spyOn(fs, 'writeFile') 13 | .mockImplementation(() => Promise.resolve()); 14 | 15 | const tsconfig = new TSConfigJson(); 16 | await tsconfig.save(); 17 | 18 | const expectedJSON = 19 | JSON.stringify( 20 | { 21 | compilerOptions: { 22 | module: 'commonjs', 23 | target: 'ES2018', 24 | sourceMap: true, 25 | strict: true, 26 | esModuleInterop: true, 27 | forceConsistentCasingInFileNames: true, 28 | rootDir: './', 29 | baseUrl: './src', 30 | paths: { 31 | '@/*': ['./*'], 32 | }, 33 | types: ['@types/jest', '@types/node'], 34 | }, 35 | include: ['src/**/*'], 36 | exclude: ['node_modules'], 37 | }, 38 | null, 39 | 2 40 | ) + '\n'; 41 | 42 | expect(spyOfWriteFile).toHaveBeenCalledWith( 43 | 'tsconfig.json', 44 | expectedJSON, 45 | { 46 | encoding: 'utf8', 47 | } 48 | ); 49 | }); 50 | 51 | it('👮 ファイルが存在する場合はエラーを返す', async () => { 52 | // lstat がファイルが存在すると解釈するようにモック 53 | jest 54 | .spyOn(fs, 'lstat') 55 | .mockImplementation(() => Promise.resolve({} as Stats)); 56 | 57 | const tsconfig = new TSConfigJson(); 58 | 59 | await expect(tsconfig.save()).rejects.toThrowError( 60 | new Error('tsconfig.json file already exist!') 61 | ); 62 | }); 63 | }); 64 | }); 65 | -------------------------------------------------------------------------------- /src/repositories/tsconfig.ts: -------------------------------------------------------------------------------- 1 | import { TSConfig } from 'pkg-types'; 2 | import fs from 'fs/promises'; 3 | import { supportPackageManagers } from '@/repositories/packageInstaller'; 4 | import { isFileExists } from '@/helper/isFileExist'; 5 | import { generateTypeCheckActionsConfig } from '@/helper/ghaConfigs'; 6 | import { GitHubActionsConfig } from '@/repositories/gha'; 7 | import { Dependencies, Toolchain } from '@/repositories/core/toolchain'; 8 | 9 | /** 10 | * tsconfig.json にまつわるものを管轄する class 11 | */ 12 | export class TSConfigJson implements Toolchain { 13 | dependencies: Readonly = { 14 | always: 'typescript', 15 | }; 16 | 17 | config: TSConfig = { 18 | compilerOptions: { 19 | module: 'commonjs', 20 | target: 'ES2018', 21 | sourceMap: true, 22 | strict: true, 23 | esModuleInterop: true, 24 | forceConsistentCasingInFileNames: true, 25 | rootDir: './', 26 | baseUrl: './src', 27 | paths: { 28 | '@/*': ['./*'], 29 | }, 30 | types: ['@types/jest', '@types/node'], 31 | }, 32 | include: ['src/**/*'], 33 | exclude: ['node_modules'], 34 | }; 35 | 36 | /** 37 | * コンフィグ情報を tsconfig.json に書き出す 38 | */ 39 | async save() { 40 | const stringifyConfigJson = JSON.stringify(this.config, null, 2) + '\n'; 41 | const isTSConfigExist = await isFileExists('tsconfig.json'); 42 | 43 | if (!isTSConfigExist) { 44 | await fs.writeFile('tsconfig.json', stringifyConfigJson, { 45 | encoding: 'utf8', 46 | }); 47 | } else { 48 | throw new Error('tsconfig.json file already exist!'); 49 | } 50 | } 51 | 52 | /** 53 | * typeCheck を GitHub Actions で実行するためのコンフィグを出力する 54 | * @param packageManager 使用するパッケージマネージャ 55 | */ 56 | async generateGitHubActionsConfig( 57 | packageManager: keyof typeof supportPackageManagers 58 | ) { 59 | const config = generateTypeCheckActionsConfig(packageManager); 60 | const { save: saveActionsConfig } = new GitHubActionsConfig(config); 61 | await saveActionsConfig('typeCheck.yaml'); 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /src/repositories/jest.ts: -------------------------------------------------------------------------------- 1 | import fs from 'fs/promises'; 2 | import path from 'path'; 3 | import { generateJestActionsConfig } from '@/helper/ghaConfigs'; 4 | import { GitHubActionsConfig } from '@/repositories/gha'; 5 | import { Dependencies, Toolchain } from '@/repositories/core/toolchain'; 6 | import { supportPackageManagers } from '@/repositories/packageInstaller'; 7 | 8 | /** 9 | * Jest の設定や GitHubActions の情報を管轄するクラス 10 | */ 11 | export class Jest implements Toolchain { 12 | dependencies: Readonly = { 13 | always: 'jest', 14 | useWithTypeScript: ['@types/jest', 'ts-node', 'ts-jest'], 15 | }; 16 | 17 | private shouldUseTypeScript = false; 18 | 19 | /** 20 | * jest.config.ts を実行時のカレントディレクトリにコピーする 21 | * @private 22 | */ 23 | private static copyJestConfig(shouldUseTS: boolean): Promise { 24 | if (shouldUseTS) { 25 | // build 後のファイルからの相対パスのため1つ後ろでOK 26 | const jestConfigTsPath = path.resolve(__dirname, '../jest.config.ts'); 27 | return fs.copyFile(jestConfigTsPath, 'jest.config.ts'); 28 | } else { 29 | const jestConfigJsPath = path.resolve( 30 | __dirname, 31 | '../jest.config.js.template' 32 | ); 33 | return fs.copyFile(jestConfigJsPath, 'jest.config.js'); 34 | } 35 | } 36 | 37 | /** 38 | * ts-jest を使う場合はこれを実行することで jest.config.ts を生成させる 39 | */ 40 | enableTypeScript() { 41 | this.shouldUseTypeScript = true; 42 | } 43 | 44 | /** 45 | * 設定ファイルを吐き出す 46 | */ 47 | async save() { 48 | if (this.shouldUseTypeScript) { 49 | await Jest.copyJestConfig(true); 50 | } else { 51 | await Jest.copyJestConfig(false); 52 | } 53 | } 54 | 55 | /** 56 | * GitHubActions の設定を吐き出す 57 | * @param packageManager npm を使うのか yarn を使うのか 58 | */ 59 | async generateGitHubActions( 60 | packageManager: keyof typeof supportPackageManagers 61 | ) { 62 | const config = generateJestActionsConfig(packageManager); 63 | const { save: saveActionsConfig } = new GitHubActionsConfig(config); 64 | await saveActionsConfig('test.yaml'); 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /src/helper/pkgScripts.spec.ts: -------------------------------------------------------------------------------- 1 | import { PkgScriptWriter, pkgIO } from '@/helper/pkgScripts'; 2 | 3 | describe('pkgScripts', () => { 4 | const spyOnIsFileExist = jest.spyOn(pkgIO, 'isFileExists'); 5 | spyOnIsFileExist.mockImplementation(async () => true); 6 | 7 | const spyOnReadPkg = jest.spyOn(pkgIO, 'readPackageJSON'); 8 | spyOnReadPkg.mockImplementation(async () => { 9 | return { name: 'test' }; 10 | }); 11 | 12 | const spyOnWritePkg = jest.spyOn(pkgIO, 'writePackageJSON'); 13 | spyOnWritePkg.mockImplementation(async () => { 14 | return; 15 | }); 16 | 17 | it('add typeCheck script', async () => { 18 | const pkg = new PkgScriptWriter(); 19 | pkg.addScript('typeCheck'); 20 | await pkg.writeScripts(); 21 | expect(spyOnWritePkg).toBeCalledWith('./package.json', { 22 | name: 'test', 23 | scripts: { 24 | typeCheck: 'tsc --noEmit', 25 | }, 26 | }); 27 | }); 28 | 29 | it('add eslint script', async () => { 30 | const pkg = new PkgScriptWriter(); 31 | pkg.addScript('eslint'); 32 | await pkg.writeScripts(); 33 | expect(spyOnWritePkg).toBeCalledWith('./package.json', { 34 | name: 'test', 35 | scripts: { 36 | lint: 'npm run lint:js', 37 | 'lint:fix': 'npm run lint:js:fix', 38 | 'lint:js': 'eslint --ext .js,.ts .', 39 | 'lint:js:fix': 'eslint --fix --ext .js,.ts .', 40 | }, 41 | }); 42 | }); 43 | 44 | it('add prettier script', async () => { 45 | const pkg = new PkgScriptWriter(); 46 | pkg.addScript('prettier'); 47 | await pkg.writeScripts(); 48 | expect(spyOnWritePkg).toBeCalledWith('./package.json', { 49 | name: 'test', 50 | scripts: { 51 | lint: 'npm run lint:code', 52 | 'lint:fix': 'npm run lint:code:fix', 53 | 'lint:code': 'prettier .', 54 | 'lint:code:fix': 'prettier --write .', 55 | }, 56 | }); 57 | }); 58 | 59 | it('add eslint prettier script', async () => { 60 | const pkg = new PkgScriptWriter(); 61 | pkg.addScript('eslint'); 62 | pkg.addScript('prettier'); 63 | await pkg.writeScripts(); 64 | expect(spyOnWritePkg).toBeCalledWith('./package.json', { 65 | name: 'test', 66 | scripts: { 67 | lint: 'npm run lint:js && npm run lint:code', 68 | 'lint:code': 'prettier .', 69 | 'lint:code:fix': 'prettier --write .', 70 | 'lint:fix': 'npm run lint:js:fix && npm run lint:code:fix', 71 | 'lint:js': 'eslint --ext .js,.ts .', 72 | 'lint:js:fix': 'eslint --fix --ext .js,.ts .', 73 | }, 74 | }); 75 | }); 76 | }); 77 | -------------------------------------------------------------------------------- /src/questions/useTypeScript.ts: -------------------------------------------------------------------------------- 1 | import inquirer from 'inquirer'; 2 | 3 | interface PromptAnswers { 4 | shouldUseTypeScriptFeatures: boolean; 5 | shouldWriteTSConfigJson: boolean; 6 | shouldInstallTypeScript: boolean; 7 | } 8 | 9 | // 質問1つ目で得られる Object の型定義 10 | // name に指定したものがそのままキー名に使われる 11 | type ShouldUseTypeScriptFeatures = Pick< 12 | PromptAnswers, 13 | 'shouldUseTypeScriptFeatures' 14 | >; 15 | 16 | type ShouldWriteTSConfigJson = Pick; 17 | 18 | type ShouldInstallPackage = Pick; 19 | 20 | interface PromptOptions { 21 | skipGenerate: boolean; 22 | skipInstall: boolean; 23 | } 24 | 25 | /** 26 | * TypeScript を使用するかどうかを聞く 27 | * @return TS を使用するか, tsconfig.json を生成するか 28 | */ 29 | export const askUseTypeScript = async ( 30 | options?: PromptOptions 31 | ): Promise => { 32 | // TypeScriptの機能自体を使用するか質問 33 | const { shouldUseTypeScriptFeatures } = 34 | await inquirer.prompt([ 35 | { 36 | type: 'confirm', 37 | name: 'shouldUseTypeScriptFeatures', 38 | message: 'Do you use typescript?', 39 | }, 40 | ]); 41 | 42 | // TS を使用しないのであればすべて false で返却 43 | if (!shouldUseTypeScriptFeatures) { 44 | return { 45 | shouldUseTypeScriptFeatures: false, 46 | shouldWriteTSConfigJson: false, 47 | shouldInstallTypeScript: false, 48 | }; 49 | } 50 | 51 | // true だった場合は tsconfig の生成をするか質問 52 | // Next などはフレームワーク側で tsconfig.json を管理しているので N と答えるよう促す 53 | // また、スキップオプションが有効な場合は false を入れる 54 | const { shouldWriteTSConfigJson } = (options ?? { skipGenerate: false }) 55 | .skipGenerate 56 | ? { shouldWriteTSConfigJson: false } 57 | : await inquirer.prompt([ 58 | { 59 | type: 'confirm', 60 | name: 'shouldWriteTSConfigJson', 61 | message: 62 | 'Should generate tsconfig.json? (If you use to Next, Nuxt etc... Answer "N")', 63 | }, 64 | ]); 65 | 66 | // typescript のパッケージをインストールするかどうか質問 67 | // スキップオプションが有効な場合は false を入れる 68 | const { shouldInstallTypeScript } = (options ?? { skipInstall: false }) 69 | .skipInstall 70 | ? { shouldInstallTypeScript: false } 71 | : await inquirer.prompt([ 72 | { 73 | type: 'confirm', 74 | name: 'shouldInstallTypeScript', 75 | message: 'Should install Typescript package?', 76 | }, 77 | ]); 78 | 79 | return { 80 | shouldUseTypeScriptFeatures, 81 | shouldWriteTSConfigJson, 82 | shouldInstallTypeScript, 83 | }; 84 | }; 85 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | 2 | # Created by https://www.toptal.com/developers/gitignore/api/node 3 | # Edit at https://www.toptal.com/developers/gitignore?templates=node 4 | 5 | ### Node ### 6 | # Logs 7 | logs 8 | *.log 9 | npm-debug.log* 10 | yarn-debug.log* 11 | yarn-error.log* 12 | lerna-debug.log* 13 | .pnpm-debug.log* 14 | 15 | # Diagnostic reports (https://nodejs.org/api/report.html) 16 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 17 | 18 | # Runtime data 19 | pids 20 | *.pid 21 | *.seed 22 | *.pid.lock 23 | 24 | # Directory for instrumented libs generated by jscoverage/JSCover 25 | lib-cov 26 | 27 | # Coverage directory used by tools like istanbul 28 | coverage 29 | *.lcov 30 | 31 | # nyc test coverage 32 | .nyc_output 33 | 34 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 35 | .grunt 36 | 37 | # Bower dependency directory (https://bower.io/) 38 | bower_components 39 | 40 | # node-waf configuration 41 | .lock-wscript 42 | 43 | # Compiled binary addons (https://nodejs.org/api/addons.html) 44 | build/Release 45 | 46 | # Dependency directories 47 | node_modules/ 48 | jspm_packages/ 49 | 50 | # Snowpack dependency directory (https://snowpack.dev/) 51 | web_modules/ 52 | 53 | # TypeScript cache 54 | *.tsbuildinfo 55 | 56 | # Optional npm cache directory 57 | .npm 58 | 59 | # Optional eslint cache 60 | .eslintcache 61 | 62 | # Optional stylelint cache 63 | .stylelintcache 64 | 65 | # Microbundle cache 66 | .rpt2_cache/ 67 | .rts2_cache_cjs/ 68 | .rts2_cache_es/ 69 | .rts2_cache_umd/ 70 | 71 | # Optional REPL history 72 | .node_repl_history 73 | 74 | # Output of 'npm pack' 75 | *.tgz 76 | 77 | # Yarn Integrity file 78 | .yarn-integrity 79 | 80 | # dotenv environment variable files 81 | .env 82 | .env.development.local 83 | .env.test.local 84 | .env.production.local 85 | .env.local 86 | 87 | # parcel-bundler cache (https://parceljs.org/) 88 | .cache 89 | .parcel-cache 90 | 91 | # Next.js build output 92 | .next 93 | out 94 | 95 | # Nuxt.js build / generate output 96 | .nuxt 97 | dist 98 | 99 | # Gatsby files 100 | .cache/ 101 | # Comment in the public line in if your project uses Gatsby and not Next.js 102 | # https://nextjs.org/blog/next-9-1#public-directory-support 103 | # public 104 | 105 | # vuepress build output 106 | .vuepress/dist 107 | 108 | # vuepress v2.x temp and cache directory 109 | .temp 110 | 111 | # Docusaurus cache and generated files 112 | .docusaurus 113 | 114 | # Serverless directories 115 | .serverless/ 116 | 117 | # FuseBox cache 118 | .fusebox/ 119 | 120 | # DynamoDB Local files 121 | .dynamodb/ 122 | 123 | # TernJS port file 124 | .tern-port 125 | 126 | # Stores VSCode versions used for testing VSCode extensions 127 | .vscode-test 128 | 129 | # yarn v2 130 | .yarn/cache 131 | .yarn/unplugged 132 | .yarn/build-state.yml 133 | .yarn/install-state.gz 134 | .pnp.* 135 | 136 | ### Node Patch ### 137 | # Serverless Webpack directories 138 | .webpack/ 139 | 140 | # Optional stylelint cache 141 | 142 | # SvelteKit build / generate output 143 | .svelte-kit 144 | 145 | # End of https://www.toptal.com/developers/gitignore/api/node 146 | -------------------------------------------------------------------------------- /src/helper/pkgScripts.ts: -------------------------------------------------------------------------------- 1 | import { PackageJson } from 'pkg-types'; 2 | import { isFileExists } from '@/helper/isFileExist'; 3 | import { promises } from 'fs'; 4 | 5 | interface PackageJsonModified extends PackageJson { 6 | scripts?: Record; 7 | license?: string; 8 | } 9 | 10 | async function readPackageJSON(path: string) { 11 | const blob = await promises.readFile(path, 'utf-8'); 12 | return JSON.parse(blob); 13 | } 14 | 15 | async function writePackageJSON(path: string, pkg: PackageJson) { 16 | await promises.writeFile(path, JSON.stringify(pkg, null, 2) + '\n'); 17 | } 18 | 19 | export const pkgIO = { 20 | isFileExists, 21 | readPackageJSON, 22 | writePackageJSON, 23 | }; 24 | 25 | export type PkgScriptKind = 'typeCheck' | 'eslint' | 'prettier' | 'test'; 26 | 27 | export class PkgScriptWriter { 28 | scripts: PkgScriptKind[] = []; 29 | 30 | addScript(kind: PkgScriptKind) { 31 | this.scripts.push(kind); 32 | } 33 | 34 | async writeScripts() { 35 | let pkg: PackageJsonModified; 36 | if (await pkgIO.isFileExists('./package.json')) { 37 | pkg = await pkgIO.readPackageJSON('./package.json'); 38 | } else { 39 | pkg = { 40 | name: '', 41 | version: '0.0.0', 42 | description: '', 43 | main: 'index.js', 44 | author: '', 45 | license: 'UNLICENSED', 46 | private: true, 47 | }; 48 | } 49 | if (this.scripts.includes('typeCheck')) { 50 | addTypeCheckScript(pkg); 51 | } 52 | if (this.scripts.includes('eslint')) { 53 | addEslintScript(pkg); 54 | } 55 | if (this.scripts.includes('prettier')) { 56 | addPrettierScript(pkg); 57 | } 58 | if (this.scripts.includes('test')) { 59 | addJestScript(pkg); 60 | } 61 | 62 | addLintScript(pkg); 63 | addLintFixScript(pkg); 64 | await pkgIO.writePackageJSON('./package.json', pkg); 65 | } 66 | } 67 | 68 | export const addTypeCheckScript = (pkg: PackageJsonModified) => { 69 | if (!pkg.scripts) { 70 | pkg.scripts = {}; 71 | } 72 | pkg.scripts.typeCheck = 'tsc --noEmit'; 73 | }; 74 | 75 | export const addJestScript = (pkg: PackageJsonModified) => { 76 | if (!pkg.scripts) { 77 | pkg.scripts = {}; 78 | } 79 | pkg.scripts.test = 'jest'; 80 | }; 81 | 82 | export const addEslintScript = (pkg: PackageJsonModified) => { 83 | if (!pkg.scripts) { 84 | pkg.scripts = {}; 85 | } 86 | pkg.scripts['lint:js'] = 'eslint --ext .js,.ts .'; 87 | pkg.scripts['lint:js:fix'] = 'eslint --fix --ext .js,.ts .'; 88 | }; 89 | 90 | export const addPrettierScript = (pkg: PackageJsonModified) => { 91 | if (!pkg.scripts) { 92 | pkg.scripts = {}; 93 | } 94 | pkg.scripts['lint:code'] = 'prettier .'; 95 | pkg.scripts['lint:code:fix'] = 'prettier --write .'; 96 | }; 97 | 98 | export const addLintScript = (pkg: PackageJsonModified) => { 99 | if (!pkg.scripts) { 100 | return; 101 | } 102 | const keys = Object.keys(pkg.scripts); 103 | if (keys.includes('lint:js') && keys.includes('lint:code')) { 104 | pkg.scripts.lint = 'npm run lint:js && npm run lint:code'; 105 | } else if (keys.includes('lint:js')) { 106 | pkg.scripts.lint = 'npm run lint:js'; 107 | } else if (keys.includes('lint:code')) { 108 | pkg.scripts.lint = 'npm run lint:code'; 109 | } 110 | }; 111 | 112 | export const addLintFixScript = (pkg: PackageJsonModified) => { 113 | if (!pkg.scripts) { 114 | return; 115 | } 116 | const keys = Object.keys(pkg.scripts); 117 | if (keys.includes('lint:js:fix') && keys.includes('lint:code:fix')) { 118 | pkg.scripts['lint:fix'] = 'npm run lint:js:fix && npm run lint:code:fix'; 119 | } else if (keys.includes('lint:js:fix')) { 120 | pkg.scripts['lint:fix'] = 'npm run lint:js:fix'; 121 | } else if (keys.includes('lint:code:fix')) { 122 | pkg.scripts['lint:fix'] = 'npm run lint:code:fix'; 123 | } 124 | }; 125 | -------------------------------------------------------------------------------- /src/helper/ghaConfigs.ts: -------------------------------------------------------------------------------- 1 | import { GHAConfigDetails } from '@/repositories/gha'; 2 | import { supportPackageManagers } from '@/repositories/packageInstaller'; 3 | 4 | type PackageManager = keyof typeof supportPackageManagers; 5 | 6 | /** 7 | * モジュールのインストールコマンドを出力する 8 | * @param usePackageManager 使うパッケージマネージャ 9 | */ 10 | const generateModuleInstallCommand = ( 11 | usePackageManager: PackageManager 12 | ): string => { 13 | const staticInstallCommands: Record = { 14 | npm: 'npm ci', 15 | yarn: 'yarn --frozen-lockfile', 16 | pnpm: 'pnpm i --frozen-lockfile', 17 | }; 18 | 19 | return staticInstallCommands[usePackageManager]; 20 | }; 21 | 22 | /** 23 | * scripts の実行コマンドを出力する 24 | * @param usePackageManager 使うパッケージマネージャ 25 | * @param target 動かすスクリプト 26 | */ 27 | const generateScriptRunnerCommand = ( 28 | usePackageManager: PackageManager, 29 | target: string 30 | ): string => { 31 | const runningCommands: Record = { 32 | npm: `npm run ${target}`, 33 | yarn: `yarn ${target}`, 34 | pnpm: `pnpm run ${target}`, 35 | }; 36 | 37 | return runningCommands[usePackageManager]; 38 | }; 39 | 40 | /** 41 | * ESLint を GitHub Actions で動かす用途のコンフィグ情報を吐き出す 42 | * @param usePackageManager 使うパッケージマネージャ 43 | */ 44 | export const generateESLintActionsConfig = ( 45 | usePackageManager: PackageManager 46 | ): GHAConfigDetails => ({ 47 | name: 'lint', 48 | on: { 49 | pull_request: { 50 | paths: ['src/**/*'], 51 | }, 52 | }, 53 | jobs: { 54 | lint: { 55 | name: 'lint', 56 | 'runs-on': 'ubuntu-latest', 57 | steps: [ 58 | { 59 | uses: 'actions/checkout@v1', 60 | }, 61 | { 62 | name: 'using node 16.13.0', 63 | uses: 'actions/setup-node@v2', 64 | with: { 65 | 'node-version': process.versions.node, 66 | }, 67 | }, 68 | { 69 | name: 'module install', 70 | run: generateModuleInstallCommand(usePackageManager), 71 | }, 72 | { 73 | name: 'lint check', 74 | uses: 'reviewdog/action-eslint@v1', 75 | with: { 76 | repoter: 'github-pr-review', 77 | 'eslint-flags': '--ext .js,.ts src', 78 | }, 79 | }, 80 | ], 81 | }, 82 | }, 83 | }); 84 | 85 | /** 86 | * Jest を GitHub Actions で動かす用途のコンフィグ情報を吐き出す 87 | * @param usePackageManager 使うパッケージマネージャ 88 | */ 89 | export const generateJestActionsConfig = ( 90 | usePackageManager: PackageManager 91 | ): GHAConfigDetails => ({ 92 | name: 'test', 93 | on: { 94 | pull_request: { 95 | paths: ['src/**/*'], 96 | }, 97 | }, 98 | jobs: { 99 | test: { 100 | name: 'test', 101 | 'runs-on': 'ubuntu-latest', 102 | steps: [ 103 | { 104 | uses: 'actions/checkout@v1', 105 | }, 106 | { 107 | name: 'using node', 108 | uses: 'actions/setup-node@v2', 109 | with: { 110 | 'node-version': process.versions.node, 111 | }, 112 | }, 113 | { 114 | name: 'module install', 115 | run: generateModuleInstallCommand(usePackageManager), 116 | }, 117 | { 118 | name: 'running test', 119 | run: usePackageManager === 'npm' ? 'npm run test' : 'yarn test', 120 | }, 121 | ], 122 | }, 123 | }, 124 | }); 125 | 126 | /** 127 | * typeCheck を GitHub Actions で動かす用途のコンフィグ情報を吐き出す 128 | * @param usePackageManager 使うパッケージマネージャ 129 | */ 130 | export const generateTypeCheckActionsConfig = ( 131 | usePackageManager: PackageManager 132 | ): GHAConfigDetails => ({ 133 | name: 'typeCheck', 134 | on: { 135 | pull_request: { 136 | paths: ['src/**/*'], 137 | }, 138 | }, 139 | jobs: { 140 | typeCheck: { 141 | name: 'typeCheck', 142 | 'runs-on': 'ubuntu-latest', 143 | steps: [ 144 | { 145 | uses: 'actions/checkout@v1', 146 | }, 147 | { 148 | name: 'using node', 149 | uses: 'actions/setup-node@v2', 150 | with: { 151 | 'node-version': process.versions.node, 152 | }, 153 | }, 154 | { 155 | name: 'module install', 156 | run: generateModuleInstallCommand(usePackageManager), 157 | }, 158 | { 159 | name: 'run typeChecking', 160 | run: generateScriptRunnerCommand(usePackageManager, 'typeCheck'), 161 | }, 162 | ], 163 | }, 164 | }, 165 | }); 166 | -------------------------------------------------------------------------------- /src/repositories/eslint.spec.ts: -------------------------------------------------------------------------------- 1 | import fs from 'fs/promises'; 2 | import yaml from 'yaml'; 3 | import { ESLintRc } from '@/repositories/eslint'; 4 | import { Stats } from 'fs'; 5 | 6 | describe('🚓 ESLintRc', () => { 7 | describe('🚓 enableTypeScriptFeatures', () => { 8 | it('👮 実行したら extends と plugins, parser が設定される', () => { 9 | const eslintrc = new ESLintRc(); 10 | eslintrc.enableTypeScriptFeatures(); 11 | 12 | const expectResults = { 13 | extends: [ 14 | 'eslint:recommended', 15 | 'plugin:@typescript-eslint/recommended', 16 | ], 17 | plugins: ['@typescript-eslint'], 18 | parser: '@typescript-eslint/parser', 19 | }; 20 | 21 | expect(eslintrc.config.extends).toStrictEqual(expectResults.extends); 22 | expect(eslintrc.config.plugins).toStrictEqual(expectResults.plugins); 23 | expect(eslintrc.config.parser).toBe(expectResults.parser); 24 | }); 25 | }); 26 | 27 | describe('🚓 enableNuxtFeatures', () => { 28 | it('👮‍ 実行すると env.browser と extends が設定される', () => { 29 | const eslintrc = new ESLintRc(); 30 | eslintrc.enableNuxtFeatures(); 31 | 32 | const expectResults = { 33 | env: { 34 | es6: true, 35 | node: true, 36 | browser: true, 37 | }, 38 | extends: ['eslint:recommended', 'plugin:nuxt/recommended'], 39 | }; 40 | 41 | expect(eslintrc.config.env).toStrictEqual(expectResults.env); 42 | expect(eslintrc.config.extends).toStrictEqual(expectResults.extends); 43 | }); 44 | }); 45 | 46 | describe('🚓 enableNuxtAndTypeScriptFeatures', () => { 47 | it('👮‍ 実行すると extends が設定される. parser は 空になる', () => { 48 | const eslintrc = new ESLintRc(); 49 | eslintrc.enableNuxtAndTypeScriptFeatures(); 50 | 51 | const expectResults = { 52 | extends: ['eslint:recommended', '@nuxtjs/eslint-config-typescript'], 53 | }; 54 | 55 | expect(eslintrc.config.extends).toStrictEqual(expectResults.extends); 56 | expect(eslintrc.config.parser).toBe(undefined); 57 | }); 58 | }); 59 | 60 | describe('🚓 enablePrettierFeature', () => { 61 | it('👮 有効にして save() を実行すると extends の末尾に prettier が存在する', async () => { 62 | jest.spyOn(fs, 'lstat').mockImplementation(() => Promise.reject()); 63 | 64 | const spyOfWriteFile = jest 65 | .spyOn(fs, 'writeFile') 66 | .mockImplementation(() => Promise.resolve()); 67 | 68 | const eslintrc = new ESLintRc(); 69 | eslintrc.enablePrettierFeature(); 70 | await eslintrc.save(); 71 | 72 | const expectedYaml = yaml.stringify({ 73 | ...eslintrc.config, 74 | extends: ['eslint:recommended', 'prettier'], // extends 設定だけここで上書きして yaml を吐き出させる 75 | }); 76 | 77 | expect(spyOfWriteFile).toHaveBeenCalledWith( 78 | '.eslintrc.yaml', 79 | expectedYaml, 80 | { encoding: 'utf8' } 81 | ); 82 | }); 83 | }); 84 | 85 | describe('🚓 addRules', () => { 86 | it('👮 単体追加', () => { 87 | const eslintrc = new ESLintRc(); 88 | eslintrc.addRules({ 'no-var': 'error' }); 89 | 90 | expect(eslintrc.config.rules).toStrictEqual({ 'no-var': 'error' }); 91 | }); 92 | 93 | it('👮 複数追加', () => { 94 | const eslintrc = new ESLintRc(); 95 | eslintrc.addRules([{ 'no-var': 'error' }, { eqeqeq: 'error' }]); 96 | 97 | expect(eslintrc.config.rules).toStrictEqual({ 98 | 'no-var': 'error', 99 | eqeqeq: 'error', 100 | }); 101 | }); 102 | }); 103 | 104 | describe('🚓 save', () => { 105 | it('👮 ファイルが存在しない場合は yaml にパース, 改行文字を付加して出力', async () => { 106 | jest.spyOn(fs, 'lstat').mockImplementation(() => Promise.reject()); 107 | 108 | const spyOfWriteFile = jest 109 | .spyOn(fs, 'writeFile') 110 | .mockImplementation(() => Promise.resolve()); 111 | 112 | const eslintrc = new ESLintRc(); 113 | await eslintrc.save(); 114 | 115 | const expectedYaml = yaml.stringify(eslintrc.config); 116 | expect(spyOfWriteFile).toHaveBeenCalledWith( 117 | '.eslintrc.yaml', 118 | expectedYaml, 119 | { encoding: 'utf8' } 120 | ); 121 | }); 122 | 123 | it('👮 ファイルが存在する場合はエラーを返す', async () => { 124 | jest 125 | .spyOn(fs, 'lstat') 126 | .mockImplementation(() => Promise.resolve({} as Stats)); 127 | 128 | const eslintrc = new ESLintRc(); 129 | 130 | await expect(eslintrc.save()).rejects.toThrowError( 131 | new Error('.eslintrc file already exist!') 132 | ); 133 | }); 134 | }); 135 | }); 136 | -------------------------------------------------------------------------------- /src/repositories/eslint.ts: -------------------------------------------------------------------------------- 1 | import { Linter } from 'eslint'; 2 | import { stringify } from 'yaml'; 3 | import fs from 'fs/promises'; 4 | import { Dependencies, Toolchain } from '@/repositories/core/toolchain'; 5 | import { isFileExists } from '@/helper/isFileExist'; 6 | import { generateESLintActionsConfig } from '@/helper/ghaConfigs'; 7 | import { GitHubActionsConfig } from '@/repositories/gha'; 8 | import { supportPackageManagers } from '@/repositories/packageInstaller'; 9 | 10 | type RulesRecord = Linter.RulesRecord; 11 | type BaseConfig = Linter.Config; 12 | 13 | export class ESLintRc implements Toolchain { 14 | dependencies: Readonly = { 15 | always: 'eslint', 16 | useWithPrettier: 'eslint-config-prettier', 17 | useWithTypeScript: [ 18 | '@typescript-eslint/eslint-plugin', 19 | '@typescript-eslint/parser', 20 | ], 21 | useWithNuxtJs: [ 22 | '@nuxtjs/eslint-module', 23 | 'eslint-plugin-nuxt', 24 | 'eslint-plugin-vue', 25 | '@babel/eslint-parser', 26 | ], 27 | useWithNuxtAndTS: '@nuxtjs/eslint-config-typescript', 28 | useWithNextJs: 'eslint-config-next', 29 | }; 30 | 31 | config: BaseConfig = { 32 | root: true, 33 | env: { 34 | es6: true, 35 | node: true, 36 | }, 37 | 38 | extends: ['eslint:recommended'], 39 | }; 40 | 41 | // Prettier の設定をファイル出力する手前で後付けするために 42 | // bool の値を内部で持たせて情報を持っておく 43 | private isEnablePrettierFeature = false; 44 | 45 | /** 46 | * `prettier` を extends の末尾に入れる 47 | * @private 最後に書き出すための省略用メソッド 48 | */ 49 | private addPrettierToExtends() { 50 | if (Array.isArray(this.config.extends)) { 51 | this.config.extends.push('prettier'); 52 | } 53 | } 54 | 55 | /** 56 | * TypeScript まわりの設定を追加する 57 | */ 58 | enableTypeScriptFeatures() { 59 | // extends が string だったり undefined だったりするケースも定義上あるので 60 | // Array であることを確定させている 61 | if (Array.isArray(this.config.extends)) { 62 | this.config.extends.push('plugin:@typescript-eslint/recommended'); 63 | } 64 | 65 | // すでに どこかしらのメソッド定義で plugins が Array になってる場合はただ追加するだけ 66 | if (Array.isArray(this.config.plugins)) { 67 | this.config.plugins.push('@typescript-eslint'); 68 | } else { 69 | // まだArrayとして定義されていない場合は Array をそのまま代入してやる 70 | this.config.plugins = ['@typescript-eslint']; 71 | } 72 | 73 | this.config.parser = '@typescript-eslint/parser'; 74 | } 75 | 76 | /** 77 | * Prettier の設定を有効化する 78 | */ 79 | enablePrettierFeature() { 80 | this.isEnablePrettierFeature = true; 81 | } 82 | 83 | /** 84 | * Nuxt 向けの設定を有効する 85 | */ 86 | enableNuxtFeatures() { 87 | if (Array.isArray(this.config.extends)) { 88 | this.config.extends.push('plugin:nuxt/recommended'); 89 | } 90 | if (this.config.env) { 91 | this.config.env.browser = true; 92 | } 93 | } 94 | 95 | /** 96 | * Nuxt と TypeScript を併用するときの設定を追加する 97 | */ 98 | enableNuxtAndTypeScriptFeatures() { 99 | if (Array.isArray(this.config.extends)) { 100 | this.config.extends.push('@nuxtjs/eslint-config-typescript'); 101 | } 102 | 103 | // parser のオプションがあると vue ファイルの検査でエラーになるので排除 104 | this.config = Object.fromEntries( 105 | Object.entries(this.config).filter(([key]) => { 106 | return key !== 'parser'; 107 | }) 108 | ); 109 | } 110 | 111 | /** 112 | * Next と共用するときの設定を追加する 113 | */ 114 | enableNextFeatures() { 115 | if (Array.isArray(this.config.extends)) { 116 | this.config.extends.push('next/core-web-vitals'); 117 | } 118 | 119 | if (this.config.env) { 120 | this.config.env.browser = true; 121 | } 122 | } 123 | 124 | /** 125 | * rules に渡したルールをコンフィグに追加する 126 | * @param rules ルールのオブジェクト単体, もしくは複数追加の場合 Array 127 | */ 128 | addRules(rules: RulesRecord | RulesRecord[]) { 129 | if (!Array.isArray(rules)) { 130 | this.config.rules = { 131 | ...this.config.rules, 132 | ...rules, 133 | }; 134 | } else { 135 | // Array で受けるパターン 136 | rules.forEach((rule) => { 137 | this.config.rules = { 138 | ...this.config.rules, 139 | ...rule, 140 | }; 141 | }); 142 | } 143 | } 144 | 145 | /** 146 | * コンフィグ情報を .eslintrc.yaml に書き出す 147 | */ 148 | async save() { 149 | // Prettier の設定が最後に設定に適用される 150 | if (this.isEnablePrettierFeature) { 151 | this.addPrettierToExtends(); 152 | } 153 | 154 | const stringifyYaml = stringify(this.config); 155 | // 設定ファイルの存在を確認 156 | const isESLintRcExistChecks = await Promise.all([ 157 | isFileExists('.eslintrc'), 158 | isFileExists('.eslintrc.json'), 159 | isFileExists('.eslintrc.js'), 160 | isFileExists('.eslintrc.yaml'), 161 | isFileExists('.eslintrc.yml'), 162 | ]); 163 | 164 | if (!isESLintRcExistChecks.includes(true)) { 165 | await fs.writeFile('.eslintrc.yaml', stringifyYaml, { encoding: 'utf8' }); 166 | } else { 167 | // TODO: prompt で上書きしていいか確認 168 | // 上書きしてOKなら writeFile で書き込み 169 | throw new Error('.eslintrc file already exist!'); 170 | } 171 | } 172 | 173 | /** 174 | * ESLint の GitHub Actions の設定を生成する 175 | * @param packageManager 使用するパッケージマネージャ 176 | */ 177 | async generateGitHubActions( 178 | packageManager: keyof typeof supportPackageManagers 179 | ) { 180 | const config = generateESLintActionsConfig(packageManager); 181 | const { save: saveActionsConfig } = new GitHubActionsConfig(config); 182 | await saveActionsConfig('lint.yaml'); 183 | } 184 | } 185 | -------------------------------------------------------------------------------- /src/commands/general.ts: -------------------------------------------------------------------------------- 1 | import { askUseTypeScript } from '@/questions/useTypeScript'; 2 | import { askToolchains } from '@/questions/toolchains'; 3 | import { askWhichPackageManager } from '@/questions/packageManager'; 4 | import { PackageInstaller } from '@/repositories/packageInstaller'; 5 | import { TSConfigJson } from '@/repositories/tsconfig'; 6 | import { PkgScriptWriter } from '@/helper/pkgScripts'; 7 | import { checkObjectContainTrue } from '@/helper/checkObjectContainTrue'; 8 | import { detectFrontConfigFile } from '@/questions/detectFrontConfigFile'; 9 | import { askUsingCITools } from '@/questions/CITools'; 10 | 11 | /** 12 | * パラメータなどの引数なしで実行したときの挙動を実行する 13 | */ 14 | export const runGeneralCommandJob = async () => { 15 | const packageManager = await askWhichPackageManager(); 16 | const frontend = await detectFrontConfigFile(); 17 | 18 | // フロントエンドの設定ファイルが1つでもあればいくつかの質問をスキップする 19 | const tsPromptOption: Parameters[0] | undefined = 20 | checkObjectContainTrue(frontend) 21 | ? { skipGenerate: true, skipInstall: true } 22 | : undefined; 23 | const environment = await askUseTypeScript(tsPromptOption); 24 | 25 | const toolchains = await askToolchains(); 26 | const usingCITools = await askUsingCITools(); 27 | 28 | //------------------------------------------------------------------------------ 29 | // Package Installation 30 | //------------------------------------------------------------------------------ 31 | const packageInstaller = new PackageInstaller(packageManager); 32 | // package.json scripts のライター 33 | const pkg = new PkgScriptWriter(); 34 | 35 | if (toolchains.ESLint) { 36 | // 必須パッケージを追加 37 | packageInstaller.addInstallPackage(toolchains.ESLint.dependencies.always); 38 | // scripts 追加 39 | pkg.addScript('eslint'); 40 | 41 | // Prettier もいっしょに使う場合はルール競合回避のパッケージを追加, ESLint のコンフィグに追記 42 | if (toolchains.Prettier) { 43 | packageInstaller.addInstallPackage( 44 | toolchains.ESLint.dependencies.useWithPrettier 45 | ); 46 | toolchains.ESLint.enablePrettierFeature(); 47 | } 48 | 49 | // TS といっしょに使う場合は追加, ESLint のコンフィグに追記 50 | if (environment.shouldUseTypeScriptFeatures) { 51 | packageInstaller.addInstallPackage( 52 | toolchains.ESLint.dependencies.useWithTypeScript 53 | ); 54 | toolchains.ESLint.enableTypeScriptFeatures(); 55 | } 56 | 57 | // Nuxt と併用する場合は ESLint に設定を追加する 58 | if (frontend.nuxt) { 59 | toolchains.ESLint.enableNuxtFeatures(); 60 | packageInstaller.addInstallPackage( 61 | toolchains.ESLint.dependencies.useWithNuxtJs 62 | ); 63 | 64 | if (environment.shouldUseTypeScriptFeatures) { 65 | toolchains.ESLint.enableNuxtAndTypeScriptFeatures(); 66 | packageInstaller.addInstallPackage( 67 | toolchains.ESLint.dependencies.useWithNuxtAndTS 68 | ); 69 | } 70 | } 71 | 72 | // Next.js と共用する場合は ESLint に設定追加 73 | if (frontend.next) { 74 | packageInstaller.addInstallPackage( 75 | toolchains.ESLint.dependencies.useWithNextJs 76 | ); 77 | toolchains.ESLint.enableNextFeatures(); 78 | } 79 | } 80 | 81 | if (toolchains.Prettier) { 82 | packageInstaller.addInstallPackage(toolchains.Prettier.dependencies.always); 83 | // scripts 追加 84 | pkg.addScript('prettier'); 85 | } 86 | 87 | if (environment.shouldUseTypeScriptFeatures) { 88 | // scripts 追加 89 | pkg.addScript('typeCheck'); 90 | } 91 | 92 | if (environment.shouldInstallTypeScript) { 93 | packageInstaller.addInstallPackage(new TSConfigJson().dependencies.always); 94 | } 95 | 96 | if (toolchains.Jest) { 97 | packageInstaller.addInstallPackage(toolchains.Jest.dependencies.always); 98 | pkg.addScript('test'); 99 | 100 | if (environment.shouldUseTypeScriptFeatures) { 101 | toolchains.Jest.enableTypeScript(); 102 | packageInstaller.addInstallPackage( 103 | toolchains.Jest.dependencies.useWithTypeScript 104 | ); 105 | } 106 | } 107 | 108 | // パッケージのインストールを開始 109 | await packageInstaller.install(); 110 | 111 | // package.json へ書き込み 112 | await pkg.writeScripts(); 113 | 114 | //------------------------------------------------------------------------------ 115 | // Config files generating 116 | //------------------------------------------------------------------------------ 117 | 118 | if (environment.shouldWriteTSConfigJson) { 119 | const tsconfig = new TSConfigJson(); 120 | 121 | if (usingCITools.GitHubActions) { 122 | await Promise.all([ 123 | tsconfig.generateGitHubActionsConfig(packageManager), 124 | tsconfig.save(), 125 | ]); 126 | } else { 127 | await tsconfig.save(); 128 | } 129 | } 130 | 131 | // フロントエンドは tsconfig.json を出さずに GitHub Actions のみ出力 132 | if ( 133 | checkObjectContainTrue(frontend) && 134 | environment.shouldUseTypeScriptFeatures && 135 | usingCITools.GitHubActions 136 | ) { 137 | await new TSConfigJson().generateGitHubActionsConfig(packageManager); 138 | } 139 | 140 | if (toolchains.ESLint) { 141 | if (usingCITools.GitHubActions) { 142 | await Promise.all([ 143 | toolchains.ESLint.generateGitHubActions(packageManager), 144 | toolchains.ESLint.save(), 145 | ]); 146 | } else { 147 | await toolchains.ESLint.save(); 148 | } 149 | } 150 | 151 | if (toolchains.Prettier) { 152 | await toolchains.Prettier.save(); 153 | } 154 | 155 | if (toolchains.Jest) { 156 | if (usingCITools.GitHubActions) { 157 | await Promise.all([ 158 | toolchains.Jest.generateGitHubActions(packageManager), 159 | toolchains.Jest.save(), 160 | ]); 161 | } else { 162 | await toolchains.Jest.save(); 163 | } 164 | } 165 | 166 | // dependabot を使う場合は保存 167 | // @TODO assignee の割当質問の増設 168 | if (usingCITools.dependabot) { 169 | await usingCITools.dependabot.save('dependabot.yml'); 170 | } 171 | 172 | console.log('Done All settings!'); 173 | }; 174 | --------------------------------------------------------------------------------