├── .npmrc ├── .codesandbox └── ci.json ├── screenshot.png ├── bin ├── preflight.js └── tsconfig.json ├── renovate.json ├── .size-limit.json ├── __tests__ ├── fixtures │ └── readme.md ├── __snapshots__ │ └── e2e.test.ts.snap └── e2e.test.ts ├── src ├── types │ ├── CtxParam.ts │ ├── top-user-agents.d.ts │ └── TaskParam.ts ├── util │ ├── randomUserAgent.ts │ ├── drone.ts │ ├── preflightBinPath.ts │ ├── crossPlatform.ts │ ├── commandExample.ts │ └── packageJson.ts ├── checks │ ├── noDependencyProblems │ │ ├── nextJsProjectHasSharpInstalled.ts │ │ ├── noUnusedDependencies.ts │ │ └── noDependenciesWithoutTypes.ts │ ├── noExtraneousFilesCommittedToGit.ts │ ├── preflightIsLatestVersion.ts │ ├── prettier.ts │ ├── useSinglePackageManager.ts │ ├── projectFolderNameMatchesCorrectFormat.ts │ ├── noSecretsCommittedToGit.ts │ ├── nodeModulesIgnoredFromGit.ts │ ├── allChangesCommittedToGit.ts │ ├── linkOnGithubAbout.ts │ ├── eslint.ts │ ├── stylelint.ts │ ├── eslintConfigIsValid.ts │ └── stylelintConfigIsValid.ts └── index.ts ├── prettier.config.js ├── .gitignore ├── babel.config.cjs ├── docker ├── tsconfig.json ├── package.json ├── tsconfig.build.json ├── pnpm-lock.yaml └── clone-and-preflight.ts ├── tsconfig.json ├── eslint.config.js ├── .github └── workflows │ ├── package-size.yml │ ├── build-and-push-docker-image.yml │ └── build-lint-test.yml ├── tsconfig.nonsrc.json ├── patches └── tsdx+0.14.1.patch ├── Dockerfile ├── LICENSE ├── README.md ├── tsconfig.src.json └── package.json /.npmrc: -------------------------------------------------------------------------------- 1 | save-exact=true 2 | -------------------------------------------------------------------------------- /.codesandbox/ci.json: -------------------------------------------------------------------------------- 1 | { 2 | "node": "18" 3 | } 4 | -------------------------------------------------------------------------------- /screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/gruvector/preflight/HEAD/screenshot.png -------------------------------------------------------------------------------- /bin/preflight.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | import '../dist/preflight.esm.js'; 4 | -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["github>karlhorky/renovate-config:default.json5"] 3 | } 4 | -------------------------------------------------------------------------------- /.size-limit.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "path": "dist/preflight.esm.js", 4 | "limit": "30 KB" 5 | } 6 | ] 7 | -------------------------------------------------------------------------------- /__tests__/fixtures/readme.md: -------------------------------------------------------------------------------- 1 | This folder will be filled by the `git clone` commands in the end to end tests 2 | -------------------------------------------------------------------------------- /src/types/CtxParam.ts: -------------------------------------------------------------------------------- 1 | import { ListrContext } from 'listr2'; 2 | 3 | export type CtxParam = ListrContext; 4 | -------------------------------------------------------------------------------- /src/types/top-user-agents.d.ts: -------------------------------------------------------------------------------- 1 | declare module 'top-user-agents' { 2 | const content: string[]; 3 | export default content; 4 | } 5 | -------------------------------------------------------------------------------- /bin/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "eslint-config-upleveled/tsconfig.base.json", 3 | "include": ["preflight.js"], 4 | "exclude": ["node_modules"] 5 | } 6 | -------------------------------------------------------------------------------- /prettier.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('prettier').Options} */ 2 | const config = { 3 | singleQuote: true, 4 | trailingComma: 'all', 5 | }; 6 | 7 | export default config; 8 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.log 2 | .DS_Store 3 | node_modules 4 | dist 5 | 6 | __tests__/fixtures/__temp 7 | docker/clone-and-preflight.js 8 | 9 | *.tsbuildinfo 10 | 11 | .eslintcache 12 | -------------------------------------------------------------------------------- /src/types/TaskParam.ts: -------------------------------------------------------------------------------- 1 | import { ListrDefaultRenderer, ListrTaskWrapper } from 'listr2'; 2 | 3 | export type TaskParam = ListrTaskWrapper< 4 | any, 5 | ListrDefaultRenderer, 6 | ListrDefaultRenderer 7 | >; 8 | -------------------------------------------------------------------------------- /src/util/randomUserAgent.ts: -------------------------------------------------------------------------------- 1 | import userAgents from 'top-user-agents'; 2 | 3 | export function randomUserAgent() { 4 | const randomIndex = Math.floor(Math.random() * (userAgents.length - 1)); 5 | return userAgents[randomIndex]!; 6 | } 7 | -------------------------------------------------------------------------------- /src/util/drone.ts: -------------------------------------------------------------------------------- 1 | import { execaCommand } from 'execa'; 2 | 3 | export async function isDrone() { 4 | const { stdout } = await execaCommand('cat /etc/os-release', { 5 | reject: false, 6 | }); 7 | return /Alpine Linux/.test(stdout); 8 | } 9 | -------------------------------------------------------------------------------- /babel.config.cjs: -------------------------------------------------------------------------------- 1 | /** @type {import('@babel/core').TransformOptions} */ 2 | const config = { 3 | env: { 4 | test: { 5 | plugins: ['@babel/plugin-transform-modules-commonjs'], 6 | }, 7 | }, 8 | }; 9 | 10 | module.exports = config; 11 | -------------------------------------------------------------------------------- /src/util/preflightBinPath.ts: -------------------------------------------------------------------------------- 1 | import { dirname } from 'node:path'; 2 | import { fileURLToPath } from 'node:url'; 3 | import { execaCommand } from 'execa'; 4 | 5 | export const { stdout: preflightBinPath } = await execaCommand(`pnpm bin`, { 6 | cwd: dirname(fileURLToPath(import.meta.url)), 7 | }); 8 | -------------------------------------------------------------------------------- /docker/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "eslint-config-upleveled/tsconfig.base.json", 3 | "compilerOptions": { 4 | "module": "Node16", 5 | "moduleResolution": "Node16", 6 | "target": "ES2020" 7 | }, 8 | "include": ["clone-and-preflight.ts"], 9 | "exclude": ["node_modules"] 10 | } 11 | -------------------------------------------------------------------------------- /src/util/crossPlatform.ts: -------------------------------------------------------------------------------- 1 | const CRLF = '\r\n'; 2 | 3 | export function normalizeNewlines(input: string) { 4 | if (typeof input !== 'string') { 5 | throw new TypeError(`Expected a \`string\`, got \`${typeof input}\``); 6 | } 7 | 8 | return input.replace(new RegExp(CRLF, 'g'), '\n'); 9 | } 10 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json.schemastore.org/tsconfig", 3 | "files": [], 4 | "references": [ 5 | { "path": "./bin/tsconfig.json" }, 6 | { "path": "./docker/tsconfig.json" }, 7 | { "path": "./tsconfig.nonsrc.json" }, 8 | { "path": "./tsconfig.src.json" }, 9 | ] 10 | } 11 | -------------------------------------------------------------------------------- /src/util/commandExample.ts: -------------------------------------------------------------------------------- 1 | import chalk from 'chalk'; 2 | 3 | // https://www.compart.com/en/unicode/U+2800 4 | // eslint-disable-next-line security/detect-bidi-characters -- Intentional use of unusual character for formatting 5 | const emptyBrailleCharacter = '‎'; 6 | 7 | export function commandExample(command: string) { 8 | return `${emptyBrailleCharacter} ${chalk.dim('$')} ${command}`; 9 | } 10 | -------------------------------------------------------------------------------- /docker/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@upleveled/preflight-docker", 3 | "version": "1.0.0", 4 | "private": true, 5 | "author": "UpLeveled (https://github.com/upleveled)", 6 | "contributors": [ 7 | "José Fernando Höwer Barbosa ", 8 | "Karl Horky " 9 | ], 10 | "type": "module", 11 | "dependencies": { 12 | "execa": "8.0.1" 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /eslint.config.js: -------------------------------------------------------------------------------- 1 | import upleveled from 'eslint-config-upleveled'; 2 | 3 | /** @type {import('@typescript-eslint/utils/ts-eslint').FlatConfig.ConfigArray} */ 4 | const config = [ 5 | ...upleveled, 6 | { 7 | languageOptions: { 8 | parserOptions: { 9 | EXPERIMENTAL_useProjectService: true, 10 | project: './tsconfig.json', 11 | }, 12 | }, 13 | }, 14 | ]; 15 | 16 | export default config; 17 | -------------------------------------------------------------------------------- /.github/workflows/package-size.yml: -------------------------------------------------------------------------------- 1 | name: size 2 | on: [pull_request] 3 | jobs: 4 | size: 5 | runs-on: ubuntu-latest 6 | timeout-minutes: 30 7 | env: 8 | CI_JOB_NUMBER: 1 9 | steps: 10 | - uses: actions/checkout@v4 11 | - uses: pnpm/action-setup@v2 12 | with: 13 | version: 'latest' 14 | - uses: andresz1/size-limit-action@v1 15 | with: 16 | github_token: ${{ secrets.GITHUB_TOKEN }} 17 | -------------------------------------------------------------------------------- /docker/tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | // Without this, module: Node16 causes emit in CommonJS, which leads to this error: 5 | // 6 | // ReferenceError: exports is not defined in ES module scope 7 | // This file is being treated as an ES module because it has a '.js' file extension and '/preflight/package.json' contains "type": "module". To treat it as a CommonJS script, rename it to use the '.cjs' file extension. 8 | "module": "ESNext", 9 | "noEmit": false 10 | }, 11 | "include": ["clone-and-preflight.ts"] 12 | } 13 | -------------------------------------------------------------------------------- /tsconfig.nonsrc.json: -------------------------------------------------------------------------------- 1 | // For files outside of /src 2 | { 3 | "$schema": "https://json.schemastore.org/tsconfig", 4 | "extends": "eslint-config-upleveled/tsconfig.base.json", 5 | "compilerOptions": { 6 | "composite": true, 7 | "module": "Node16", 8 | "moduleResolution": "Node16", 9 | "target": "ES2020", 10 | "checkJs": true 11 | }, 12 | "include": [ 13 | "**/*.ts", 14 | "**/*.tsx", 15 | "**/*.js", 16 | "**/*.jsx", 17 | "**/*.cjs", 18 | "**/*.mjs" 19 | ], 20 | "exclude": [ 21 | "node_modules", 22 | "__tests__/fixtures", 23 | "bin", 24 | "dist", 25 | "docker", 26 | "src" 27 | ] 28 | } 29 | -------------------------------------------------------------------------------- /patches/tsdx+0.14.1.patch: -------------------------------------------------------------------------------- 1 | diff --git a/node_modules/tsdx/dist/createJestConfig.js b/node_modules/tsdx/dist/createJestConfig.js 2 | index ec95298..03bf4bf 100644 3 | --- a/node_modules/tsdx/dist/createJestConfig.js 4 | +++ b/node_modules/tsdx/dist/createJestConfig.js 5 | @@ -10,7 +10,7 @@ function createJestConfig(_, rootDir) { 6 | moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json', 'node'], 7 | collectCoverageFrom: ['src/**/*.{ts,tsx,js,jsx}'], 8 | testMatch: ['/**/*.(spec|test).{ts,tsx,js,jsx}'], 9 | - testURL: 'http://localhost', 10 | + testEnvironmentOptions: { url: 'http://localhost' }, 11 | rootDir, 12 | watchPlugins: [ 13 | require.resolve('jest-watch-typeahead/filename'), 14 | -------------------------------------------------------------------------------- /src/checks/noDependencyProblems/nextJsProjectHasSharpInstalled.ts: -------------------------------------------------------------------------------- 1 | import { commandExample } from '../../util/commandExample'; 2 | import { projectPackageJson } from '../../util/packageJson'; 3 | 4 | export const title = 'Next.js project has sharp installed'; 5 | 6 | export default function nextJsProjectHasSharpInstalled() { 7 | const dependenciesPackageNames = Object.keys( 8 | projectPackageJson.dependencies || {}, 9 | ); 10 | if ( 11 | dependenciesPackageNames.includes('next') && 12 | !dependenciesPackageNames.includes('sharp') 13 | ) { 14 | throw new Error( 15 | `Next.js projects should have sharp installed for better image optimization. Install it with: 16 | 17 | ${commandExample('pnpm add sharp')} 18 | `, 19 | ); 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/checks/noExtraneousFilesCommittedToGit.ts: -------------------------------------------------------------------------------- 1 | import { execaCommand } from 'execa'; 2 | import { commandExample } from '../util/commandExample'; 3 | 4 | export const title = 'No extraneous files committed to Git'; 5 | 6 | export default async function noExtraneousFilesCommittedToGit() { 7 | const { stdout } = await execaCommand( 8 | 'git ls-files .DS_Store yarn-error.log npm-debug.log', 9 | ); 10 | 11 | if (stdout !== '') { 12 | throw new Error( 13 | `Extraneous files committed to Git: 14 | ${stdout} 15 | 16 | Remove these files from your repo by running the following command for each file: 17 | 18 | ${commandExample('git rm --cached ')} 19 | 20 | Once you've removed all files, make sure that it doesn't happen again by adding the filenames above to your .gitignore file. 21 | `, 22 | ); 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/checks/preflightIsLatestVersion.ts: -------------------------------------------------------------------------------- 1 | import os from 'node:os'; 2 | import { execaCommand } from 'execa'; 3 | import semver from 'semver'; 4 | import { commandExample } from '../util/commandExample'; 5 | import { preflightPackageJson } from '../util/packageJson'; 6 | 7 | export const title = 'Preflight is latest version'; 8 | 9 | export default async function preflightIsLatestVersion() { 10 | const { stdout: remoteVersion } = await execaCommand( 11 | 'npm show @upleveled/preflight version', 12 | ); 13 | 14 | if (semver.gt(remoteVersion, preflightPackageJson.version)) { 15 | throw new Error( 16 | `Your current version of Preflight (${ 17 | preflightPackageJson.version 18 | }) is older than the latest version ${remoteVersion} - upgrade with: 19 | 20 | ${commandExample( 21 | `${ 22 | os.platform() === 'linux' ? 'sudo ' : '' 23 | }pnpm add --global @upleveled/preflight`, 24 | )} 25 | `, 26 | ); 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:lts-alpine 2 | 3 | WORKDIR /preflight 4 | 5 | COPY ./docker/package.json ./docker/pnpm-lock.yaml ./ 6 | 7 | # Install dependencies: 8 | # - Git to allow `git clone` in the clone-and-preflight script (git) 9 | # - PostgreSQL for project databases (postgresql15) 10 | # - Python and build tools for building libpg-query with node-gyp (python3, py3-pip, build-base, bash) 11 | RUN apk update 12 | RUN apk add --no-cache git postgresql15 python3 py3-pip build-base bash 13 | 14 | RUN corepack enable && corepack prepare pnpm@latest --activate 15 | RUN pnpm install --frozen-lockfile 16 | 17 | # Enable `pnpm add --global` on Alpine Linux by setting 18 | # home location environment variable to a location already in $PATH 19 | # https://github.com/pnpm/pnpm/issues/784#issuecomment-1518582235 20 | ENV PNPM_HOME=/usr/local/bin 21 | 22 | RUN pnpm add --global @upleveled/preflight@latest 23 | 24 | COPY ./docker/clone-and-preflight.js ./ 25 | RUN chmod +x ./clone-and-preflight.js 26 | ENTRYPOINT ["./clone-and-preflight.js"] 27 | -------------------------------------------------------------------------------- /src/checks/prettier.ts: -------------------------------------------------------------------------------- 1 | import { execaCommand } from 'execa'; 2 | import { normalizeNewlines } from '../util/crossPlatform'; 3 | 4 | export const title = 'Prettier'; 5 | 6 | export default async function prettierCheck() { 7 | try { 8 | await execaCommand( 9 | 'pnpm prettier "**/*.{js,jsx,ts,tsx,css,scss,sql}" --list-different --end-of-line auto', 10 | ); 11 | } catch (error) { 12 | const { stdout, stderr } = error as { stdout: string; stderr: string }; 13 | 14 | if (!stdout || stderr) { 15 | throw error; 16 | } 17 | 18 | const unformattedFiles = normalizeNewlines(stdout).split('\n'); 19 | 20 | if (unformattedFiles.length > 0) { 21 | throw new Error( 22 | `Prettier has not been run in the following files: 23 | ${unformattedFiles.join('\n')} 24 | 25 | For each of the files above, open the file in your editor and save the file. This will format the file with Prettier, which will cause changes to appear in Git. 26 | `, 27 | ); 28 | } 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/checks/useSinglePackageManager.ts: -------------------------------------------------------------------------------- 1 | import { execaCommand } from 'execa'; 2 | import { commandExample } from '../util/commandExample'; 3 | 4 | export const title = 'Use single package manager'; 5 | 6 | export default async function useSinglePackageManager() { 7 | const { stdout } = await execaCommand( 8 | 'git ls-files package-lock.json yarn.lock', 9 | ); 10 | 11 | if (stdout !== '') { 12 | throw new Error( 13 | `package-lock.json or yarn.lock file committed to Git. Remove it with: 14 | 15 | ${commandExample('git rm --cached ')} 16 | 17 | After you've removed it, you can delete the file with: 18 | 19 | ${commandExample('rm ')} 20 | 21 | The presence of this file indicates that another package manager was used in addition to pnpm (eg. "npm install" or "yarn add" was run). In order to avoid issues with the state of the pnpm-lock.yaml file, we suggest also forcing regeneration this file with the following command: 22 | 23 | ${commandExample('pnpm install --force')} 24 | `, 25 | ); 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/checks/projectFolderNameMatchesCorrectFormat.ts: -------------------------------------------------------------------------------- 1 | import path from 'node:path'; 2 | import { commandExample } from '../util/commandExample'; 3 | 4 | export const title = 'Project folder name matches correct format'; 5 | 6 | export default function projectFolderNameMatchesCorrectFormat() { 7 | const currentDirectoryName = path.basename(process.cwd()); 8 | const lowercaseHyphenedDirectoryName = currentDirectoryName 9 | .toLowerCase() 10 | .replaceAll(' ', '-'); 11 | 12 | if (currentDirectoryName !== lowercaseHyphenedDirectoryName) { 13 | throw new Error( 14 | `Project directory name "${currentDirectoryName}" doesn't match the correct format (no spaces or uppercase letters). 15 | 16 | Rename the directory to the correct name "${lowercaseHyphenedDirectoryName}" with the following sequence of commands: 17 | 18 | ${commandExample('cd ..')} 19 | ${commandExample( 20 | `mv ${currentDirectoryName} ${lowercaseHyphenedDirectoryName}`, 21 | )} 22 | ${commandExample(`cd ${lowercaseHyphenedDirectoryName}`)} 23 | `, 24 | ); 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 josehower 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. -------------------------------------------------------------------------------- /src/checks/noSecretsCommittedToGit.ts: -------------------------------------------------------------------------------- 1 | import { execaCommand } from 'execa'; 2 | import { commandExample } from '../util/commandExample'; 3 | 4 | export const title = 'No secrets committed to Git'; 5 | 6 | export default async function noSecretsCommittedToGit() { 7 | const { stdout } = await execaCommand('git ls-files .env .env*.local'); 8 | 9 | if (stdout !== '') { 10 | throw new Error( 11 | `Secrets committed to Git 😱: 12 | ${stdout} 13 | 14 | Remove these files from your repo by installing BFG from the System Setup Guide (see Optional Software at the bottom) and running it on each of your files like this: 15 | 16 | ${commandExample('bfg --delete-files ')} 17 | 18 | Once you've done this for every secret file, then force push to your repository: 19 | 20 | ${commandExample('git push --force')} 21 | 22 | More info: https://docs.github.com/en/github/authenticating-to-github/removing-sensitive-data-from-a-repository 23 | 24 | Finally, make sure that this doesn't happen again by adding the filenames above to your .gitignore file. 25 | `, 26 | ); 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/checks/nodeModulesIgnoredFromGit.ts: -------------------------------------------------------------------------------- 1 | import { promises as fs } from 'node:fs'; 2 | import { execaCommand } from 'execa'; 3 | import { commandExample } from '../util/commandExample'; 4 | import { normalizeNewlines } from '../util/crossPlatform'; 5 | 6 | export const title = 'node_modules/ folder ignored in Git'; 7 | 8 | export default async function nodeModulesIgnoredFromGit() { 9 | if ((await execaCommand('git ls-files node_modules/')).stdout !== '') { 10 | throw new Error( 11 | `node_modules/ folder committed to Git. Remove it using: 12 | 13 | ${commandExample('git rm -r --cached node_modules')} 14 | `, 15 | ); 16 | } 17 | 18 | if ((await execaCommand('git ls-files .gitignore')).stdout !== '.gitignore') { 19 | throw new Error('.gitignore file not found'); 20 | } 21 | 22 | const nodeModulesInGitignore = normalizeNewlines( 23 | await fs.readFile('./.gitignore', 'utf8'), 24 | ) 25 | .split('\n') 26 | .reduce((found, line) => found || /^\/?node_modules\/?$/.test(line), false); 27 | 28 | if (!nodeModulesInGitignore) { 29 | throw new Error('node_modules not found in .gitignore'); 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/checks/allChangesCommittedToGit.ts: -------------------------------------------------------------------------------- 1 | import { promises as fs } from 'node:fs'; 2 | import { execaCommand } from 'execa'; 3 | import { commandExample } from '../util/commandExample'; 4 | import { isDrone } from '../util/drone'; 5 | 6 | export const title = 'All changes committed to Git'; 7 | 8 | export default async function allChangesCommittedToGit() { 9 | const { stdout: replSlug } = await execaCommand('echo $REPL_SLUG'); 10 | 11 | const isRunningInReplIt = replSlug !== ''; 12 | 13 | if (isRunningInReplIt) { 14 | await fs.writeFile('.git/info/exclude', '.replit\n'); 15 | } 16 | 17 | const { stdout } = await execaCommand('git status --porcelain'); 18 | 19 | if (stdout !== '') { 20 | const onlyPnpmLockModifiedOnDrone = 21 | stdout.trim() === 'M pnpm-lock.yaml' && (await isDrone()); 22 | throw new Error( 23 | `Some changes have not been committed to Git: 24 | ${stdout}${ 25 | onlyPnpmLockModifiedOnDrone 26 | ? ` 27 | 28 | The only file with changes is the pnpm-lock.yaml file, indicating that npm was incorrectly used in addition to pnpm (eg. an "npm install" command was run). To fix this, force regeneration of the pnpm-lock.yaml file locally with the following command and then commit the changes: 29 | 30 | ${commandExample('pnpm install --force')}` 31 | : '' 32 | } 33 | `, 34 | ); 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # UpLeveled Preflight 2 | 3 | A command line tool to check your UpLeveled projects before you submit 4 | 5 | A command line tool showing various passing tests that have run against a software project 6 | 7 | ## Install 8 | 9 | ```bash 10 | pnpm add --global @upleveled/preflight 11 | ``` 12 | 13 | ## Run 14 | 15 | ```bash 16 | preflight 17 | ``` 18 | 19 | ## Install and Run with Docker 20 | 21 | ```bash 22 | # Pull the image 23 | docker pull ghcr.io/upleveled/preflight 24 | 25 | # Run the image against a GitHub repo URL 26 | docker run ghcr.io/upleveled/preflight https://github.com/upleveled/preflight-test-project-react-passing 27 | 28 | # Or run the image against a specific branch in a GitHub repo URL 29 | docker run ghcr.io/upleveled/preflight https://github.com/upleveled/preflight-test-project-react-passing fix-tests 30 | ``` 31 | 32 | ## Run Preflight with GitHub Actions workflow 33 | 34 | To run Preflight on every commit in your repository, you can use the following GitHub Actions workflow: 35 | 36 | `.github/workflows/preflight.yml` 37 | 38 | ```yaml 39 | name: Preflight 40 | on: [push] 41 | 42 | jobs: 43 | preflight: 44 | name: Preflight 45 | runs-on: ubuntu-latest 46 | 47 | steps: 48 | - name: Pull latest Preflight image 49 | run: docker pull ghcr.io/upleveled/preflight 50 | - name: Run Preflight 51 | run: docker run ghcr.io/upleveled/preflight https://github.com/${{ github.repository}} ${{ github.ref_name }} 52 | ``` 53 | -------------------------------------------------------------------------------- /tsconfig.src.json: -------------------------------------------------------------------------------- 1 | { 2 | "include": ["src"], 3 | "exclude": ["dist"], 4 | "compilerOptions": { 5 | "module": "esnext", 6 | "lib": ["dom", "esnext"], 7 | "target": "es2020", 8 | "importHelpers": true, 9 | // output .d.ts declaration files for consumers 10 | "declaration": true, 11 | // output .js.map sourcemap files for consumers 12 | "sourceMap": true, 13 | // match output dir to input dir. e.g. dist/index instead of dist/src/index 14 | "rootDir": "./src", 15 | // stricter type-checking for stronger correctness. Recommended by TS 16 | "strict": true, 17 | // linter checks for common issues 18 | "noImplicitReturns": true, 19 | "noUncheckedIndexedAccess": true, 20 | "noFallthroughCasesInSwitch": true, 21 | // use Node's module resolution algorithm, instead of the legacy TS one 22 | "moduleResolution": "node", 23 | // transpile JSX to React.createElement 24 | "jsx": "react", 25 | // interop between ESM and CJS modules. Recommended by TS 26 | "esModuleInterop": true, 27 | // significant perf increase by skipping checking .d.ts files, particularly those in node_modules. Recommended by TS 28 | "skipLibCheck": true, 29 | // error out if import and file system have a casing mismatch. Recommended by TS 30 | "forceConsistentCasingInFileNames": true, 31 | // `tsdx build` ignores this option, but it is commonly used when type-checking separately with `tsc` 32 | "noEmit": true, 33 | "resolveJsonModule": true, 34 | "checkJs": true 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/util/packageJson.ts: -------------------------------------------------------------------------------- 1 | import { promises as fs } from 'node:fs'; 2 | import { URL } from 'node:url'; 3 | 4 | type PackageJson = { 5 | name: string; 6 | version: string; 7 | description?: string; 8 | keywords?: string; 9 | homepage?: string; 10 | bugs?: { 11 | email?: string; 12 | url?: string; 13 | }; 14 | license?: string; 15 | author?: 16 | | string 17 | | { 18 | name: string; 19 | email?: string; 20 | url?: string; 21 | }; 22 | contributors?: 23 | | string[] 24 | | { 25 | name: string; 26 | email?: string; 27 | url?: string; 28 | }[]; 29 | files?: string[]; 30 | main?: string; 31 | browser?: string; 32 | bin?: Record; 33 | man?: string; 34 | directories?: { 35 | lib?: string; 36 | bin?: string; 37 | man?: string; 38 | doc?: string; 39 | example?: string; 40 | test?: string; 41 | }; 42 | repository?: { 43 | type?: 'git'; 44 | url?: string; 45 | directory?: string; 46 | }; 47 | scripts?: Record; 48 | config?: Record; 49 | dependencies?: Record; 50 | devDependencies?: Record; 51 | peerDependencies?: Record; 52 | optionalDependencies?: Record; 53 | bundledDependencies?: string[]; 54 | engines?: Record; 55 | os?: string[]; 56 | cpu?: string[]; 57 | }; 58 | 59 | export const projectPackageJson = JSON.parse( 60 | await fs.readFile('package.json', 'utf-8'), 61 | ) as PackageJson; 62 | 63 | export const preflightPackageJson = JSON.parse( 64 | await fs.readFile(new URL('../package.json', import.meta.url), 'utf-8'), 65 | ) as PackageJson; 66 | -------------------------------------------------------------------------------- /src/checks/linkOnGithubAbout.ts: -------------------------------------------------------------------------------- 1 | import cheerio from 'cheerio'; 2 | import type { Element } from 'domhandler'; 3 | import { execaCommand } from 'execa'; 4 | import fetch from 'node-fetch'; 5 | import { randomUserAgent } from '../util/randomUserAgent'; 6 | 7 | export const title = 'GitHub repo has deployed project link under About'; 8 | 9 | export default async function linkOnGithubAbout() { 10 | const { stdout } = await execaCommand('git remote get-url origin'); 11 | 12 | const repoUrl = stdout 13 | .replace('git@github.com:', 'https://github.com/') 14 | .replace('.git', ''); 15 | 16 | const html = await (await fetch(repoUrl)).text(); 17 | 18 | const $ = cheerio.load(html); 19 | 20 | const urlInAboutSection = $('h2') 21 | .filter(function (this: Element) { 22 | return $(this).text().trim() === 'About'; 23 | }) 24 | .nextAll('div') 25 | .filter(function (this: Element) { 26 | return $(this).children('.octicon.octicon-link').length > 0; 27 | }) 28 | .children('.octicon.octicon-link') 29 | .next() 30 | .children('a[href]') 31 | .attr('href'); 32 | 33 | if (!urlInAboutSection) { 34 | throw new Error( 35 | `Deployed project link not found in About section on ${repoUrl}. Click on the cog symbol to the right of the About heading and paste the Repl.it / Netlify / Fly.io link in the Website box.`, 36 | ); 37 | } 38 | 39 | const response = await fetch(urlInAboutSection, { 40 | headers: { 41 | // For repl.it 42 | 'user-agent': randomUserAgent(), 43 | }, 44 | }); 45 | 46 | if (!response.ok) { 47 | throw new Error( 48 | `Project link in About section on ${repoUrl} is not returning a proper status code: the link returns status code ${response.status} (${response.statusText}).`, 49 | ); 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /.github/workflows/build-and-push-docker-image.yml: -------------------------------------------------------------------------------- 1 | name: Build and push Docker image 2 | on: 3 | create: 4 | workflow_dispatch: 5 | inputs: 6 | dispatchConfirmation: 7 | description: 'Please confirm the workflow dispatch' 8 | required: true 9 | default: 'Workflow dispatch confirmed' 10 | 11 | jobs: 12 | build: 13 | if: ${{ github.event.ref_type == 'tag' || github.event.inputs.dispatchConfirmation == 'Workflow dispatch confirmed' }} 14 | name: Build and push Docker image 15 | runs-on: ubuntu-latest 16 | timeout-minutes: 30 17 | permissions: 18 | contents: read 19 | packages: write 20 | steps: 21 | - name: Checkout 22 | uses: actions/checkout@v4 23 | 24 | - uses: pnpm/action-setup@v2 25 | with: 26 | version: 'latest' 27 | 28 | - name: Use Node.js 29 | uses: actions/setup-node@v4 30 | with: 31 | node-version: 'lts/*' 32 | cache: 'pnpm' 33 | 34 | - run: pnpm install 35 | 36 | - name: Compile TypeScript 37 | run: pnpm docker-build-ts 38 | 39 | # Publish to container registry 40 | - name: Log in to the Container registry 41 | uses: docker/login-action@v3 42 | with: 43 | registry: ghcr.io 44 | username: ${{ github.actor }} 45 | password: ${{ secrets.GITHUB_TOKEN }} 46 | - name: Extract metadata (tags, labels) for Docker 47 | id: meta 48 | uses: docker/metadata-action@v5 49 | with: 50 | images: ghcr.io/${{ github.repository }} 51 | flavor: | 52 | latest=true 53 | - name: Build and push Docker image 54 | uses: docker/build-push-action@v5 55 | with: 56 | context: . 57 | push: true 58 | tags: ${{ steps.meta.outputs.tags }} 59 | labels: ${{ steps.meta.outputs.labels }} 60 | -------------------------------------------------------------------------------- /.github/workflows/build-lint-test.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | on: 3 | push: 4 | branches: 5 | - '**' 6 | jobs: 7 | build: 8 | name: '${{ matrix.os }}: Build, lint, test' 9 | 10 | runs-on: ${{ matrix.os }} 11 | timeout-minutes: 30 12 | strategy: 13 | matrix: 14 | os: [ubuntu-latest, windows-latest] 15 | env: 16 | PGHOST: localhost 17 | PGDATABASE: preflight_test_project_next_js_passing 18 | PGUSERNAME: preflight_test_project_next_js_passing 19 | PGPASSWORD: preflight_test_project_next_js_passing 20 | steps: 21 | - uses: ikalnytskyi/action-setup-postgres@v5 22 | with: 23 | username: ${{ env.PGUSERNAME }} 24 | password: ${{ env.PGPASSWORD }} 25 | database: ${{ env.PGDATABASE }} 26 | # To avoid CRLF in Windows tests, which cause problems with Prettier: 27 | # https://github.com/upleveled/preflight/runs/1824397400 28 | # 29 | # Suggested here: https://github.com/actions/checkout/issues/250#issuecomment-635267458 30 | # Example repo: https://github.com/ghdl/ghdl/blob/aa63b5efcd2be66acc26443032df2b251e4b1a7a/.github/workflows/Test.yml#L230-L232 31 | - name: Use LF instead of CRLF for clone 32 | run: git config --global core.autocrlf input 33 | 34 | - name: Checkout repo 35 | uses: actions/checkout@v4 36 | 37 | - uses: pnpm/action-setup@v2 38 | with: 39 | version: 'latest' 40 | 41 | - name: Use Node.js 42 | uses: actions/setup-node@v4 43 | with: 44 | node-version: 'lts/*' 45 | cache: 'pnpm' 46 | 47 | - run: pnpm install 48 | - run: pnpm install 49 | if: runner.os != 'Windows' 50 | working-directory: docker 51 | 52 | - name: Lint 53 | if: runner.os != 'Windows' 54 | run: pnpm lint 55 | 56 | - run: pnpm tsc --build 57 | if: runner.os != 'Windows' 58 | 59 | - name: Build 60 | run: pnpm build 61 | 62 | - name: Test 63 | run: pnpm test -- --ci --maxWorkers=2 64 | -------------------------------------------------------------------------------- /src/checks/eslint.ts: -------------------------------------------------------------------------------- 1 | import { sep } from 'node:path'; 2 | import { ESLint } from 'eslint'; 3 | import { execaCommand } from 'execa'; 4 | 5 | export const title = 'ESLint'; 6 | 7 | export default async function eslintCheck() { 8 | try { 9 | await execaCommand('pnpm eslint . --max-warnings 0 --format json'); 10 | } catch (error) { 11 | const { stdout } = error as { stdout: string }; 12 | 13 | let eslintResults; 14 | 15 | try { 16 | eslintResults = (JSON.parse(stdout) as ESLint.LintResult[]) 17 | // Filter out results with no problems, which the ESLint CLI 18 | // still reports with the `--format json` flag 19 | .filter((eslintResult) => { 20 | return eslintResult.errorCount > 0 || eslintResult.warningCount > 0; 21 | }); 22 | } catch (parseError) { 23 | throw error; 24 | } 25 | 26 | if ( 27 | eslintResults.length < 1 || 28 | !eslintResults.every( 29 | (result) => 'errorCount' in result && 'warningCount' in result, 30 | ) 31 | ) { 32 | throw new Error( 33 | `Unexpected shape of ESLint JSON related to .errorCount and .warningCount properties - please report this to the UpLeveled engineering team, including the following output: 34 | ${stdout} 35 | `, 36 | ); 37 | } 38 | 39 | throw new Error( 40 | `ESLint problems found in the following files: 41 | ${eslintResults 42 | // Make paths relative to the project: 43 | // 44 | // Before: 45 | // macOS / Linux: /home/projects/next-student-project/app/api/hello/route.js 46 | // Windows: C:\Users\Lukas\projects\next-student-project\app\api\hello\route.js 47 | // 48 | // After: 49 | // macOS / Linux: app/api/hello/route.js 50 | // Windows: app\api\hello\route.js 51 | .map(({ filePath }) => filePath.replace(`${process.cwd()}${sep}`, '')) 52 | .join('\n')} 53 | 54 | Open these files in your editor - there should be problems to fix 55 | `, 56 | ); 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /src/checks/stylelint.ts: -------------------------------------------------------------------------------- 1 | import { sep } from 'node:path'; 2 | import { execaCommand } from 'execa'; 3 | import { LintResult } from 'stylelint'; 4 | 5 | export const supportedStylelintFileExtensions = [ 6 | 'css', 7 | 'sass', 8 | 'scss', 9 | 'less', 10 | 'js', 11 | 'tsx', 12 | 'jsx', 13 | ]; 14 | 15 | export const title = 'Stylelint'; 16 | 17 | export default async function stylelintCheck() { 18 | try { 19 | await execaCommand( 20 | `pnpm stylelint **/*.{${supportedStylelintFileExtensions.join( 21 | ',', 22 | )}} --max-warnings 0 --formatter json`, 23 | ); 24 | } catch (error) { 25 | const { stdout } = error as { stdout: string }; 26 | 27 | let stylelintResults; 28 | 29 | try { 30 | stylelintResults = (JSON.parse(stdout) as LintResult[]).filter( 31 | (stylelintResult) => stylelintResult.errored === true, 32 | ); 33 | } catch (parseError) { 34 | throw error; 35 | } 36 | 37 | if ( 38 | stylelintResults.length < 1 || 39 | !stylelintResults.every((result) => 'errored' in result) 40 | ) { 41 | throw new Error( 42 | `Unexpected shape of Stylelint JSON related to .errored properties - please report this to the UpLeveled engineering team, including the following output: 43 | ${stdout} 44 | `, 45 | ); 46 | } 47 | 48 | throw new Error( 49 | `Stylelint problems found in the following files: 50 | ${stylelintResults 51 | // Make paths relative to the project: 52 | // 53 | // Before: 54 | // macOS / Linux: /home/projects/random-color-generator-react-app/src/index.css 55 | // Windows: C:\Users\Lukas\projects\random-color-generator-react-app\src\index.css 56 | // 57 | // After: 58 | // macOS / Linux: src/index.css 59 | // Windows: src\index.css 60 | .map(({ source }) => source!.replace(`${process.cwd()}${sep}`, '')) 61 | .join('\n')} 62 | 63 | Open these files in your editor - there should be problems to fix 64 | `, 65 | ); 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /src/checks/eslintConfigIsValid.ts: -------------------------------------------------------------------------------- 1 | import { promises as fs } from 'node:fs'; 2 | import { createRequire } from 'node:module'; 3 | import { execaCommand } from 'execa'; 4 | import readdirp from 'readdirp'; 5 | import semver from 'semver'; 6 | 7 | const require = createRequire(`${process.cwd()}/`); 8 | 9 | export const title = 'ESLint config is latest version'; 10 | 11 | export default async function eslintConfigIsValid() { 12 | const { stdout: remoteVersion } = await execaCommand( 13 | 'npm show eslint-config-upleveled version', 14 | ); 15 | 16 | let localVersion; 17 | 18 | try { 19 | const eslintConfigPackageJsonPath = require.resolve( 20 | 'eslint-config-upleveled/package.json', 21 | ); 22 | 23 | localVersion = JSON.parse( 24 | await fs.readFile(eslintConfigPackageJsonPath, 'utf-8'), 25 | ).version; 26 | } catch (error) {} 27 | 28 | if (typeof localVersion === 'undefined') { 29 | throw new Error( 30 | `The UpLeveled ESLint Config has not been installed - please install using the instructions on https://www.npmjs.com/package/eslint-config-upleveled 31 | `, 32 | ); 33 | } 34 | 35 | if (semver.gt(remoteVersion, localVersion)) { 36 | throw new Error( 37 | `Your current version of the UpLeveled ESLint Config (${localVersion}) is older than the latest version ${remoteVersion} - upgrade by running all lines of the install instructions on https://www.npmjs.com/package/eslint-config-upleveled 38 | `, 39 | ); 40 | } 41 | 42 | let eslintConfigMatches; 43 | 44 | try { 45 | eslintConfigMatches = 46 | (await fs.readFile('./eslint.config.js', 'utf-8')).trim() === 47 | "export { default } from 'eslint-config-upleveled';"; 48 | } catch (error) { 49 | throw new Error( 50 | `Error reading your eslint.config.js file - please delete the file if it exists and reinstall the config using the instructions on https://www.npmjs.com/package/eslint-config-upleveled 51 | `, 52 | ); 53 | } 54 | 55 | if (!eslintConfigMatches) { 56 | throw new Error( 57 | `Your eslint.config.js file does not match the configuration file template - please delete the file and reinstall the config using the instructions on https://www.npmjs.com/package/eslint-config-upleveled 58 | `, 59 | ); 60 | } 61 | 62 | const eslintDisableOccurrences = []; 63 | 64 | for await (const { path } of readdirp('.', { 65 | directoryFilter: ['!.git', '!.next', '!node_modules'], 66 | fileFilter: ['*.js', '*.jsx', '*.ts', '*.tsx'], 67 | })) { 68 | const fileContents = await fs.readFile(path, 'utf-8'); 69 | if (/eslint-disable|eslint [a-z0-9@/-]+: (0|off)/.test(fileContents)) { 70 | eslintDisableOccurrences.push(path); 71 | } 72 | } 73 | 74 | if (eslintDisableOccurrences.length > 0) { 75 | throw new Error( 76 | `ESLint has been disabled in the following files: 77 | ${eslintDisableOccurrences.join('\n')} 78 | 79 | Remove all comments disabling or modifying ESLint rule configuration (eg. eslint-disable and eslint-disable-next-line comments) and fix the problems 80 | `, 81 | ); 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /src/checks/stylelintConfigIsValid.ts: -------------------------------------------------------------------------------- 1 | import { promises as fs } from 'node:fs'; 2 | import { createRequire } from 'node:module'; 3 | import { execaCommand } from 'execa'; 4 | import readdirp from 'readdirp'; 5 | import semver from 'semver'; 6 | import { supportedStylelintFileExtensions } from './stylelint'; 7 | 8 | const require = createRequire(`${process.cwd()}/`); 9 | 10 | export const title = 'Stylelint config is latest version'; 11 | 12 | export default async function stylelintConfigIsValid() { 13 | const { stdout: remoteVersion } = await execaCommand( 14 | 'npm show stylelint-config-upleveled version', 15 | ); 16 | 17 | let localVersion; 18 | 19 | try { 20 | const stylelintConfigPackageJsonPath = require.resolve( 21 | 'stylelint-config-upleveled/package.json', 22 | ); 23 | 24 | localVersion = JSON.parse( 25 | await fs.readFile(stylelintConfigPackageJsonPath, 'utf-8'), 26 | ).version; 27 | } catch (error) {} 28 | 29 | if (typeof localVersion === 'undefined') { 30 | throw new Error( 31 | `The UpLeveled Stylelint Config has not been installed - please install using the instructions on https://www.npmjs.com/package/eslint-config-upleveled 32 | `, 33 | ); 34 | } 35 | 36 | if (semver.gt(remoteVersion, localVersion)) { 37 | throw new Error( 38 | `Your current version of the UpLeveled Stylelint Config (${localVersion}) is older than the latest version ${remoteVersion} - upgrade by running: 39 | 40 | pnpm add stylelint-config-upleveled@${remoteVersion}`, 41 | ); 42 | } 43 | 44 | let stylelintConfigMatches; 45 | 46 | try { 47 | stylelintConfigMatches = 48 | (await fs.readFile('./stylelint.config.js', 'utf-8')).trim() === 49 | `/** @type {import('stylelint').Config} */ 50 | const config = { 51 | extends: ['stylelint-config-upleveled'], 52 | }; 53 | 54 | export default config;`; 55 | } catch (error) { 56 | throw new Error( 57 | `Error reading your stylelint.config.js file - please delete the file if it exists and reinstall the config using the instructions on https://www.npmjs.com/package/eslint-config-upleveled 58 | `, 59 | ); 60 | } 61 | 62 | if (!stylelintConfigMatches) { 63 | throw new Error( 64 | `Your stylelint.config.js file does not match the configuration file template - please delete the file and reinstall the config using the instructions on https://www.npmjs.com/package/eslint-config-upleveled 65 | `, 66 | ); 67 | } 68 | 69 | const stylelintDisableOccurrences = []; 70 | 71 | for await (const { path } of readdirp('.', { 72 | directoryFilter: ['!.git', '!.next', '!node_modules'], 73 | fileFilter: supportedStylelintFileExtensions.map( 74 | (fileExtension) => `*.${fileExtension}`, 75 | ), 76 | })) { 77 | const fileContents = await fs.readFile(path, 'utf-8'); 78 | if (fileContents.includes('stylelint-disable')) { 79 | stylelintDisableOccurrences.push(path); 80 | } 81 | } 82 | 83 | if (stylelintDisableOccurrences.length > 0) { 84 | throw new Error( 85 | `Stylelint has been disabled in the following files: 86 | ${stylelintDisableOccurrences.join('\n')} 87 | 88 | Remove all comments disabling or modifying Stylelint rule configuration (eg. stylelint-disable and stylelint-disable-next-line comments) and fix the problems 89 | `, 90 | ); 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@upleveled/preflight", 3 | "version": "6.0.0", 4 | "repository": "upleveled/preflight", 5 | "license": "MIT", 6 | "author": "UpLeveled (https://github.com/upleveled)", 7 | "contributors": [ 8 | "José Fernando Höwer Barbosa ", 9 | "Karl Horky " 10 | ], 11 | "type": "module", 12 | "main": "dist/preflight.esm.js", 13 | "module": "dist/preflight.esm.js", 14 | "typings": "dist/index.d.ts", 15 | "bin": { 16 | "preflight": "bin/preflight.js" 17 | }, 18 | "files": [ 19 | "bin/preflight.js", 20 | "dist", 21 | "src" 22 | ], 23 | "scripts": { 24 | "analyze": "size-limit --why", 25 | "build": "tsdx build --format esm --target node --tsconfig tsconfig.src.json", 26 | "docker-build": "docker build --tag preflight .", 27 | "docker-build-run": "pnpm docker-build-ts && pnpm docker-build && pnpm docker-run", 28 | "docker-build-ts": "tsc --project docker/tsconfig.build.json", 29 | "docker-run": "docker run preflight", 30 | "postinstall": "patch-package", 31 | "lint": "eslint . --max-warnings 0", 32 | "prepare": "pnpm build", 33 | "size": "size-limit", 34 | "start": "tsdx watch --format esm --target node", 35 | "test": "tsdx test", 36 | "test-local": "rm -rf ./__tests__/fixtures/__temp && pnpm test" 37 | }, 38 | "jest": { 39 | "transformIgnorePatterns": [ 40 | "node_modules/(?!execa)/" 41 | ] 42 | }, 43 | "dependencies": { 44 | "@types/eslint": "8.56.2", 45 | "algoliasearch": "4.22.1", 46 | "chalk": "5.3.0", 47 | "cheerio": "1.0.0-rc.12", 48 | "depcheck": "1.4.7", 49 | "domhandler": "5.0.3", 50 | "execa": "8.0.1", 51 | "listr2": "8.0.2", 52 | "node-fetch": "3.3.2", 53 | "p-reduce": "3.0.0", 54 | "patch-package": "8.0.0", 55 | "readdirp": "3.6.0", 56 | "semver": "7.6.0", 57 | "top-user-agents": "2.1.9" 58 | }, 59 | "devDependencies": { 60 | "@babel/plugin-transform-modules-commonjs": "7.23.3", 61 | "@jest/globals": "29.7.0", 62 | "@size-limit/file": "11.0.2", 63 | "@types/babel__core": "7.20.5", 64 | "@types/jest": "29.5.12", 65 | "@types/node": "20.11.17", 66 | "@types/p-map": "2.0.0", 67 | "@types/semver": "7.5.6", 68 | "babel-jest": "29.7.0", 69 | "eslint": "8.56.0", 70 | "eslint-config-upleveled": "7.7.1", 71 | "p-map": "7.0.1", 72 | "postinstall-postinstall": "2.1.0", 73 | "prettier": "3.2.5", 74 | "size-limit": "11.0.2", 75 | "stylelint": "16.2.1", 76 | "tsdx": "0.14.1", 77 | "tslib": "2.6.2", 78 | "typescript": "5.3.3" 79 | }, 80 | "engines": { 81 | "node": ">=18" 82 | }, 83 | "pnpm": { 84 | "overrides": { 85 | "@typescript-eslint/eslint-plugin": "6.15.0", 86 | "@typescript-eslint/parser": "6.15.0", 87 | "@typescript-eslint/scope-manager": "6.15.0", 88 | "@typescript-eslint/utils": "6.15.0", 89 | "eslint": "8.53.0", 90 | "eslint-plugin-jest": "27.6.0", 91 | "eslint-plugin-react-hooks": "4.6.0", 92 | "node-notifier": "10.0.1", 93 | "normalize-package-data@2.5.0>semver": "5.7.2", 94 | "jest": "29.5.0", 95 | "rollup-plugin-typescript2": "^0.32.0", 96 | "ts-jest": "29.1.0", 97 | "typescript": "5.1.6" 98 | } 99 | } 100 | } 101 | -------------------------------------------------------------------------------- /src/checks/noDependencyProblems/noUnusedDependencies.ts: -------------------------------------------------------------------------------- 1 | import { execaCommand } from 'execa'; 2 | import { commandExample } from '../../util/commandExample'; 3 | import { preflightBinPath } from '../../util/preflightBinPath'; 4 | 5 | export const title = 'No unused dependencies'; 6 | 7 | export default async function noUnusedAndMissingDependencies() { 8 | const ignoredPackagePatterns = [ 9 | // Unused dependency detected in https://github.com/upleveled/next-portfolio-dev 10 | '@graphql-codegen/cli', 11 | 12 | // Tailwind CSS 13 | '@tailwindcss/jit', 14 | 'autoprefixer', 15 | 'postcss', 16 | 'tailwindcss', 17 | 18 | // Sass (eg. in Next.js) 19 | 'sass', 20 | 21 | // Prettier and plugins 22 | 'prettier', 23 | 'prettier-plugin-*', 24 | 25 | // ESLint configuration 26 | '@ts-safeql/eslint-plugin', 27 | 'libpg-query', 28 | 29 | // TODO: Remove this once depcheck issue is fixed: 30 | // PR: https://github.com/depcheck/depcheck/pull/790 31 | // Issue: https://github.com/depcheck/depcheck/issues/791 32 | // 33 | // Stylelint configuration 34 | 'stylelint', 35 | 'stylelint-config-upleveled', 36 | 37 | // Testing 38 | '@testing-library/user-event', 39 | 'jest', 40 | 'jest-environment-jsdom', 41 | 'playwright', 42 | 43 | // `expect` required for proper types with `@testing-library/jest-dom` with `@jest/globals` and pnpm 44 | // https://github.com/testing-library/jest-dom/issues/123#issuecomment-1536828385 45 | // TODO: Remove when we switch from Jest to Vitest 46 | 'expect', 47 | 48 | // TypeScript 49 | 'typescript', 50 | '@types/*', 51 | 'tsx', 52 | 53 | // Next.js 54 | 'sharp', 55 | ].join(','); 56 | 57 | try { 58 | await execaCommand( 59 | `${preflightBinPath}/depcheck --ignores="${ignoredPackagePatterns}"`, 60 | ); 61 | } catch (error) { 62 | const { stdout } = error as { stdout: string }; 63 | if ( 64 | !stdout.startsWith('Unused dependencies') && 65 | !stdout.startsWith('Unused devDependencies') && 66 | !stdout.startsWith('Missing dependencies') 67 | ) { 68 | throw error; 69 | } 70 | 71 | const [unusedDependenciesStdout, missingDependenciesStdout] = stdout.split( 72 | 'Missing dependencies', 73 | ); 74 | 75 | const messages = []; 76 | 77 | if (unusedDependenciesStdout) { 78 | messages.push(`Unused dependencies found: 79 | ${unusedDependenciesStdout 80 | .split('\n') 81 | .filter((str: string) => str.includes('* ')) 82 | .join('\n')} 83 | 84 | Remove these dependencies by running the following command for each dependency: 85 | 86 | ${commandExample('pnpm remove ')} 87 | `); 88 | } 89 | 90 | if (missingDependenciesStdout) { 91 | messages.push(`Missing dependencies found: 92 | ${missingDependenciesStdout 93 | .split('\n') 94 | .filter((str: string) => str.includes('* ')) 95 | .join('\n')} 96 | 97 | Add these missing dependencies by running the following command for each dependency: 98 | 99 | ${commandExample('pnpm add ')} 100 | `); 101 | } 102 | 103 | if (messages.length > 0) throw new Error(messages.join('\n\n')); 104 | } 105 | } 106 | -------------------------------------------------------------------------------- /__tests__/__snapshots__/e2e.test.ts.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`Passes in the next-js-passing test project 1`] = ` 4 | "🚀 UpLeveled Preflight 5 | [COMPLETED] All changes committed to Git 6 | [COMPLETED] ESLint 7 | [COMPLETED] ESLint config is latest version 8 | [COMPLETED] GitHub repo has deployed project link under About 9 | [COMPLETED] Next.js project has sharp installed 10 | [COMPLETED] No dependencies without types 11 | [COMPLETED] No dependency problems 12 | [COMPLETED] No extraneous files committed to Git 13 | [COMPLETED] No secrets committed to Git 14 | [COMPLETED] No unused dependencies 15 | [COMPLETED] Preflight is latest version 16 | [COMPLETED] Prettier 17 | [COMPLETED] Project folder name matches correct format 18 | [COMPLETED] Stylelint 19 | [COMPLETED] Stylelint config is latest version 20 | [COMPLETED] Use single package manager 21 | [COMPLETED] node_modules/ folder ignored in Git 22 | [STARTED] All changes committed to Git 23 | [STARTED] ESLint 24 | [STARTED] ESLint config is latest version 25 | [STARTED] GitHub repo has deployed project link under About 26 | [STARTED] Next.js project has sharp installed 27 | [STARTED] No dependencies without types 28 | [STARTED] No dependency problems 29 | [STARTED] No extraneous files committed to Git 30 | [STARTED] No secrets committed to Git 31 | [STARTED] No unused dependencies 32 | [STARTED] Preflight is latest version 33 | [STARTED] Prettier 34 | [STARTED] Project folder name matches correct format 35 | [STARTED] Stylelint 36 | [STARTED] Stylelint config is latest version 37 | [STARTED] Use single package manager 38 | [STARTED] node_modules/ folder ignored in Git" 39 | `; 40 | 41 | exports[`Passes in the next-js-passing test project 2`] = `""`; 42 | 43 | exports[`Passes in the react-passing test project 1`] = ` 44 | "🚀 UpLeveled Preflight 45 | [COMPLETED] All changes committed to Git 46 | [COMPLETED] ESLint 47 | [COMPLETED] ESLint config is latest version 48 | [COMPLETED] GitHub repo has deployed project link under About 49 | [COMPLETED] No dependencies without types 50 | [COMPLETED] No dependency problems 51 | [COMPLETED] No extraneous files committed to Git 52 | [COMPLETED] No secrets committed to Git 53 | [COMPLETED] No unused dependencies 54 | [COMPLETED] Preflight is latest version 55 | [COMPLETED] Prettier 56 | [COMPLETED] Project folder name matches correct format 57 | [COMPLETED] Stylelint 58 | [COMPLETED] Stylelint config is latest version 59 | [COMPLETED] Use single package manager 60 | [COMPLETED] node_modules/ folder ignored in Git 61 | [STARTED] All changes committed to Git 62 | [STARTED] ESLint 63 | [STARTED] ESLint config is latest version 64 | [STARTED] GitHub repo has deployed project link under About 65 | [STARTED] No dependencies without types 66 | [STARTED] No dependency problems 67 | [STARTED] No extraneous files committed to Git 68 | [STARTED] No secrets committed to Git 69 | [STARTED] No unused dependencies 70 | [STARTED] Preflight is latest version 71 | [STARTED] Prettier 72 | [STARTED] Project folder name matches correct format 73 | [STARTED] Stylelint 74 | [STARTED] Stylelint config is latest version 75 | [STARTED] Use single package manager 76 | [STARTED] node_modules/ folder ignored in Git" 77 | `; 78 | 79 | exports[`Passes in the react-passing test project 2`] = `""`; 80 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import { Listr } from 'listr2'; 2 | import * as allChangesCommittedToGit from './checks/allChangesCommittedToGit.js'; 3 | import * as eslint from './checks/eslint.js'; 4 | import * as eslintConfigIsValid from './checks/eslintConfigIsValid.js'; 5 | import * as linkOnGithubAbout from './checks/linkOnGithubAbout.js'; 6 | import * as nodeModulesIgnoredFromGit from './checks/nodeModulesIgnoredFromGit.js'; 7 | import * as nextJsProjectHasSharpInstalled from './checks/noDependencyProblems/nextJsProjectHasSharpInstalled.js'; 8 | import * as noDependenciesWithoutTypes from './checks/noDependencyProblems/noDependenciesWithoutTypes.js'; 9 | import * as noUnusedAndMissingDependencies from './checks/noDependencyProblems/noUnusedDependencies.js'; 10 | import * as noExtraneousFilesCommittedToGit from './checks/noExtraneousFilesCommittedToGit.js'; 11 | import * as noSecretsCommittedToGit from './checks/noSecretsCommittedToGit.js'; 12 | import * as preflightIsLatestVersion from './checks/preflightIsLatestVersion.js'; 13 | import * as prettier from './checks/prettier.js'; 14 | import * as projectFolderNameMatchesCorrectFormat from './checks/projectFolderNameMatchesCorrectFormat.js'; 15 | import * as stylelint from './checks/stylelint.js'; 16 | import * as stylelintConfigIsValid from './checks/stylelintConfigIsValid.js'; 17 | import * as useSinglePackageManager from './checks/useSinglePackageManager.js'; 18 | import { CtxParam } from './types/CtxParam.js'; 19 | import { TaskParam } from './types/TaskParam.js'; 20 | import { 21 | preflightPackageJson, 22 | projectPackageJson, 23 | } from './util/packageJson.js'; 24 | 25 | const projectDependencies = projectPackageJson.dependencies || {}; 26 | 27 | console.log(`🚀 UpLeveled Preflight v${preflightPackageJson.version}`); 28 | 29 | const listrTasks = [ 30 | // ======= Sync Tasks ======= 31 | // Git 32 | allChangesCommittedToGit, 33 | nodeModulesIgnoredFromGit, 34 | noExtraneousFilesCommittedToGit, 35 | noSecretsCommittedToGit, 36 | 37 | // Package managers 38 | useSinglePackageManager, 39 | 40 | // Project setup 41 | projectFolderNameMatchesCorrectFormat, 42 | 43 | // ======= Async Tasks ======= 44 | // Dependencies 45 | { 46 | title: 'No dependency problems', 47 | task: (ctx: CtxParam, task: TaskParam): Listr => 48 | task.newListr([ 49 | ...(!Object.keys(projectDependencies).includes('next') 50 | ? [] 51 | : [ 52 | { 53 | title: nextJsProjectHasSharpInstalled.title, 54 | task: nextJsProjectHasSharpInstalled.default, 55 | }, 56 | ]), 57 | { 58 | title: noUnusedAndMissingDependencies.title, 59 | task: noUnusedAndMissingDependencies.default, 60 | }, 61 | { 62 | title: noDependenciesWithoutTypes.title, 63 | task: noDependenciesWithoutTypes.default, 64 | }, 65 | ]), 66 | }, 67 | 68 | // GitHub 69 | linkOnGithubAbout, 70 | 71 | // Linting 72 | eslint, 73 | ...(!( 74 | '@upleveled/react-scripts' in projectDependencies || 75 | 'next' in projectDependencies 76 | ) 77 | ? [] 78 | : [stylelint]), 79 | prettier, 80 | 81 | // Version and configuration checks 82 | eslintConfigIsValid, 83 | ...(!( 84 | '@upleveled/react-scripts' in projectDependencies || 85 | 'next' in projectDependencies 86 | ) 87 | ? [] 88 | : [stylelintConfigIsValid]), 89 | preflightIsLatestVersion, 90 | ].map((module) => { 91 | if ('task' in module) return module; 92 | return { 93 | title: module.title, 94 | task: module.default, 95 | }; 96 | }); 97 | 98 | const tasks = new Listr(listrTasks, { 99 | exitOnError: false, 100 | collectErrors: 'minimal', 101 | rendererOptions: { 102 | collapseErrors: false, 103 | removeEmptyLines: false, 104 | formatOutput: 'wrap', 105 | }, 106 | fallbackRenderer: 'verbose', 107 | concurrent: 5, 108 | }); 109 | 110 | await tasks.run(); 111 | 112 | if (tasks.errors.length > 0) { 113 | process.exit(1); 114 | } 115 | -------------------------------------------------------------------------------- /src/checks/noDependencyProblems/noDependenciesWithoutTypes.ts: -------------------------------------------------------------------------------- 1 | import { existsSync, promises as fs } from 'node:fs'; 2 | import algoliasearch from 'algoliasearch'; 3 | import pReduce from 'p-reduce'; 4 | import { commandExample } from '../../util/commandExample'; 5 | import { projectPackageJson } from '../../util/packageJson'; 6 | 7 | const client = algoliasearch( 8 | // Application ID and API key specific to UpLeveled 9 | // Preflight. Please don't use anywhere else without 10 | // asking Algolia's permission. 11 | 'OFCNCOG2CU', // Application ID 12 | 'ec73550aa8b2936dab436d4e02144784', // API Key 13 | ); 14 | const index = client.initIndex('npm-search'); 15 | 16 | interface AlgoliaObj { 17 | types?: { 18 | definitelyTyped?: string; 19 | }; 20 | } 21 | 22 | export const title = 'No dependencies without types'; 23 | 24 | // This is a naive check for matching @types/ packages 25 | // that the student hasn't yet installed. It is not intended to 26 | // be an exhaustive check for any types for all packages. 27 | // 28 | // It attempts to address scenarios such as this with 29 | // `styled-components`: 30 | // 31 | // https://learn.upleveled.io/courses/btcmp-l-webfs-gen-0/modules/122-cheatsheet-css-in-js/#eslint-errors-with-styled-components 32 | export default async function noDependenciesWithoutTypes() { 33 | const dependenciesWithMissingTypes = await pReduce( 34 | Object.keys(projectPackageJson.dependencies || {}), 35 | async (filteredDependencies: [string, string][], dependency: string) => { 36 | try { 37 | const packageJsonPath = require.resolve(`${dependency}/package.json`); 38 | 39 | const modulePackageJson = JSON.parse( 40 | await fs.readFile(packageJsonPath, 'utf-8'), 41 | ); 42 | 43 | // If the keys "types" or "typings" are in the module's `package.json`, bail out 44 | if ('types' in modulePackageJson || 'typings' in modulePackageJson) { 45 | return filteredDependencies; 46 | } 47 | } catch (error) {} 48 | 49 | let indexDTsPath; 50 | 51 | try { 52 | indexDTsPath = require.resolve(`${dependency}/index.d.ts`); 53 | } catch (error) {} 54 | 55 | // If the index.d.ts file exists inside the module's directory, bail out 56 | if (indexDTsPath && existsSync(indexDTsPath)) { 57 | return filteredDependencies; 58 | } 59 | 60 | let results: AlgoliaObj; 61 | 62 | try { 63 | results = await index.getObject(dependency, { 64 | attributesToRetrieve: ['types'], 65 | }); 66 | } catch (error) { 67 | // Show dependency name if Algolia's `index.getObject()` throws with an 68 | // error message (such as the error message "ObjectID does not exist" 69 | // when a package cannot be found in the index) 70 | throw new Error( 71 | `Algolia error for \`${dependency}\`: ${(error as Error).message}`, 72 | ); 73 | } 74 | 75 | const definitelyTypedPackageName = results.types?.definitelyTyped; 76 | 77 | if (definitelyTypedPackageName) { 78 | // If a matching `@types/` has been already installed in devDependencies, bail out 79 | if ( 80 | Object.keys(projectPackageJson.devDependencies || {}).includes( 81 | definitelyTypedPackageName, 82 | ) 83 | ) { 84 | return filteredDependencies; 85 | } 86 | 87 | filteredDependencies.push([dependency, definitelyTypedPackageName]); 88 | } 89 | 90 | return filteredDependencies; 91 | }, 92 | [], 93 | ); 94 | 95 | if (dependenciesWithMissingTypes.length > 0) { 96 | throw new Error( 97 | `Dependencies found without types. Add the missing types with: 98 | 99 | ${commandExample( 100 | `pnpm add --save-dev ${dependenciesWithMissingTypes 101 | .map(([, definitelyTypedPackageName]) => definitelyTypedPackageName) 102 | .join(' ')}`, 103 | )} 104 | 105 | If the dependencies above are already in your package.json, check that they have not been incorrectly installed as regular dependencies in the "dependencies" object - they should be installed inside "devDependencies" (using the --save-dev flag mentioned above). To fix this situation, remove the dependencies and run the command above exactly. 106 | `, 107 | ); 108 | } 109 | } 110 | -------------------------------------------------------------------------------- /docker/pnpm-lock.yaml: -------------------------------------------------------------------------------- 1 | lockfileVersion: '6.0' 2 | 3 | settings: 4 | autoInstallPeers: true 5 | excludeLinksFromLockfile: false 6 | 7 | dependencies: 8 | execa: 9 | specifier: 8.0.1 10 | version: 8.0.1 11 | 12 | packages: 13 | 14 | /cross-spawn@7.0.3: 15 | resolution: {integrity: sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==} 16 | engines: {node: '>= 8'} 17 | dependencies: 18 | path-key: 3.1.1 19 | shebang-command: 2.0.0 20 | which: 2.0.2 21 | dev: false 22 | 23 | /execa@8.0.1: 24 | resolution: {integrity: sha512-VyhnebXciFV2DESc+p6B+y0LjSm0krU4OgJN44qFAhBY0TJ+1V61tYD2+wHusZ6F9n5K+vl8k0sTy7PEfV4qpg==} 25 | engines: {node: '>=16.17'} 26 | dependencies: 27 | cross-spawn: 7.0.3 28 | get-stream: 8.0.1 29 | human-signals: 5.0.0 30 | is-stream: 3.0.0 31 | merge-stream: 2.0.0 32 | npm-run-path: 5.1.0 33 | onetime: 6.0.0 34 | signal-exit: 4.1.0 35 | strip-final-newline: 3.0.0 36 | dev: false 37 | 38 | /get-stream@8.0.1: 39 | resolution: {integrity: sha512-VaUJspBffn/LMCJVoMvSAdmscJyS1auj5Zulnn5UoYcY531UWmdwhRWkcGKnGU93m5HSXP9LP2usOryrBtQowA==} 40 | engines: {node: '>=16'} 41 | dev: false 42 | 43 | /human-signals@5.0.0: 44 | resolution: {integrity: sha512-AXcZb6vzzrFAUE61HnN4mpLqd/cSIwNQjtNWR0euPm6y0iqx3G4gOXaIDdtdDwZmhwe82LA6+zinmW4UBWVePQ==} 45 | engines: {node: '>=16.17.0'} 46 | dev: false 47 | 48 | /is-stream@3.0.0: 49 | resolution: {integrity: sha512-LnQR4bZ9IADDRSkvpqMGvt/tEJWclzklNgSw48V5EAaAeDd6qGvN8ei6k5p0tvxSR171VmGyHuTiAOfxAbr8kA==} 50 | engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} 51 | dev: false 52 | 53 | /isexe@2.0.0: 54 | resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} 55 | dev: false 56 | 57 | /merge-stream@2.0.0: 58 | resolution: {integrity: sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==} 59 | dev: false 60 | 61 | /mimic-fn@4.0.0: 62 | resolution: {integrity: sha512-vqiC06CuhBTUdZH+RYl8sFrL096vA45Ok5ISO6sE/Mr1jRbGH4Csnhi8f3wKVl7x8mO4Au7Ir9D3Oyv1VYMFJw==} 63 | engines: {node: '>=12'} 64 | dev: false 65 | 66 | /npm-run-path@5.1.0: 67 | resolution: {integrity: sha512-sJOdmRGrY2sjNTRMbSvluQqg+8X7ZK61yvzBEIDhz4f8z1TZFYABsqjjCBd/0PUNE9M6QDgHJXQkGUEm7Q+l9Q==} 68 | engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} 69 | dependencies: 70 | path-key: 4.0.0 71 | dev: false 72 | 73 | /onetime@6.0.0: 74 | resolution: {integrity: sha512-1FlR+gjXK7X+AsAHso35MnyN5KqGwJRi/31ft6x0M194ht7S+rWAvd7PHss9xSKMzE0asv1pyIHaJYq+BbacAQ==} 75 | engines: {node: '>=12'} 76 | dependencies: 77 | mimic-fn: 4.0.0 78 | dev: false 79 | 80 | /path-key@3.1.1: 81 | resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} 82 | engines: {node: '>=8'} 83 | dev: false 84 | 85 | /path-key@4.0.0: 86 | resolution: {integrity: sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ==} 87 | engines: {node: '>=12'} 88 | dev: false 89 | 90 | /shebang-command@2.0.0: 91 | resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} 92 | engines: {node: '>=8'} 93 | dependencies: 94 | shebang-regex: 3.0.0 95 | dev: false 96 | 97 | /shebang-regex@3.0.0: 98 | resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} 99 | engines: {node: '>=8'} 100 | dev: false 101 | 102 | /signal-exit@4.1.0: 103 | resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} 104 | engines: {node: '>=14'} 105 | dev: false 106 | 107 | /strip-final-newline@3.0.0: 108 | resolution: {integrity: sha512-dOESqjYr96iWYylGObzd39EuNTa5VJxyvVAEm5Jnh7KGo75V43Hk1odPQkNDyXNmUR6k+gEiDVXnjB8HJ3crXw==} 109 | engines: {node: '>=12'} 110 | dev: false 111 | 112 | /which@2.0.2: 113 | resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} 114 | engines: {node: '>= 8'} 115 | hasBin: true 116 | dependencies: 117 | isexe: 2.0.0 118 | dev: false 119 | -------------------------------------------------------------------------------- /__tests__/e2e.test.ts: -------------------------------------------------------------------------------- 1 | import { tmpdir } from 'node:os'; 2 | import { beforeAll, expect, test } from '@jest/globals'; 3 | import { execaCommand } from 'execa'; 4 | import pMap from 'p-map'; 5 | 6 | const fixturesTempDir = process.env.GITHUB_ACTIONS 7 | ? // Switch to `tmpdir()` on GitHub Actions to avoid 8 | // ESLint crashing with Windows paths over the 260 9 | // character MAX_PATH limit 10 | // https://github.com/upleveled/preflight/pull/469/#issuecomment-1812422819 11 | // https://github.com/eslint/eslint/issues/17763 12 | tmpdir() 13 | : '__tests__/fixtures/__temp'; 14 | 15 | function cloneRepoToFixtures(repoPath: string, fixtureDirName: string) { 16 | return execaCommand( 17 | `git clone --depth 1 --single-branch --branch=main https://github.com/${repoPath}.git ${fixturesTempDir}/${fixtureDirName} --config core.autocrlf=input`, 18 | ); 19 | } 20 | 21 | type Repo = { 22 | repoPath: string; 23 | dirName: string; 24 | installCommands?: string[]; 25 | }; 26 | 27 | const testRepos: Repo[] = [ 28 | { 29 | repoPath: 'upleveled/preflight-test-project-react-passing', 30 | dirName: 'react-passing', 31 | }, 32 | { 33 | repoPath: 'upleveled/preflight-test-project-next-js-passing', 34 | dirName: 'next-js-passing', 35 | installCommands: 36 | // libpg-query is not yet supported on Windows 37 | // https://github.com/pganalyze/libpg_query/issues/44 38 | process.platform === 'win32' 39 | ? [ 40 | // `pnpm remove` also installs if node_modules doesn't 41 | // exist (no need to run `pnpm install` as well) 42 | 'pnpm remove @ts-safeql/eslint-plugin libpg-query', 43 | // Commit packages.json and pnpm-lock.yaml changes to 44 | // avoid failing "All changes committed to Git" check 45 | 'git config user.email github-actions[bot]@users.noreply.github.com', 46 | 'git config user.name github-actions[bot]', 47 | 'git commit --all --message Remove\\ SafeSQL\\ for\\ Windows', 48 | ] 49 | : [ 50 | 'pnpm install --frozen-lockfile', 51 | // Run project database migrations 52 | 'pnpm migrate up', 53 | ], 54 | }, 55 | ]; 56 | 57 | beforeAll( 58 | async () => { 59 | await pMap( 60 | testRepos, 61 | ({ repoPath, dirName }) => cloneRepoToFixtures(repoPath, dirName), 62 | { concurrency: 4 }, 63 | ); 64 | 65 | await pMap( 66 | testRepos, 67 | async ({ dirName, installCommands }) => { 68 | if (!installCommands || installCommands.length < 1) { 69 | // Return array to keep return type uniform with 70 | // `return pMap()` below 71 | return [ 72 | await execaCommand('pnpm install --frozen-lockfile', { 73 | cwd: `${fixturesTempDir}/${dirName}`, 74 | }), 75 | ]; 76 | } 77 | 78 | return pMap( 79 | installCommands, 80 | (command) => 81 | execaCommand(command, { 82 | cwd: `${fixturesTempDir}/${dirName}`, 83 | }), 84 | { concurrency: 1 }, 85 | ); 86 | }, 87 | { concurrency: 1 }, 88 | ); 89 | }, 90 | // 7.5 minute timeout for pnpm installation inside test repos 91 | 450000, 92 | ); 93 | 94 | function sortStdoutAndStripVersionNumber(stdout: string) { 95 | return stdout 96 | .replace(/(UpLeveled Preflight) v\d+\.\d+\.\d+(-\d+)?/, '$1') 97 | .split('\n') 98 | .sort((a: string, b: string) => { 99 | if (b.includes('UpLeveled Preflight')) return 1; 100 | return a < b ? -1 : 1; 101 | }) 102 | .join('\n') 103 | .trim(); 104 | } 105 | 106 | test('Passes in the react-passing test project', async () => { 107 | const { stdout, stderr } = await execaCommand( 108 | `${process.cwd()}/bin/preflight.js`, 109 | { 110 | cwd: `${fixturesTempDir}/react-passing`, 111 | }, 112 | ); 113 | 114 | expect(sortStdoutAndStripVersionNumber(stdout)).toMatchSnapshot(); 115 | expect(stderr.replace(/^\(node:\d+\) /, '')).toMatchSnapshot(); 116 | }, 30000); 117 | 118 | test('Passes in the next-js-passing test project', async () => { 119 | const { stdout, stderr } = await execaCommand( 120 | `${process.cwd()}/bin/preflight.js`, 121 | { 122 | cwd: `${fixturesTempDir}/next-js-passing`, 123 | }, 124 | ); 125 | 126 | expect(sortStdoutAndStripVersionNumber(stdout)).toMatchSnapshot(); 127 | expect(stderr.replace(/^\(node:\d+\) /, '')).toMatchSnapshot(); 128 | }, 45000); 129 | -------------------------------------------------------------------------------- /docker/clone-and-preflight.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | import { execaCommand, Options } from 'execa'; 4 | 5 | const regex = /^https:\/\/github\.com\/[a-zA-Z0-9\-.]+\/[a-zA-Z0-9\-.]+$/; 6 | 7 | if (!process.argv[2] || !process.argv[2].match(regex)) { 8 | console.error(`Argument doesn't match GitHub URL format. Example: 9 | 10 | $ docker run ghcr.io/upleveled/preflight https://github.com/upleveled/preflight-test-project-react-passing`); 11 | process.exit(1); 12 | } 13 | 14 | const projectPath = 'project-to-check'; 15 | 16 | async function executeCommand(command: string, options?: Pick) { 17 | let all: string | undefined = ''; 18 | let exitCode = 0; 19 | 20 | try { 21 | ({ all, exitCode } = await execaCommand(command, { 22 | cwd: options?.cwd, 23 | all: true, 24 | })); 25 | } catch (error) { 26 | console.error(error); 27 | process.exit(1); 28 | } 29 | 30 | if (exitCode !== 0) { 31 | console.error(all); 32 | process.exit(1); 33 | } else { 34 | return all; 35 | } 36 | } 37 | 38 | console.log(`Cloning ${process.argv[2]}...`); 39 | await executeCommand( 40 | `git clone --depth 1 ${ 41 | !process.argv[3] ? '' : `--branch ${process.argv[3]}` 42 | } --single-branch ${ 43 | process.argv[2] 44 | } ${projectPath} --config core.autocrlf=input`, 45 | ); 46 | 47 | console.log('Installing dependencies...'); 48 | await executeCommand('pnpm install', { cwd: projectPath }); 49 | 50 | // Exit code of grep will be 0 if the `"postgres":` 51 | // string is found in package.json, indicating that 52 | // Postgres.js is installed and the project uses 53 | // a PostgreSQL database 54 | const projectUsesPostgresql = 55 | ( 56 | await execaCommand('grep package.json -e \'"postgres":\'', { 57 | cwd: projectPath, 58 | shell: true, 59 | reject: false, 60 | }) 61 | ).exitCode === 0; 62 | 63 | if (projectUsesPostgresql) { 64 | console.log('Setting up PostgreSQL database...'); 65 | 66 | // Set database connection environment variables (inherited in 67 | // all future execaCommand / executeCommand calls) 68 | process.env.PGHOST = 'localhost'; 69 | process.env.PGDATABASE = 'project_to_check'; 70 | process.env.PGUSERNAME = 'project_to_check'; 71 | process.env.PGPASSWORD = 'project_to_check'; 72 | 73 | // Create directory for PostgreSQL socket 74 | await executeCommand('mkdir /run/postgresql'); 75 | await executeCommand('chown postgres:postgres /run/postgresql'); 76 | 77 | // Run script as postgres user to: 78 | // - Create data directory 79 | // - Init database 80 | // - Start database 81 | // - Create database 82 | // - Create database user 83 | // - Create schema 84 | // - Grant permissions to database user 85 | // 86 | // Example script: 87 | // https://github.com/upleveled/preflight-test-project-next-js-passing/blob/e65717f6951b5336bb0bd83c15bbc99caa67ebe9/scripts/alpine-postgresql-setup-and-start.sh 88 | const postgresUid = Number((await executeCommand('id -u postgres'))!); 89 | await execaCommand('bash ./scripts/alpine-postgresql-setup-and-start.sh', { 90 | cwd: projectPath, 91 | // postgres user, for initdb and pg_ctl 92 | uid: postgresUid, 93 | // Show output to simplify debugging 94 | stdout: 'inherit', 95 | stderr: 'inherit', 96 | }); 97 | 98 | console.log('Running migrations...'); 99 | await executeCommand('pnpm migrate up', { cwd: projectPath }); 100 | 101 | if ( 102 | // Exit code of grep will be non-zero if the 103 | // `"@ts-safeql/eslint-plugin":` string is not found in 104 | // package.json, indicating that SafeQL has not been 105 | // installed 106 | ( 107 | await execaCommand( 108 | 'grep package.json -e \'"@ts-safeql/eslint-plugin":\'', 109 | { 110 | cwd: projectPath, 111 | shell: true, 112 | reject: false, 113 | }, 114 | ) 115 | ).exitCode !== 0 116 | ) { 117 | console.log( 118 | 'SafeQL ESLint plugin not yet installed (project created on Windows machine), installing...', 119 | ); 120 | await executeCommand('pnpm add @ts-safeql/eslint-plugin libpg-query', { 121 | cwd: projectPath, 122 | }); 123 | 124 | // Commit packages.json and pnpm-lock.yaml changes to 125 | // avoid failing "All changes committed to Git" check 126 | await executeCommand( 127 | 'git config user.email github-actions[bot]@users.noreply.github.com', 128 | { cwd: projectPath }, 129 | ); 130 | await executeCommand('git config user.name github-actions[bot]', { 131 | cwd: projectPath, 132 | }); 133 | await executeCommand( 134 | 'git commit --all --message Add\\ SafeSQL\\ for\\ Windows', 135 | { cwd: projectPath }, 136 | ); 137 | } 138 | } 139 | 140 | console.log('Running Preflight...'); 141 | 142 | const { exitCode } = await execaCommand('preflight', { 143 | cwd: projectPath, 144 | reject: false, 145 | stdout: 'inherit', 146 | stderr: 'inherit', 147 | }); 148 | 149 | process.exit(exitCode); 150 | --------------------------------------------------------------------------------