├── .all-contributorsrc ├── .github ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── DEVELOPMENT.md ├── FUNDING.yml ├── ISSUE_TEMPLATE.md ├── ISSUE_TEMPLATE │ ├── 01-bug.yml │ ├── 02-documentation.yml │ ├── 03-feature.yml │ └── 04-tooling.yml ├── PULL_REQUEST_TEMPLATE.md ├── SECURITY.md ├── actions │ └── prepare │ │ └── action.yml ├── renovate.json └── workflows │ ├── ci.yml │ ├── contributors.yml │ ├── octoguide.yml │ ├── post-release.yml │ ├── pr-review-requested.yml │ └── release.yml ├── .gitignore ├── .husky ├── .gitignore └── pre-commit ├── .markdownlint.json ├── .markdownlintignore ├── .nvmrc ├── .prettierignore ├── .prettierrc.json ├── .release-it.json ├── .vscode ├── extensions.json ├── launch.json ├── settings.json └── tasks.json ├── CHANGELOG.md ├── LICENSE.md ├── README.md ├── bin └── index.js ├── cspell.json ├── docs ├── Blocks.md ├── CLI.md ├── Configuration Files.md ├── FAQs.md ├── Setup.md ├── Transition.md ├── UseThisTemplate.md └── create-typescript-app.png ├── eslint.config.js ├── knip.json ├── package.json ├── pnpm-lock.yaml ├── src ├── base.test.ts ├── base.ts ├── blocks │ ├── actions │ │ ├── resolveUses.test.ts │ │ ├── resolveUses.ts │ │ └── steps.ts │ ├── bin │ │ ├── getPrimaryBin.test.ts │ │ └── getPrimaryBin.ts │ ├── blockAllContributors.test.ts │ ├── blockAllContributors.ts │ ├── blockAreTheTypesWrong.test.ts │ ├── blockAreTheTypesWrong.ts │ ├── blockCSpell.test.ts │ ├── blockCSpell.ts │ ├── blockCTATransitions.test.ts │ ├── blockCTATransitions.ts │ ├── blockCodecov.test.ts │ ├── blockCodecov.ts │ ├── blockContributingDocs.test.ts │ ├── blockContributingDocs.ts │ ├── blockContributorCovenant.test.ts │ ├── blockContributorCovenant.ts │ ├── blockDevelopmentDocs.test.ts │ ├── blockDevelopmentDocs.ts │ ├── blockESLint.test.ts │ ├── blockESLint.ts │ ├── blockESLintComments.ts │ ├── blockESLintIntake.test.ts │ ├── blockESLintJSDoc.ts │ ├── blockESLintJSONC.ts │ ├── blockESLintMarkdown.ts │ ├── blockESLintMoreStyling.ts │ ├── blockESLintNode.test.ts │ ├── blockESLintNode.ts │ ├── blockESLintPackageJson.test.ts │ ├── blockESLintPackageJson.ts │ ├── blockESLintPerfectionist.ts │ ├── blockESLintPlugin.test.ts │ ├── blockESLintPlugin.ts │ ├── blockESLintRegexp.ts │ ├── blockESLintYML.test.ts │ ├── blockESLintYML.ts │ ├── blockExampleFiles.test.ts │ ├── blockExampleFiles.ts │ ├── blockFunding.ts │ ├── blockGitHubActionsCI.test.ts │ ├── blockGitHubActionsCI.ts │ ├── blockGitHubApps.test.ts │ ├── blockGitHubApps.ts │ ├── blockGitHubIssueTemplates.ts │ ├── blockGitHubPRTemplate.ts │ ├── blockGitignore.test.ts │ ├── blockGitignore.ts │ ├── blockKnip.test.ts │ ├── blockKnip.ts │ ├── blockMITLicense.ts │ ├── blockMain.test.ts │ ├── blockMain.ts │ ├── blockMarkdownlint.test.ts │ ├── blockMarkdownlint.ts │ ├── blockNcc.test.ts │ ├── blockNcc.ts │ ├── blockNvmrc.test.ts │ ├── blockNvmrc.ts │ ├── blockOctoGuide.test.ts │ ├── blockOctoGuide.ts │ ├── blockOctoGuideStrict.ts │ ├── blockPackageJson.test.ts │ ├── blockPackageJson.ts │ ├── blockPnpmDedupe.test.ts │ ├── blockPnpmDedupe.ts │ ├── blockPrettier.test.ts │ ├── blockPrettier.ts │ ├── blockPrettierPluginCurly.ts │ ├── blockPrettierPluginPackageJson.ts │ ├── blockPrettierPluginSh.ts │ ├── blockREADME.test.ts │ ├── blockREADME.ts │ ├── blockReleaseIt.test.ts │ ├── blockReleaseIt.ts │ ├── blockRemoveDependencies.test.ts │ ├── blockRemoveDependencies.ts │ ├── blockRemoveFiles.test.ts │ ├── blockRemoveFiles.ts │ ├── blockRemoveWorkflows.test.ts │ ├── blockRemoveWorkflows.ts │ ├── blockRenovate.test.ts │ ├── blockRenovate.ts │ ├── blockRepositoryBranchRuleset.test.ts │ ├── blockRepositoryBranchRuleset.ts │ ├── blockRepositoryLabels.test.ts │ ├── blockRepositoryLabels.ts │ ├── blockRepositorySecrets.test.ts │ ├── blockRepositorySecrets.ts │ ├── blockRepositorySettings.test.ts │ ├── blockRepositorySettings.ts │ ├── blockSecurityDocs.ts │ ├── blockTSup.test.ts │ ├── blockTSup.ts │ ├── blockTemplatedWith.test.ts │ ├── blockTemplatedWith.ts │ ├── blockTypeScript.test.ts │ ├── blockTypeScript.ts │ ├── blockVSCode.test.ts │ ├── blockVSCode.ts │ ├── blockVitest.test.ts │ ├── blockVitest.ts │ ├── blockWebExt.test.ts │ ├── blockWebExt.ts │ ├── eslint │ │ ├── blockESLintIntake.ts │ │ ├── blockESLintPluginIntake.ts │ │ └── schemas.ts │ ├── files │ │ ├── createJobName.test.ts │ │ ├── createJobName.ts │ │ ├── createMultiWorkflowFile.ts │ │ ├── createSoloWorkflowFile.ts │ │ ├── formatIgnoreFile.ts │ │ ├── formatWorkflowYaml.ts │ │ ├── formatYaml.ts │ │ ├── removeUsesQuotes.test.ts │ │ └── removeUsesQuotes.ts │ ├── getInstallationSuggestions.test.ts │ ├── getInstallationSuggestions.ts │ ├── index.ts │ ├── intake │ │ ├── intakeFile.test.ts │ │ ├── intakeFile.ts │ │ ├── intakeFileAsJson.test.ts │ │ ├── intakeFileAsJson.ts │ │ ├── intakeFileAsYaml.test.ts │ │ ├── intakeFileAsYaml.ts │ │ ├── intakeFileDefineConfig.test.ts │ │ └── intakeFileDefineConfig.ts │ ├── options.fakes.ts │ ├── outcomeLabels.ts │ └── phases.ts ├── constants.ts ├── data │ ├── contributions.ts │ ├── packageData.test.ts │ └── packageData.ts ├── docsBlocks.test.ts ├── docsOptions.test.ts ├── index.ts ├── inputs │ ├── inputFromDirectory.ts │ ├── inputFromOctokit.test.ts │ └── inputFromOctokit.ts ├── integration.test.ts ├── options │ ├── readAccess.test.ts │ ├── readAccess.ts │ ├── readAllContributors.test.ts │ ├── readAllContributors.ts │ ├── readAuthor.test.ts │ ├── readAuthor.ts │ ├── readBin.test.ts │ ├── readBin.ts │ ├── readDescription.test.ts │ ├── readDescription.ts │ ├── readDescriptionFromReadme.test.ts │ ├── readDescriptionFromReadme.ts │ ├── readDevelopmentDocumentation.test.ts │ ├── readDevelopmentDocumentation.ts │ ├── readDocumentation.ts │ ├── readEmailFromCodeOfConduct.test.ts │ ├── readEmailFromCodeOfConduct.ts │ ├── readEmailFromGit.test.ts │ ├── readEmailFromGit.ts │ ├── readEmailFromNpm.test.ts │ ├── readEmailFromNpm.ts │ ├── readEmails.test.ts │ ├── readEmails.ts │ ├── readEmoji.test.ts │ ├── readEmoji.ts │ ├── readExistingLabels.test.ts │ ├── readExistingLabels.ts │ ├── readFileAsJson.test.ts │ ├── readFileAsJson.ts │ ├── readFileSafe.test.ts │ ├── readFileSafe.ts │ ├── readFunding.ts │ ├── readGitDefaults.test.ts │ ├── readGitDefaults.ts │ ├── readGuide.test.ts │ ├── readGuide.ts │ ├── readKeywords.test.ts │ ├── readKeywords.ts │ ├── readLogo.test.ts │ ├── readLogo.ts │ ├── readLogoSizing.test.ts │ ├── readLogoSizing.ts │ ├── readNode.test.ts │ ├── readNode.ts │ ├── readNpmDefaults.test.ts │ ├── readNpmDefaults.ts │ ├── readOwner.test.ts │ ├── readOwner.ts │ ├── readPackageAuthor.test.ts │ ├── readPackageAuthor.ts │ ├── readPackageData.test.ts │ ├── readPackageData.ts │ ├── readPnpm.test.ts │ ├── readPnpm.ts │ ├── readReadmeAdditional.test.ts │ ├── readReadmeAdditional.ts │ ├── readReadmeExplainer.test.ts │ ├── readReadmeExplainer.ts │ ├── readReadmeFootnotes.test.ts │ ├── readReadmeFootnotes.ts │ ├── readReadmeUsage.test.ts │ ├── readReadmeUsage.ts │ ├── readRepository.test.ts │ ├── readRepository.ts │ ├── readRulesetId.test.ts │ ├── readRulesetId.ts │ ├── readTitle.test.ts │ ├── readTitle.ts │ ├── readWords.test.ts │ ├── readWords.ts │ └── readWorkflowsVersions.ts ├── presets │ ├── common.ts │ ├── everything.ts │ ├── index.ts │ └── minimal.ts ├── schemas.ts ├── template.ts ├── types.ts └── utils │ ├── htmlToTextSafe.ts │ ├── resolveBin.ts │ ├── swallowError.test.ts │ ├── swallowError.ts │ ├── swallowErrorAsync.test.ts │ ├── swallowErrorAsync.ts │ ├── trimPrecedingSlash.test.ts │ ├── trimPrecedingSlash.ts │ └── tryCatch.ts ├── tsconfig.json ├── tsup.config.ts └── vitest.config.ts /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: JoshuaKGoldberg 2 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | ## Overview 8 | 9 | ... 10 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/01-bug.yml: -------------------------------------------------------------------------------- 1 | body: 2 | - attributes: 3 | description: If any of these required steps are not taken, we may not be able to review your issue. Help us to help you! 4 | label: Bug Report Checklist 5 | options: 6 | - label: I have tried restarting my IDE and the issue persists. 7 | required: true 8 | - label: I have pulled the latest `main` branch of the repository. 9 | required: true 10 | - label: I have [searched for related issues](https://github.com/JoshuaKGoldberg/create-typescript-app/issues?q=is%3Aissue) and found none that matched my issue. 11 | required: true 12 | type: checkboxes 13 | - attributes: 14 | description: What did you expect to happen? 15 | label: Expected 16 | type: textarea 17 | validations: 18 | required: true 19 | - attributes: 20 | description: What happened instead? 21 | label: Actual 22 | type: textarea 23 | validations: 24 | required: true 25 | - attributes: 26 | description: Any additional info you'd like to provide. 27 | label: Additional Info 28 | type: textarea 29 | 30 | description: Report a bug trying to run the code 31 | 32 | labels: 33 | - "type: bug" 34 | 35 | name: 🐛 Bug 36 | 37 | title: "🐛 Bug: " 38 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/02-documentation.yml: -------------------------------------------------------------------------------- 1 | body: 2 | - attributes: 3 | description: If any of these required steps are not taken, we may not be able to review your issue. Help us to help you! 4 | label: Documentation Report Checklist 5 | options: 6 | - label: I have pulled the latest `main` branch of the repository. 7 | required: true 8 | - label: I have [searched for related issues](https://github.com/JoshuaKGoldberg/create-typescript-app/issues?q=is%3Aissue) and found none that matched my issue. 9 | required: true 10 | type: checkboxes 11 | - attributes: 12 | description: What would you like to report? 13 | label: Overview 14 | type: textarea 15 | validations: 16 | required: true 17 | - attributes: 18 | description: Any additional info you'd like to provide. 19 | label: Additional Info 20 | type: textarea 21 | 22 | description: Report a typo or missing area of documentation 23 | 24 | labels: 25 | - "area: documentation" 26 | 27 | name: 📝 Documentation 28 | 29 | title: "📝 Documentation: " 30 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/03-feature.yml: -------------------------------------------------------------------------------- 1 | body: 2 | - attributes: 3 | description: If any of these required steps are not taken, we may not be able to review your issue. Help us to help you! 4 | label: Feature Request Checklist 5 | options: 6 | - label: I have pulled the latest `main` branch of the repository. 7 | required: true 8 | - label: I have [searched for related issues](https://github.com/JoshuaKGoldberg/create-typescript-app/issues?q=is%3Aissue) and found none that matched my issue. 9 | required: true 10 | type: checkboxes 11 | - attributes: 12 | description: What did you expect to be able to do? 13 | label: Overview 14 | type: textarea 15 | validations: 16 | required: true 17 | - attributes: 18 | description: Any additional info you'd like to provide. 19 | label: Additional Info 20 | type: textarea 21 | 22 | description: Request that a new feature be added or an existing feature improved 23 | 24 | labels: 25 | - "type: feature" 26 | 27 | name: 🚀 Feature 28 | 29 | title: "🚀 Feature: " 30 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/04-tooling.yml: -------------------------------------------------------------------------------- 1 | body: 2 | - attributes: 3 | description: If any of these required steps are not taken, we may not be able to review your issue. Help us to help you! 4 | label: Tooling Report Checklist 5 | options: 6 | - label: I have tried restarting my IDE and the issue persists. 7 | required: true 8 | - label: I have pulled the latest `main` branch of the repository. 9 | required: true 10 | - label: I have [searched for related issues](https://github.com/JoshuaKGoldberg/create-typescript-app/issues?q=is%3Aissue) and found none that matched my issue. 11 | required: true 12 | type: checkboxes 13 | - attributes: 14 | description: What did you expect to be able to do? 15 | label: Overview 16 | type: textarea 17 | validations: 18 | required: true 19 | - attributes: 20 | description: Any additional info you'd like to provide. 21 | label: Additional Info 22 | type: textarea 23 | 24 | description: Report a bug or request an enhancement in repository tooling 25 | 26 | labels: 27 | - "area: tooling" 28 | 29 | name: 🛠 Tooling 30 | 31 | title: "🛠 Tooling: " 32 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | 4 | 5 | ## PR Checklist 6 | 7 | - [ ] Addresses an existing open issue: fixes #000 8 | - [ ] That issue was marked as [`status: accepting prs`](https://github.com/JoshuaKGoldberg/create-typescript-app/issues?q=is%3Aopen+is%3Aissue+label%3A%22status%3A+accepting+prs%22) 9 | - [ ] Steps in [CONTRIBUTING.md](https://github.com/JoshuaKGoldberg/create-typescript-app/blob/main/.github/CONTRIBUTING.md) were taken 10 | 11 | ## Overview 12 | 13 | 14 | -------------------------------------------------------------------------------- /.github/SECURITY.md: -------------------------------------------------------------------------------- 1 | # Security Policy 2 | 3 | We take all security vulnerabilities seriously. 4 | If you have a vulnerability or other security issues to disclose: 5 | 6 | - Thank you very much, please do! 7 | - Please send them to us by emailing `github@joshuakgoldberg.com` 8 | 9 | We appreciate your efforts and responsible disclosure and will make every effort to acknowledge your contributions. 10 | -------------------------------------------------------------------------------- /.github/actions/prepare/action.yml: -------------------------------------------------------------------------------- 1 | description: Prepares the repo for a typical CI job 2 | 3 | name: Prepare 4 | 5 | runs: 6 | steps: 7 | - uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda # v4 8 | - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 9 | with: 10 | cache: pnpm 11 | node-version: 22.14.0 12 | - run: pnpm install --frozen-lockfile 13 | shell: bash 14 | using: composite 15 | -------------------------------------------------------------------------------- /.github/renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://docs.renovatebot.com/renovate-schema.json", 3 | "automerge": true, 4 | "extends": [ 5 | ":preserveSemverRanges", 6 | "config:best-practices", 7 | "replacements:all" 8 | ], 9 | "ignoreDeps": ["all-contributors-cli", "codecov/codecov-action"], 10 | "labels": ["dependencies"], 11 | "minimumReleaseAge": "7 days", 12 | "patch": { "enabled": false }, 13 | "postUpdateOptions": ["pnpmDedupe"] 14 | } 15 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | jobs: 2 | are_the_types_wrong: 3 | name: Are The Types Wrong? 4 | runs-on: ubuntu-latest 5 | steps: 6 | - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 7 | - uses: ./.github/actions/prepare 8 | - run: pnpm build 9 | - run: npx --yes @arethetypeswrong/cli --pack . --ignore-rules cjs-resolves-to-esm 10 | build: 11 | name: Build 12 | runs-on: ubuntu-latest 13 | steps: 14 | - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 15 | - uses: ./.github/actions/prepare 16 | - run: pnpm build 17 | - run: node lib/index.js --version 18 | lint: 19 | name: Lint 20 | runs-on: ubuntu-latest 21 | steps: 22 | - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 23 | - uses: ./.github/actions/prepare 24 | - run: pnpm build 25 | - run: pnpm lint 26 | lint_knip: 27 | name: Lint Knip 28 | runs-on: ubuntu-latest 29 | steps: 30 | - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 31 | - uses: ./.github/actions/prepare 32 | - run: pnpm lint:knip 33 | lint_markdown: 34 | name: Lint Markdown 35 | runs-on: ubuntu-latest 36 | steps: 37 | - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 38 | - uses: ./.github/actions/prepare 39 | - run: pnpm lint:md 40 | lint_packages: 41 | name: Lint Packages 42 | runs-on: ubuntu-latest 43 | steps: 44 | - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 45 | - uses: ./.github/actions/prepare 46 | - run: pnpm lint:packages 47 | lint_spelling: 48 | name: Lint Spelling 49 | runs-on: ubuntu-latest 50 | steps: 51 | - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 52 | - uses: ./.github/actions/prepare 53 | - run: pnpm lint:spelling 54 | prettier: 55 | name: Prettier 56 | runs-on: ubuntu-latest 57 | steps: 58 | - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 59 | - uses: ./.github/actions/prepare 60 | - run: pnpm format --list-different 61 | test: 62 | name: Test 63 | runs-on: ubuntu-latest 64 | steps: 65 | - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 66 | - uses: ./.github/actions/prepare 67 | - run: pnpm run test --coverage 68 | - env: 69 | CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} 70 | if: always() 71 | uses: codecov/codecov-action@v3 72 | type_check: 73 | name: Type Check 74 | runs-on: ubuntu-latest 75 | steps: 76 | - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 77 | - uses: ./.github/actions/prepare 78 | - run: pnpm tsc 79 | 80 | name: CI 81 | 82 | on: 83 | pull_request: ~ 84 | push: 85 | branches: 86 | - main 87 | -------------------------------------------------------------------------------- /.github/workflows/contributors.yml: -------------------------------------------------------------------------------- 1 | jobs: 2 | contributors: 3 | runs-on: ubuntu-latest 4 | steps: 5 | - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 6 | with: 7 | fetch-depth: 0 8 | - uses: ./.github/actions/prepare 9 | - env: 10 | GITHUB_TOKEN: ${{ secrets.ACCESS_TOKEN }} 11 | uses: JoshuaKGoldberg/all-contributors-auto-action@944abe4387e751b5bbb38616cb25cf4a4ca998f2 # v0.5.0 12 | 13 | name: Contributors 14 | 15 | on: 16 | push: 17 | branches: 18 | - main 19 | -------------------------------------------------------------------------------- /.github/workflows/octoguide.yml: -------------------------------------------------------------------------------- 1 | jobs: 2 | octoguide: 3 | if: ${{ !endsWith(github.actor, '[bot]') }} 4 | runs-on: ubuntu-latest 5 | steps: 6 | - uses: JoshuaKGoldberg/octoguide@0.11.1 7 | with: 8 | config: strict 9 | github-token: ${{ secrets.GITHUB_TOKEN }} 10 | 11 | name: OctoGuide 12 | 13 | on: 14 | discussion: 15 | types: 16 | - created 17 | - edited 18 | discussion_comment: 19 | types: 20 | - created 21 | - deleted 22 | - edited 23 | issue_comment: 24 | types: 25 | - created 26 | - deleted 27 | - edited 28 | issues: 29 | types: 30 | - edited 31 | - opened 32 | pull_request_review_comment: 33 | types: 34 | - created 35 | - deleted 36 | - edited 37 | pull_request_target: 38 | types: 39 | - edited 40 | - opened 41 | 42 | permissions: 43 | discussions: write 44 | issues: write 45 | pull-requests: write 46 | -------------------------------------------------------------------------------- /.github/workflows/post-release.yml: -------------------------------------------------------------------------------- 1 | jobs: 2 | post_release: 3 | runs-on: ubuntu-latest 4 | steps: 5 | - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 6 | with: 7 | fetch-depth: 0 8 | - run: echo "npm_version=$(npm pkg get version | tr -d '"')" >> "$GITHUB_ENV" 9 | - uses: apexskier/github-release-commenter@3bd413ad5e1d603bfe2282f9f06f2bdcec079327 # v1 10 | with: 11 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 12 | comment-template: | 13 | :tada: This is included in version {release_link} :tada: 14 | 15 | The release is available on: 16 | 17 | * [GitHub releases](https://github.com/JoshuaKGoldberg/create-typescript-app/releases/tag/{release_tag}) 18 | * [npm package (@latest dist-tag)](https://www.npmjs.com/package/create-typescript-app/v/${{ env.npm_version }}) 19 | 20 | Cheers! 📦🚀 21 | 22 | name: Post Release 23 | 24 | on: 25 | release: 26 | types: 27 | - published 28 | 29 | permissions: 30 | issues: write 31 | pull-requests: write 32 | -------------------------------------------------------------------------------- /.github/workflows/pr-review-requested.yml: -------------------------------------------------------------------------------- 1 | jobs: 2 | pr_review_requested: 3 | runs-on: ubuntu-latest 4 | steps: 5 | - uses: actions-ecosystem/action-remove-labels@2ce5d41b4b6aa8503e285553f75ed56e0a40bae0 # v1 6 | with: 7 | labels: "status: waiting for author" 8 | - if: failure() 9 | run: | 10 | echo "Don't worry if the previous step failed." 11 | echo "See https://github.com/actions-ecosystem/action-remove-labels/issues/221." 12 | 13 | name: PR Review Requested 14 | 15 | on: 16 | pull_request_target: 17 | types: 18 | - review_requested 19 | 20 | permissions: 21 | pull-requests: write 22 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | concurrency: 2 | group: ${{ github.workflow }} 3 | 4 | jobs: 5 | release: 6 | runs-on: ubuntu-latest 7 | steps: 8 | - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 9 | with: 10 | fetch-depth: 0 11 | ref: main 12 | token: ${{ secrets.ACCESS_TOKEN }} 13 | - uses: ./.github/actions/prepare 14 | - run: pnpm build 15 | - env: 16 | GITHUB_TOKEN: ${{ secrets.ACCESS_TOKEN }} 17 | NPM_TOKEN: ${{ secrets.NPM_TOKEN }} 18 | uses: JoshuaKGoldberg/release-it-action@dc71f396c291f62f9a17701cfc4d4a3e7c263020 # v0.3.2 19 | 20 | name: Release 21 | 22 | on: 23 | push: 24 | branches: 25 | - main 26 | 27 | permissions: 28 | contents: write 29 | id-token: write 30 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /coverage 2 | /lib 3 | /node_modules 4 | -------------------------------------------------------------------------------- /.husky/.gitignore: -------------------------------------------------------------------------------- 1 | _ 2 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | npx lint-staged 2 | -------------------------------------------------------------------------------- /.markdownlint.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "markdownlint/style/prettier", 3 | "first-line-h1": false, 4 | "no-inline-html": false 5 | } 6 | -------------------------------------------------------------------------------- /.markdownlintignore: -------------------------------------------------------------------------------- 1 | .github/CODE_OF_CONDUCT.md 2 | CHANGELOG.md 3 | lib/ 4 | node_modules/ 5 | -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | 22.14.0 2 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | /.all-contributorsrc 2 | /.husky 3 | /coverage 4 | /lib 5 | /pnpm-lock.yaml 6 | -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "http://json.schemastore.org/prettierrc", 3 | "overrides": [{ "files": ".nvmrc", "options": { "parser": "yaml" } }], 4 | "plugins": [ 5 | "prettier-plugin-curly", 6 | "prettier-plugin-packagejson", 7 | "prettier-plugin-sh" 8 | ], 9 | "useTabs": true 10 | } 11 | -------------------------------------------------------------------------------- /.release-it.json: -------------------------------------------------------------------------------- 1 | { 2 | "git": { 3 | "commitMessage": "chore: release v${version}", 4 | "requireCommits": true 5 | }, 6 | "github": { "release": true, "releaseName": "v${version}" }, 7 | "npm": { "publishArgs": ["--access public", "--provenance"] }, 8 | "plugins": { 9 | "@release-it/conventional-changelog": { 10 | "infile": "CHANGELOG.md", 11 | "preset": "angular", 12 | "types": [ 13 | { "section": "Features", "type": "feat" }, 14 | { "section": "Bug Fixes", "type": "fix" }, 15 | { "section": "Performance Improvements", "type": "perf" }, 16 | { "hidden": true, "type": "build" }, 17 | { "hidden": true, "type": "chore" }, 18 | { "hidden": true, "type": "ci" }, 19 | { "hidden": true, "type": "docs" }, 20 | { "hidden": true, "type": "refactor" }, 21 | { "hidden": true, "type": "style" }, 22 | { "hidden": true, "type": "test" } 23 | ] 24 | } 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": [ 3 | "DavidAnson.vscode-markdownlint", 4 | "dbaeumer.vscode-eslint", 5 | "esbenp.prettier-vscode", 6 | "streetsidesoftware.code-spell-checker", 7 | "vitest.explorer" 8 | ] 9 | } 10 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | "configurations": [ 3 | { 4 | "args": ["run", "${relativeFile}"], 5 | "autoAttachChildProcesses": true, 6 | "console": "integratedTerminal", 7 | "name": "Debug Current Test File", 8 | "program": "${workspaceRoot}/node_modules/vitest/vitest.mjs", 9 | "request": "launch", 10 | "skipFiles": ["/**", "**/node_modules/**"], 11 | "smartStep": true, 12 | "type": "node" 13 | }, 14 | { 15 | "name": "Debug Program", 16 | "preLaunchTask": "build", 17 | "program": "bin/index.js", 18 | "request": "launch", 19 | "skipFiles": ["/**"], 20 | "type": "node" 21 | } 22 | ], 23 | "version": "0.2.0" 24 | } 25 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "editor.codeActionsOnSave": { "source.fixAll.eslint": "explicit" }, 3 | "editor.defaultFormatter": "esbenp.prettier-vscode", 4 | "editor.formatOnSave": true, 5 | "editor.rulers": [80], 6 | "eslint.probe": [ 7 | "javascript", 8 | "javascriptreact", 9 | "json", 10 | "jsonc", 11 | "markdown", 12 | "typescript", 13 | "typescriptreact", 14 | "yaml" 15 | ], 16 | "eslint.rules.customizations": [{ "rule": "*", "severity": "warn" }], 17 | "typescript.tsdk": "node_modules/typescript/lib" 18 | } 19 | -------------------------------------------------------------------------------- /.vscode/tasks.json: -------------------------------------------------------------------------------- 1 | { 2 | "tasks": [ 3 | { 4 | "detail": "Build the project", 5 | "label": "build", 6 | "script": "build", 7 | "type": "npm" 8 | } 9 | ], 10 | "version": "2.0.0" 11 | } 12 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | # MIT License 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining 4 | a copy of this software and associated documentation files (the 5 | 'Software'), to deal in the Software without restriction, including 6 | without limitation the rights to use, copy, modify, merge, publish, 7 | distribute, sublicense, and/or sell copies of the Software, and to 8 | permit persons to whom the Software is furnished to do so, subject to 9 | the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be 12 | included in all copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, 15 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 16 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. 17 | IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY 18 | CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, 19 | TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE 20 | SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /bin/index.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | import { runTemplateCLI } from "bingo"; 3 | 4 | import { template } from "../lib/index.js"; 5 | 6 | process.exitCode = await runTemplateCLI(template); 7 | -------------------------------------------------------------------------------- /cspell.json: -------------------------------------------------------------------------------- 1 | { 2 | "dictionaries": ["npm", "node", "typescript"], 3 | "ignorePaths": [ 4 | ".all-contributorsrc", 5 | ".github", 6 | "CHANGELOG.md", 7 | "coverage", 8 | "lib", 9 | "node_modules", 10 | "pnpm-lock.yaml" 11 | ], 12 | "words": [ 13 | "Anson", 14 | "TSESTree", 15 | "apexskier", 16 | "attw", 17 | "boop", 18 | "dbaeumer", 19 | "eslint-doc-generatorrc", 20 | "infile", 21 | "joshuakgoldberg", 22 | "markdownlintignore", 23 | "mshick", 24 | "npmjs", 25 | "octoguide", 26 | "stefanzweifel" 27 | ] 28 | } 29 | -------------------------------------------------------------------------------- /docs/Setup.md: -------------------------------------------------------------------------------- 1 | # Setup Mode 2 | 3 | You can run `npx create-typescript-app` in your terminal to interactively create a new repository: 4 | 5 | ```shell 6 | npx create-typescript-app 7 | ``` 8 | 9 | The setup script will by default: 10 | 11 | 1. Prompt you for a directory, which template preset to run with, and some starting information 12 | 2. Initialize new directory as a local Git repository 13 | 3. Copy the template's files to that directory 14 | 4. Create a new repository on GitHub and set it as the local repository's upstream 15 | 5. Configure relevant settings on the GitHub repository 16 | 17 | You'll then need to manually go through the following two steps to set up tooling on GitHub: 18 | 19 | 1. Create two tokens in [repository secrets](https://docs.github.com/en/actions/security-guides/encrypted-secrets) _(unless you chose to opt out of releases)_: 20 | - `ACCESS_TOKEN`: A [GitHub PAT](https://github.com/settings/tokens/new) with _repo_ and _workflow_ permissions 21 | - `NPM_TOKEN`: An [npm access token](https://docs.npmjs.com/creating-and-viewing-access-tokens/) with _Automation_ permissions 22 | 2. Install two GitHub apps: 23 | - [Codecov](https://github.com/marketplace/codecov) _(unless you chose to opt out of tests)_ 24 | - [Renovate](https://github.com/marketplace/renovate) _(unless you chose to opt out of renovate)_ 25 | 26 | Your new repository will then be ready for development! 27 | Hooray! 🥳 28 | 29 | ## Options 30 | 31 | You can customize which pieces of tooling are provided and the options they're created with. 32 | See [CLI.md](./CLI.md). 33 | 34 | For example, skipping the _"This package was templated with..."_ block: 35 | 36 | ```shell 37 | npx create-typescript-app --mode create --exclude-templated-with 38 | ``` 39 | 40 | See [Blocks.md](./Blocks.md) for details on the tooling pieces and which presets they're included in. 41 | -------------------------------------------------------------------------------- /docs/Transition.md: -------------------------------------------------------------------------------- 1 | # Transition Mode 2 | 3 | If you have an existing repository that you'd like to migrate to the files from this template, you can run `npx create-typescript-app` in it to "migrate" its tooling to this template's. 4 | 5 | ```shell 6 | npx create-typescript-app 7 | ``` 8 | 9 | The transition script will: 10 | 11 | - Uninstall any known old packages that conflict with this template's tooling 12 | - Delete configuration files used with those old packages 13 | - Install any packages needed for this template's tooling 14 | - Create or rewrite configuration files for the new tooling 15 | - Run ESLint and Prettier auto-fixers to align formatting and style to the new settings 16 | 17 | For example, if the repository previously using Jest for testing: 18 | 19 | - `eslint-plugin-jest`, `jest`, and other Jest-related packages will be uninstalled 20 | - Any Jest config file like `jest.config.js` will be deleted 21 | - `@vitest/eslint-plugin`, `vitest`, and other Vitest-related packages will be installed 22 | - A `vitest.config.ts` file will be created 23 | 24 | You'll then need to manually go through the following two steps to set up tooling on GitHub: 25 | 26 | 1. Create two tokens in [repository secrets](https://docs.github.com/en/actions/security-guides/encrypted-secrets) _(unless you chose to opt out of releases)_: 27 | - `ACCESS_TOKEN`: A [GitHub PAT](https://github.com/settings/tokens/new) with _repo_ and _workflow_ permissions 28 | - `NPM_TOKEN`: An [npm access token](https://docs.npmjs.com/creating-and-viewing-access-tokens/) with _Automation_ permissions 29 | 2. Install two GitHub apps: 30 | - [Codecov](https://github.com/marketplace/codecov) _(unless you chose to opt out of tests)_ 31 | - [Renovate](https://github.com/marketplace/renovate) _(unless you chose to opt out of renovate)_ 32 | 33 | Your repository will then have an approximate copy of this template's tooling ready for you to review! 34 | Hooray! 🥳 35 | 36 | > [!WARNING] 37 | > Migration will override many files in your repository. 38 | > You'll want to review each of the changes. 39 | > There will almost certainly be some incorrect changes you'll need to fix. 40 | 41 | ## CLI Options 42 | 43 | You can customize which pieces of tooling are provided and the options they're created with. 44 | See [CLI.md](./CLI.md). 45 | 46 | For example, skipping the _"This package was templated with..."_ block: 47 | 48 | ```shell 49 | npx create-typescript-app --exclude-templated-with 50 | ``` 51 | 52 | See [Blocks.md](./Blocks.md) for details on the tooling pieces and which presets they're included in. 53 | -------------------------------------------------------------------------------- /docs/UseThisTemplate.md: -------------------------------------------------------------------------------- 1 | # Using the Template Repository 2 | 3 | As an alternative to [creating with `npx create-typescript-app`](./Setup.md), the [_Use this template_](https://github.com/JoshuaKGoldberg/create-typescript-app/generate) button on GitHub can be used to quickly create a new repository from the template. 4 | You can set up the new repository locally by cloning it and installing packages: 5 | 6 | ```shell 7 | git clone https://github.com/YourUsername/YourRepositoryName 8 | cd YourRepositoryName 9 | npx create-typescript-app 10 | ``` 11 | 12 | You'll then need to manually go through the following two steps to set up tooling on GitHub: 13 | 14 | 1. Create two tokens in [repository secrets](https://docs.github.com/en/actions/security-guides/encrypted-secrets) _(unless you chose to opt out of releases)_: 15 | - `ACCESS_TOKEN`: A [GitHub PAT](https://github.com/settings/tokens/new) with _repo_ and _workflow_ permissions 16 | - `NPM_TOKEN`: An [npm access token](https://docs.npmjs.com/creating-and-viewing-access-tokens/) with _Automation_ permissions 17 | 2. Install two GitHub apps: 18 | - [Codecov](https://github.com/marketplace/codecov) _(unless you chose to opt out of tests)_ 19 | - [Renovate](https://github.com/marketplace/renovate) _(unless you chose to opt out of renovate)_ 20 | 21 | Your new repository will then be ready for development! 22 | Hooray! 🥳 23 | 24 | ## CLI Options 25 | 26 | You can customize which pieces of tooling are provided and the options they're created with. 27 | See [CLI.md](./CLI.md). 28 | 29 | For example, skipping the _"This package was templated with..."_ block: 30 | 31 | ```shell 32 | npx create-typescript-app --exclude-templated-with 33 | ``` 34 | 35 | See [Blocks.md](./Blocks.md) for details on the tooling pieces and which presets they're included in. 36 | -------------------------------------------------------------------------------- /docs/create-typescript-app.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/JoshuaKGoldberg/create-typescript-app/36cbb8aa21051b00bc753abde5be781d5203821f/docs/create-typescript-app.png -------------------------------------------------------------------------------- /knip.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://unpkg.com/knip@5.46.0/schema.json", 3 | "entry": ["src/**/*.test.*", "src/index.ts"], 4 | "ignoreDependencies": [ 5 | "all-contributors-cli", 6 | "cspell-populate-words", 7 | "remove-dependencies", 8 | "trash-cli" 9 | ], 10 | "ignoreExportsUsedInFile": { "interface": true, "type": true }, 11 | "project": ["src/**/*.ts"] 12 | } 13 | -------------------------------------------------------------------------------- /src/base.test.ts: -------------------------------------------------------------------------------- 1 | import { prepareOptions } from "bingo"; 2 | import { readFile } from "fs/promises"; 3 | import { describe, expect, test } from "vitest"; 4 | 5 | import { base } from "./base.js"; 6 | import { AllContributorsData } from "./types.js"; 7 | 8 | describe("base", () => { 9 | test("production from create-typescript-app", async () => { 10 | const options = await prepareOptions(base); 11 | 12 | expect(options).toEqual({ 13 | access: "public", 14 | author: "Josh Goldberg ✨", 15 | bin: "bin/index.js", 16 | contributors: ( 17 | JSON.parse( 18 | (await readFile(".all-contributorsrc")).toString(), 19 | ) as AllContributorsData 20 | ).contributors, 21 | description: 22 | "Quickstart-friendly TypeScript template with comprehensive, configurable, opinionated tooling. 🎁", 23 | documentation: { 24 | development: expect.any(String), 25 | readme: { 26 | additional: expect.any(String), 27 | explainer: [ 28 | `\`create-typescript-app\` is a one-stop-shop solution to set up a new or existing repository with the latest and greatest TypeScript tooling.`, 29 | `It includes options not just for building and testing but also automated release management, contributor recognition, GitHub repository settings, and more.`, 30 | ].join("\n"), 31 | usage: expect.any(String), 32 | }, 33 | }, 34 | email: { 35 | github: "github@joshuakgoldberg.com", 36 | npm: "npm@joshuakgoldberg.com", 37 | }, 38 | emoji: "🎁", 39 | existingLabels: expect.any(Array), 40 | funding: "JoshuaKGoldberg", 41 | guide: { 42 | href: "https://www.joshuakgoldberg.com/blog/contributing-to-a-create-typescript-app-repository", 43 | title: "Contributing to a create-typescript-app Repository", 44 | }, 45 | logo: { 46 | alt: "Project logo: the TypeScript blue square with rounded corners, but a plus sign instead of 'TS'", 47 | height: 128, 48 | src: "./docs/create-typescript-app.png", 49 | width: 128, 50 | }, 51 | node: { 52 | minimum: expect.any(String), 53 | pinned: expect.any(String), 54 | }, 55 | owner: "JoshuaKGoldberg", 56 | packageData: expect.any(Object), 57 | pnpm: expect.any(String), 58 | repository: "create-typescript-app", 59 | title: "Create TypeScript App", 60 | type: expect.any(String), 61 | version: expect.any(String), 62 | // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-require-imports 63 | words: require("../cspell.json").words, 64 | workflowsVersions: expect.any(Object), 65 | }); 66 | }); 67 | }); 68 | -------------------------------------------------------------------------------- /src/blocks/actions/resolveUses.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it } from "vitest"; 2 | 3 | import { resolveUses } from "./resolveUses.js"; 4 | 5 | describe(resolveUses, () => { 6 | it("returns action@version when workflowsVersions is undefined", () => { 7 | const actual = resolveUses("test-action", "v1.2.3"); 8 | 9 | expect(actual).toBe("test-action@v1.2.3"); 10 | }); 11 | 12 | it("returns action@version when workflowsVersions does not contain the action", () => { 13 | const actual = resolveUses("test-action", "v1.2.3", { other: {} }); 14 | 15 | expect(actual).toBe("test-action@v1.2.3"); 16 | }); 17 | 18 | it("uses the provided version when it is greater than all the action versions in workflowsVersions", () => { 19 | const actual = resolveUses("test-action", "v1.2.3", { 20 | "test-action": { 21 | "v0.1.2": { 22 | pinned: true, 23 | }, 24 | "v1.1.4": { 25 | pinned: true, 26 | }, 27 | }, 28 | }); 29 | 30 | expect(actual).toBe("test-action@v1.2.3"); 31 | }); 32 | 33 | it("prefers a provided valid semver version when an action also has a non-semver tag", () => { 34 | const actual = resolveUses("test-action", "v1.2.3", { 35 | "test-action": { 36 | main: { 37 | pinned: true, 38 | }, 39 | }, 40 | }); 41 | 42 | expect(actual).toBe("test-action@v1.2.3"); 43 | }); 44 | 45 | it("prefers an action's semver tag when the provided version is a non-semver tag", () => { 46 | const actual = resolveUses("test-action", "main", { 47 | "test-action": { 48 | "v1.2.3": { 49 | pinned: true, 50 | }, 51 | }, 52 | }); 53 | 54 | expect(actual).toBe("test-action@v1.2.3"); 55 | }); 56 | 57 | it("uses the greatest version when the provided version is not bigger than all the action versions in workflowsVersions", () => { 58 | const actual = resolveUses("test-action", "v1.2.3", { 59 | "test-action": { 60 | "v0.1.2": { 61 | pinned: true, 62 | }, 63 | "v1.3.5": { 64 | pinned: true, 65 | }, 66 | }, 67 | }); 68 | 69 | expect(actual).toBe("test-action@v1.3.5"); 70 | }); 71 | 72 | it("uses a pinned hash when the greatest version contains a hash", () => { 73 | const actual = resolveUses("test-action", "v1.2.3", { 74 | "test-action": { 75 | "v0.1.2": { 76 | pinned: true, 77 | }, 78 | "v1.3.5": { 79 | hash: "abc", 80 | pinned: true, 81 | }, 82 | }, 83 | }); 84 | 85 | expect(actual).toBe("test-action@abc # v1.3.5"); 86 | }); 87 | }); 88 | -------------------------------------------------------------------------------- /src/blocks/actions/resolveUses.ts: -------------------------------------------------------------------------------- 1 | import { CachedFactory } from "cached-factory"; 2 | import semver from "semver"; 3 | 4 | import { WorkflowsVersions } from "../../schemas.js"; 5 | 6 | const semverCoercions = new CachedFactory((version: string) => { 7 | return semver.coerce(version)?.toString() ?? "0.0.0"; 8 | }); 9 | 10 | export function resolveUses( 11 | action: string, 12 | version: string, 13 | workflowsVersions?: WorkflowsVersions, 14 | ) { 15 | if (!workflowsVersions || !(action in workflowsVersions)) { 16 | return `${action}@${version}`; 17 | } 18 | 19 | const workflowVersions = workflowsVersions[action]; 20 | 21 | const biggestVersion = Object.keys(workflowVersions).reduce( 22 | (highestVersion, potentialVersion) => 23 | semver.gt( 24 | semverCoercions.get(potentialVersion), 25 | semverCoercions.get(highestVersion), 26 | ) 27 | ? potentialVersion 28 | : highestVersion, 29 | version, 30 | ); 31 | 32 | if (!(biggestVersion in workflowVersions)) { 33 | return `${action}@${biggestVersion}`; 34 | } 35 | 36 | const atBiggestVersion = workflowVersions[biggestVersion]; 37 | 38 | return atBiggestVersion.hash 39 | ? `${action}@${atBiggestVersion.hash} # ${biggestVersion}` 40 | : `${action}@${biggestVersion}`; 41 | } 42 | -------------------------------------------------------------------------------- /src/blocks/actions/steps.ts: -------------------------------------------------------------------------------- 1 | import { IntakeDirectory } from "bingo-fs"; 2 | import _ from "lodash"; 3 | import { z } from "zod"; 4 | 5 | import { intakeFileAsYaml } from "../intake/intakeFileAsYaml.js"; 6 | 7 | export const zActionStep = z.intersection( 8 | z.object({ 9 | env: z.record(z.string(), z.string()).optional(), 10 | if: z.string().optional(), 11 | with: z.record(z.string(), z.string()).optional(), 12 | }), 13 | z.union([z.object({ run: z.string() }), z.object({ uses: z.string() })]), 14 | ); 15 | 16 | export interface JobOrRunStep { 17 | env?: Record; 18 | uses?: unknown; 19 | with?: Record; 20 | } 21 | 22 | export function intakeFileYamlSteps( 23 | files: IntakeDirectory, 24 | filePath: string[], 25 | ymlPath: string[], 26 | ) { 27 | const actionYml = intakeFileAsYaml(files, filePath); 28 | if (!actionYml) { 29 | return undefined; 30 | } 31 | 32 | const steps = _.get(actionYml, ymlPath) as JobOrRunStep[] | undefined; 33 | if (!steps || !Array.isArray(steps)) { 34 | return undefined; 35 | } 36 | 37 | return steps; 38 | } 39 | -------------------------------------------------------------------------------- /src/blocks/bin/getPrimaryBin.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, test } from "vitest"; 2 | 3 | import { getPrimaryBin } from "./getPrimaryBin.js"; 4 | 5 | const repository = "test-repository"; 6 | 7 | describe(getPrimaryBin, () => { 8 | test.each([ 9 | [undefined, undefined], 10 | ["bin/index.js", "bin/index.js"], 11 | [{ [repository]: "bin/index.js" }, "bin/index.js"], 12 | [{}, undefined], 13 | [{ other: "bin/index.js" }, undefined], 14 | ])("%j", (bin, expected) => { 15 | expect(getPrimaryBin(bin, repository)).toBe(expected); 16 | }); 17 | }); 18 | -------------------------------------------------------------------------------- /src/blocks/bin/getPrimaryBin.ts: -------------------------------------------------------------------------------- 1 | export function getPrimaryBin( 2 | bin: Record | string | undefined, 3 | repository: string, 4 | ) { 5 | return typeof bin === "object" ? bin[repository] : bin; 6 | } 7 | -------------------------------------------------------------------------------- /src/blocks/blockAreTheTypesWrong.test.ts: -------------------------------------------------------------------------------- 1 | import { testBlock } from "bingo-stratum-testers"; 2 | import { describe, expect, test } from "vitest"; 3 | 4 | import { blockAreTheTypesWrong } from "./blockAreTheTypesWrong.js"; 5 | import { optionsBase } from "./options.fakes.js"; 6 | 7 | describe("blockAreTheTypesWrong", () => { 8 | test("production", () => { 9 | const creation = testBlock(blockAreTheTypesWrong, { 10 | options: optionsBase, 11 | }); 12 | 13 | expect(creation).toMatchInlineSnapshot(` 14 | { 15 | "addons": [ 16 | { 17 | "addons": { 18 | "jobs": [ 19 | { 20 | "name": "Are The Types Wrong?", 21 | "steps": [ 22 | { 23 | "run": "pnpm build", 24 | }, 25 | { 26 | "run": "npx --yes @arethetypeswrong/cli --pack . --ignore-rules cjs-resolves-to-esm", 27 | }, 28 | ], 29 | }, 30 | ], 31 | }, 32 | "block": [Function], 33 | }, 34 | ], 35 | } 36 | `); 37 | }); 38 | }); 39 | -------------------------------------------------------------------------------- /src/blocks/blockAreTheTypesWrong.ts: -------------------------------------------------------------------------------- 1 | import { base } from "../base.js"; 2 | import { blockGitHubActionsCI } from "./blockGitHubActionsCI.js"; 3 | 4 | export const blockAreTheTypesWrong = base.createBlock({ 5 | about: { 6 | name: "Are The Types Wrong", 7 | }, 8 | produce() { 9 | return { 10 | addons: [ 11 | blockGitHubActionsCI({ 12 | jobs: [ 13 | { 14 | name: "Are The Types Wrong?", 15 | steps: [ 16 | { run: "pnpm build" }, 17 | { 18 | run: "npx --yes @arethetypeswrong/cli --pack . --ignore-rules cjs-resolves-to-esm", 19 | }, 20 | ], 21 | }, 22 | ], 23 | }), 24 | ], 25 | }; 26 | }, 27 | }); 28 | -------------------------------------------------------------------------------- /src/blocks/blockCodecov.ts: -------------------------------------------------------------------------------- 1 | import { z } from "zod"; 2 | 3 | import { base } from "../base.js"; 4 | import { resolveUses } from "./actions/resolveUses.js"; 5 | import { intakeFileYamlSteps } from "./actions/steps.js"; 6 | import { blockGitHubApps } from "./blockGitHubApps.js"; 7 | import { blockREADME } from "./blockREADME.js"; 8 | import { blockRemoveFiles } from "./blockRemoveFiles.js"; 9 | import { blockVitest } from "./blockVitest.js"; 10 | 11 | export const blockCodecov = base.createBlock({ 12 | about: { 13 | name: "Codecov", 14 | }, 15 | addons: { 16 | env: z.record(z.string(), z.string()).optional(), 17 | }, 18 | intake({ files }) { 19 | const steps = intakeFileYamlSteps( 20 | files, 21 | [".github", "workflows", "ci.yml"], 22 | ["jobs", "test", "steps"], 23 | ); 24 | if (!steps) { 25 | return undefined; 26 | } 27 | 28 | const step = steps.find( 29 | (step) => 30 | typeof step.uses === "string" && 31 | step.uses.startsWith("codecov/codecov-action"), 32 | ); 33 | if (!step) { 34 | return undefined; 35 | } 36 | 37 | return { 38 | env: step.env, 39 | }; 40 | }, 41 | produce({ addons, options }) { 42 | const { env } = addons; 43 | return { 44 | addons: [ 45 | blockGitHubApps({ 46 | apps: [ 47 | { 48 | name: "Codecov", 49 | url: "https://github.com/apps/codecov", 50 | }, 51 | ], 52 | }), 53 | blockREADME({ 54 | badges: [ 55 | { 56 | alt: "🧪 Coverage", 57 | href: `https://codecov.io/gh/${options.owner}/${options.repository}`, 58 | src: `https://img.shields.io/codecov/c/github/${options.owner}/${options.repository}?label=%F0%9F%A7%AA%20coverage`, 59 | }, 60 | ], 61 | }), 62 | blockVitest({ 63 | actionSteps: [ 64 | { 65 | ...(env && { env }), 66 | if: "always()", 67 | uses: resolveUses( 68 | "codecov/codecov-action", 69 | "v3", 70 | options.workflowsVersions, 71 | ), 72 | }, 73 | ], 74 | }), 75 | ], 76 | }; 77 | }, 78 | transition() { 79 | return { 80 | addons: [ 81 | blockRemoveFiles({ files: [".github/codecov.yml", "codecov.yml"] }), 82 | ], 83 | }; 84 | }, 85 | }); 86 | -------------------------------------------------------------------------------- /src/blocks/blockESLintComments.ts: -------------------------------------------------------------------------------- 1 | import { base } from "../base.js"; 2 | import { blockESLint } from "./blockESLint.js"; 3 | 4 | export const blockESLintComments = base.createBlock({ 5 | about: { 6 | name: "ESLint Comments Plugin", 7 | }, 8 | produce() { 9 | return { 10 | addons: [ 11 | blockESLint({ 12 | extensions: ["comments.recommended"], 13 | imports: [ 14 | { 15 | source: "@eslint-community/eslint-plugin-eslint-comments/configs", 16 | specifier: "comments", 17 | }, 18 | ], 19 | }), 20 | ], 21 | }; 22 | }, 23 | }); 24 | -------------------------------------------------------------------------------- /src/blocks/blockESLintJSDoc.ts: -------------------------------------------------------------------------------- 1 | import { base } from "../base.js"; 2 | import { blockESLint } from "./blockESLint.js"; 3 | 4 | export const blockESLintJSDoc = base.createBlock({ 5 | about: { 6 | name: "ESLint JSDoc Plugin", 7 | }, 8 | produce() { 9 | return { 10 | addons: [ 11 | blockESLint({ 12 | extensions: [ 13 | 'jsdoc.configs["flat/contents-typescript-error"]', 14 | 'jsdoc.configs["flat/logical-typescript-error"]', 15 | 'jsdoc.configs["flat/stylistic-typescript-error"]', 16 | ], 17 | imports: [{ source: "eslint-plugin-jsdoc", specifier: "jsdoc" }], 18 | }), 19 | ], 20 | }; 21 | }, 22 | }); 23 | -------------------------------------------------------------------------------- /src/blocks/blockESLintJSONC.ts: -------------------------------------------------------------------------------- 1 | import { base } from "../base.js"; 2 | import { blockESLint } from "./blockESLint.js"; 3 | 4 | export const blockESLintJSONC = base.createBlock({ 5 | about: { 6 | name: "ESLint JSONC Plugin", 7 | }, 8 | produce() { 9 | return { 10 | addons: [ 11 | blockESLint({ 12 | extensions: [`jsonc.configs["flat/recommended-with-json"]`], 13 | imports: [{ source: "eslint-plugin-jsonc", specifier: "jsonc" }], 14 | }), 15 | ], 16 | }; 17 | }, 18 | }); 19 | -------------------------------------------------------------------------------- /src/blocks/blockESLintMarkdown.ts: -------------------------------------------------------------------------------- 1 | import { base } from "../base.js"; 2 | import { blockESLint } from "./blockESLint.js"; 3 | 4 | export const blockESLintMarkdown = base.createBlock({ 5 | about: { 6 | name: "ESLint Markdown Plugin", 7 | }, 8 | produce() { 9 | return { 10 | addons: [ 11 | blockESLint({ 12 | extensions: ["markdown.configs.recommended"], 13 | imports: [ 14 | { 15 | source: "eslint-plugin-markdown", 16 | specifier: "markdown", 17 | types: true, 18 | }, 19 | ], 20 | }), 21 | ], 22 | }; 23 | }, 24 | }); 25 | -------------------------------------------------------------------------------- /src/blocks/blockESLintMoreStyling.ts: -------------------------------------------------------------------------------- 1 | import { base } from "../base.js"; 2 | import { blockESLint } from "./blockESLint.js"; 3 | 4 | export const stylisticComment = 5 | "Stylistic concerns that don't interfere with Prettier"; 6 | 7 | export const blockESLintMoreStyling = base.createBlock({ 8 | about: { 9 | name: "ESLint More Styling", 10 | }, 11 | produce() { 12 | return { 13 | addons: [ 14 | blockESLint({ 15 | rules: [ 16 | { 17 | comment: stylisticComment, 18 | entries: { 19 | "logical-assignment-operators": [ 20 | "error", 21 | "always", 22 | { enforceForIfStatements: true }, 23 | ], 24 | "no-useless-rename": "error", 25 | "object-shorthand": "error", 26 | "operator-assignment": "error", 27 | }, 28 | }, 29 | ], 30 | }), 31 | ], 32 | }; 33 | }, 34 | }); 35 | -------------------------------------------------------------------------------- /src/blocks/blockESLintNode.test.ts: -------------------------------------------------------------------------------- 1 | import { testBlock } from "bingo-stratum-testers"; 2 | import { describe, expect, test, vi } from "vitest"; 3 | 4 | import { blockESLintNode } from "./blockESLintNode.js"; 5 | import { optionsBase } from "./options.fakes.js"; 6 | 7 | vi.mock("../utils/resolveBin.js", () => ({ 8 | resolveBin: (bin: string) => `path/to/${bin}`, 9 | })); 10 | 11 | describe("blockESLintNode", () => { 12 | test("production", () => { 13 | const creation = testBlock(blockESLintNode, { 14 | options: optionsBase, 15 | }); 16 | 17 | expect(creation).toMatchInlineSnapshot(` 18 | { 19 | "addons": [ 20 | { 21 | "addons": { 22 | "extensions": [ 23 | "n.configs["flat/recommended"]", 24 | { 25 | "extends": [ 26 | "tseslint.configs.disableTypeChecked", 27 | ], 28 | "files": [ 29 | "**/*.md/*.ts", 30 | ], 31 | "rules": { 32 | "n/no-missing-import": "off", 33 | }, 34 | }, 35 | ], 36 | "imports": [ 37 | { 38 | "source": "eslint-plugin-n", 39 | "specifier": "n", 40 | }, 41 | ], 42 | }, 43 | "block": [Function], 44 | }, 45 | ], 46 | } 47 | `); 48 | }); 49 | }); 50 | -------------------------------------------------------------------------------- /src/blocks/blockESLintNode.ts: -------------------------------------------------------------------------------- 1 | import { base } from "../base.js"; 2 | import { blockESLint } from "./blockESLint.js"; 3 | 4 | export const blockESLintNode = base.createBlock({ 5 | about: { 6 | name: "ESLint Node Plugin", 7 | }, 8 | produce() { 9 | return { 10 | addons: [ 11 | blockESLint({ 12 | extensions: [ 13 | 'n.configs["flat/recommended"]', 14 | { 15 | extends: ["tseslint.configs.disableTypeChecked"], 16 | files: ["**/*.md/*.ts"], 17 | rules: { "n/no-missing-import": "off" }, 18 | }, 19 | ], 20 | imports: [{ source: "eslint-plugin-n", specifier: "n" }], 21 | }), 22 | ], 23 | }; 24 | }, 25 | }); 26 | -------------------------------------------------------------------------------- /src/blocks/blockESLintPackageJson.test.ts: -------------------------------------------------------------------------------- 1 | import { testBlock } from "bingo-stratum-testers"; 2 | import { describe, expect, test } from "vitest"; 3 | 4 | import { blockESLintPackageJson } from "./blockESLintPackageJson.js"; 5 | import { optionsBase } from "./options.fakes.js"; 6 | 7 | describe("blockESLintPackageJson", () => { 8 | test("without mode", () => { 9 | const creation = testBlock(blockESLintPackageJson, { 10 | options: optionsBase, 11 | }); 12 | 13 | expect(creation).toMatchInlineSnapshot(` 14 | { 15 | "addons": [ 16 | { 17 | "addons": { 18 | "extensions": [ 19 | "packageJson.configs.recommended", 20 | ], 21 | "imports": [ 22 | { 23 | "source": "eslint-plugin-package-json", 24 | "specifier": "packageJson", 25 | }, 26 | ], 27 | }, 28 | "block": [Function], 29 | }, 30 | { 31 | "addons": { 32 | "properties": { 33 | "scripts": { 34 | "lint:package-json": undefined, 35 | }, 36 | }, 37 | }, 38 | "block": [Function], 39 | }, 40 | ], 41 | } 42 | `); 43 | }); 44 | 45 | test("transition mode", () => { 46 | const creation = testBlock(blockESLintPackageJson, { 47 | mode: "transition", 48 | options: optionsBase, 49 | }); 50 | 51 | expect(creation).toMatchInlineSnapshot(` 52 | { 53 | "addons": [ 54 | { 55 | "addons": { 56 | "extensions": [ 57 | "packageJson.configs.recommended", 58 | ], 59 | "imports": [ 60 | { 61 | "source": "eslint-plugin-package-json", 62 | "specifier": "packageJson", 63 | }, 64 | ], 65 | }, 66 | "block": [Function], 67 | }, 68 | { 69 | "addons": { 70 | "properties": { 71 | "scripts": { 72 | "lint:package-json": undefined, 73 | }, 74 | }, 75 | }, 76 | "block": [Function], 77 | }, 78 | { 79 | "addons": { 80 | "files": [ 81 | ".npmpackagejsonlintrc*", 82 | ], 83 | }, 84 | "block": [Function], 85 | }, 86 | { 87 | "addons": { 88 | "dependencies": [ 89 | "npm-package-json-lint", 90 | "npm-package-json-lint-config-default", 91 | ], 92 | }, 93 | "block": [Function], 94 | }, 95 | { 96 | "addons": { 97 | "workflows": [ 98 | "lint-package-json", 99 | ], 100 | }, 101 | "block": [Function], 102 | }, 103 | ], 104 | } 105 | `); 106 | }); 107 | }); 108 | -------------------------------------------------------------------------------- /src/blocks/blockESLintPackageJson.ts: -------------------------------------------------------------------------------- 1 | import { base } from "../base.js"; 2 | import { blockESLint } from "./blockESLint.js"; 3 | import { blockPackageJson } from "./blockPackageJson.js"; 4 | import { blockRemoveDependencies } from "./blockRemoveDependencies.js"; 5 | import { blockRemoveFiles } from "./blockRemoveFiles.js"; 6 | import { blockRemoveWorkflows } from "./blockRemoveWorkflows.js"; 7 | 8 | export const blockESLintPackageJson = base.createBlock({ 9 | about: { 10 | name: "ESLint package.json Plugin", 11 | }, 12 | produce() { 13 | return { 14 | addons: [ 15 | blockESLint({ 16 | extensions: ["packageJson.configs.recommended"], 17 | imports: [ 18 | { 19 | source: "eslint-plugin-package-json", 20 | specifier: "packageJson", 21 | }, 22 | ], 23 | }), 24 | blockPackageJson({ 25 | properties: { 26 | scripts: { 27 | "lint:package-json": undefined, 28 | }, 29 | }, 30 | }), 31 | ], 32 | }; 33 | }, 34 | transition() { 35 | return { 36 | addons: [ 37 | blockRemoveFiles({ 38 | files: [".npmpackagejsonlintrc*"], 39 | }), 40 | blockRemoveDependencies({ 41 | dependencies: [ 42 | "npm-package-json-lint", 43 | "npm-package-json-lint-config-default", 44 | ], 45 | }), 46 | blockRemoveWorkflows({ 47 | workflows: ["lint-package-json"], 48 | }), 49 | ], 50 | }; 51 | }, 52 | }); 53 | -------------------------------------------------------------------------------- /src/blocks/blockESLintPerfectionist.ts: -------------------------------------------------------------------------------- 1 | import { base } from "../base.js"; 2 | import { blockESLint } from "./blockESLint.js"; 3 | 4 | export const blockESLintPerfectionist = base.createBlock({ 5 | about: { 6 | name: "ESLint Perfectionist Plugin", 7 | }, 8 | produce() { 9 | return { 10 | addons: [ 11 | blockESLint({ 12 | extensions: [`perfectionist.configs["recommended-natural"]`], 13 | imports: [ 14 | { 15 | source: "eslint-plugin-perfectionist", 16 | specifier: "perfectionist", 17 | }, 18 | ], 19 | settings: { 20 | perfectionist: { 21 | partitionByComment: true, 22 | type: "natural", 23 | }, 24 | }, 25 | }), 26 | ], 27 | }; 28 | }, 29 | }); 30 | -------------------------------------------------------------------------------- /src/blocks/blockESLintRegexp.ts: -------------------------------------------------------------------------------- 1 | import { base } from "../base.js"; 2 | import { blockESLint } from "./blockESLint.js"; 3 | 4 | export const blockESLintRegexp = base.createBlock({ 5 | about: { 6 | name: "ESLint Regexp Plugin", 7 | }, 8 | produce() { 9 | return { 10 | addons: [ 11 | blockESLint({ 12 | extensions: [`regexp.configs["flat/recommended"]`], 13 | imports: [ 14 | { source: "eslint-plugin-regexp", specifier: "* as regexp" }, 15 | ], 16 | }), 17 | ], 18 | }; 19 | }, 20 | }); 21 | -------------------------------------------------------------------------------- /src/blocks/blockESLintYML.test.ts: -------------------------------------------------------------------------------- 1 | import { testBlock } from "bingo-stratum-testers"; 2 | import { describe, expect, test } from "vitest"; 3 | 4 | import { blockESLintYML } from "./blockESLintYML.js"; 5 | import { optionsBase } from "./options.fakes.js"; 6 | 7 | describe("blockESLintYML", () => { 8 | test("production", () => { 9 | const creation = testBlock(blockESLintYML, { 10 | options: optionsBase, 11 | }); 12 | 13 | expect(creation).toMatchInlineSnapshot(` 14 | { 15 | "addons": [ 16 | { 17 | "addons": { 18 | "extensions": [ 19 | { 20 | "extends": [ 21 | "yml.configs["flat/standard"]", 22 | "yml.configs["flat/prettier"]", 23 | ], 24 | "files": [ 25 | "**/*.{yml,yaml}", 26 | ], 27 | "rules": { 28 | "yml/file-extension": [ 29 | "error", 30 | { 31 | "extension": "yml", 32 | }, 33 | ], 34 | "yml/sort-keys": [ 35 | "error", 36 | { 37 | "order": { 38 | "type": "asc", 39 | }, 40 | "pathPattern": "^.*$", 41 | }, 42 | ], 43 | "yml/sort-sequence-values": [ 44 | "error", 45 | { 46 | "order": { 47 | "type": "asc", 48 | }, 49 | "pathPattern": "^.*$", 50 | }, 51 | ], 52 | }, 53 | }, 54 | ], 55 | "imports": [ 56 | { 57 | "source": "eslint-plugin-yml", 58 | "specifier": "yml", 59 | }, 60 | ], 61 | }, 62 | "block": [Function], 63 | }, 64 | ], 65 | } 66 | `); 67 | }); 68 | }); 69 | -------------------------------------------------------------------------------- /src/blocks/blockESLintYML.ts: -------------------------------------------------------------------------------- 1 | import { base } from "../base.js"; 2 | import { blockESLint } from "./blockESLint.js"; 3 | 4 | export const blockESLintYML = base.createBlock({ 5 | about: { 6 | name: "ESLint YML Plugin", 7 | }, 8 | produce() { 9 | return { 10 | addons: [ 11 | blockESLint({ 12 | extensions: [ 13 | { 14 | extends: [ 15 | 'yml.configs["flat/standard"]', 16 | 'yml.configs["flat/prettier"]', 17 | ], 18 | files: ["**/*.{yml,yaml}"], 19 | rules: { 20 | "yml/file-extension": ["error", { extension: "yml" }], 21 | "yml/sort-keys": [ 22 | "error", 23 | { 24 | order: { type: "asc" }, 25 | pathPattern: "^.*$", 26 | }, 27 | ], 28 | "yml/sort-sequence-values": [ 29 | "error", 30 | { 31 | order: { type: "asc" }, 32 | pathPattern: "^.*$", 33 | }, 34 | ], 35 | }, 36 | }, 37 | ], 38 | imports: [{ source: "eslint-plugin-yml", specifier: "yml" }], 39 | }), 40 | ], 41 | }; 42 | }, 43 | }); 44 | -------------------------------------------------------------------------------- /src/blocks/blockExampleFiles.test.ts: -------------------------------------------------------------------------------- 1 | import { testBlock } from "bingo-stratum-testers"; 2 | import { describe, expect, test } from "vitest"; 3 | 4 | import { blockExampleFiles } from "./blockExampleFiles.js"; 5 | import { optionsBase } from "./options.fakes.js"; 6 | 7 | describe("blockExampleFiles", () => { 8 | test("without addons.files", () => { 9 | const creation = testBlock(blockExampleFiles, { 10 | addons: {}, 11 | options: optionsBase, 12 | }); 13 | 14 | expect(creation).toMatchInlineSnapshot(`{}`); 15 | }); 16 | 17 | test("with addons.files and without mode", () => { 18 | const creation = testBlock(blockExampleFiles, { 19 | addons: { 20 | files: { 21 | "index.ts": "console.log('Hello, world!');", 22 | }, 23 | }, 24 | options: optionsBase, 25 | }); 26 | 27 | expect(creation).toMatchInlineSnapshot(`{}`); 28 | }); 29 | 30 | test("with addons.files and mode: setup", () => { 31 | const creation = testBlock(blockExampleFiles, { 32 | addons: { 33 | files: { 34 | "index.ts": "console.log('Hello, world!');", 35 | }, 36 | }, 37 | mode: "setup", 38 | options: optionsBase, 39 | }); 40 | 41 | expect(creation).toMatchInlineSnapshot(` 42 | { 43 | "addons": [ 44 | { 45 | "addons": { 46 | "defaultUsage": undefined, 47 | }, 48 | "block": [Function], 49 | }, 50 | ], 51 | "files": { 52 | "src": { 53 | "index.ts": "console.log('Hello, world!');", 54 | }, 55 | }, 56 | } 57 | `); 58 | }); 59 | }); 60 | -------------------------------------------------------------------------------- /src/blocks/blockExampleFiles.ts: -------------------------------------------------------------------------------- 1 | import { z } from "zod"; 2 | 3 | import { base } from "../base.js"; 4 | import { blockREADME } from "./blockREADME.js"; 5 | 6 | export const blockExampleFiles = base.createBlock({ 7 | about: { 8 | name: "Example Files", 9 | }, 10 | addons: { 11 | files: z.record(z.string()).default({}), 12 | usage: z.array(z.string()).default([]), 13 | }, 14 | setup({ addons }) { 15 | const { usage } = addons; 16 | 17 | return { 18 | addons: [ 19 | blockREADME({ 20 | defaultUsage: usage, 21 | }), 22 | ], 23 | files: { 24 | src: addons.files, 25 | }, 26 | }; 27 | }, 28 | // TODO: Make produce() optional, so this empty-ish produce() can be removed 29 | // https://github.com/JoshuaKGoldberg/bingo/issues/295 30 | produce() { 31 | return {}; 32 | }, 33 | }); 34 | -------------------------------------------------------------------------------- /src/blocks/blockFunding.ts: -------------------------------------------------------------------------------- 1 | import { base } from "../base.js"; 2 | import { formatYaml } from "./files/formatYaml.js"; 3 | 4 | export const blockFunding = base.createBlock({ 5 | about: { 6 | name: "Funding", 7 | }, 8 | produce({ options }) { 9 | return { 10 | files: { 11 | ".github": { 12 | "FUNDING.yml": 13 | options.funding && formatYaml({ github: options.funding }), 14 | }, 15 | }, 16 | }; 17 | }, 18 | }); 19 | -------------------------------------------------------------------------------- /src/blocks/blockGitHubApps.test.ts: -------------------------------------------------------------------------------- 1 | import { testBlock } from "bingo-stratum-testers"; 2 | import { describe, expect, test } from "vitest"; 3 | 4 | import { blockGitHubApps } from "./blockGitHubApps.js"; 5 | import { optionsBase } from "./options.fakes.js"; 6 | 7 | describe("blockGitHubApps", () => { 8 | test("without addons", () => { 9 | const creation = testBlock(blockGitHubApps, { 10 | options: optionsBase, 11 | }); 12 | 13 | expect(creation).toMatchInlineSnapshot(` 14 | { 15 | "suggestions": undefined, 16 | } 17 | `); 18 | }); 19 | 20 | test("with addons", () => { 21 | const creation = testBlock(blockGitHubApps, { 22 | addons: { 23 | apps: [ 24 | { 25 | name: "Secret A.", 26 | url: "https://example.com?a", 27 | }, 28 | { 29 | name: "Secret B.", 30 | url: "https://example.com?b", 31 | }, 32 | ], 33 | }, 34 | options: optionsBase, 35 | }); 36 | 37 | expect(creation).toMatchInlineSnapshot(` 38 | { 39 | "suggestions": [ 40 | "- enable the GitHub apps on https://github.com/test-owner/test-repository/settings/installations: 41 | - Secret A. (https://example.com?a) 42 | - Secret B. (https://example.com?b)", 43 | ], 44 | } 45 | `); 46 | }); 47 | }); 48 | -------------------------------------------------------------------------------- /src/blocks/blockGitHubApps.ts: -------------------------------------------------------------------------------- 1 | import { z } from "zod"; 2 | 3 | import { base } from "../base.js"; 4 | import { getInstallationSuggestions } from "./getInstallationSuggestions.js"; 5 | 6 | export const blockGitHubApps = base.createBlock({ 7 | about: { 8 | name: "GitHub Apps", 9 | }, 10 | addons: { 11 | apps: z 12 | .array( 13 | z.object({ 14 | name: z.string(), 15 | url: z.string(), 16 | }), 17 | ) 18 | .default([]), 19 | }, 20 | produce({ addons, options }) { 21 | return { 22 | suggestions: getInstallationSuggestions( 23 | "enable the GitHub app", 24 | addons.apps.map((app) => `${app.name} (${app.url})`), 25 | `https://github.com/${options.owner}/${options.repository}/settings/installations`, 26 | ), 27 | }; 28 | }, 29 | }); 30 | -------------------------------------------------------------------------------- /src/blocks/blockGitHubPRTemplate.ts: -------------------------------------------------------------------------------- 1 | import { base } from "../base.js"; 2 | 3 | export const blockGitHubPRTemplate = base.createBlock({ 4 | about: { 5 | name: "GitHub Issue Templates", 6 | }, 7 | produce({ options }) { 8 | return { 9 | files: { 10 | ".github": { 11 | "PULL_REQUEST_TEMPLATE.md": ` 14 | 15 | ## PR Checklist 16 | 17 | - [ ] Addresses an existing open issue: fixes #000 18 | - [ ] That issue was marked as [\`status: accepting prs\`](https://github.com/${options.owner}/${options.repository}/issues?q=is%3Aopen+is%3Aissue+label%3A%22status%3A+accepting+prs%22) 19 | - [ ] Steps in [CONTRIBUTING.md](https://github.com/${options.owner}/${options.repository}/blob/main/.github/CONTRIBUTING.md) were taken 20 | 21 | ## Overview 22 | 23 | 24 | `, 25 | }, 26 | }, 27 | }; 28 | }, 29 | }); 30 | -------------------------------------------------------------------------------- /src/blocks/blockGitignore.test.ts: -------------------------------------------------------------------------------- 1 | import { testBlock } from "bingo-stratum-testers"; 2 | import { describe, expect, test } from "vitest"; 3 | 4 | import { blockGitignore } from "./blockGitignore.js"; 5 | import { optionsBase } from "./options.fakes.js"; 6 | 7 | describe("blockGitignore", () => { 8 | test("without addons", () => { 9 | const creation = testBlock(blockGitignore, { 10 | options: optionsBase, 11 | }); 12 | 13 | expect(creation).toMatchInlineSnapshot(` 14 | { 15 | "files": { 16 | ".gitignore": "/node_modules 17 | ", 18 | }, 19 | } 20 | `); 21 | }); 22 | 23 | test("with addons", () => { 24 | const creation = testBlock(blockGitignore, { 25 | addons: { 26 | ignores: ["/lib"], 27 | }, 28 | options: optionsBase, 29 | }); 30 | 31 | expect(creation).toMatchInlineSnapshot(` 32 | { 33 | "files": { 34 | ".gitignore": "/lib 35 | /node_modules 36 | ", 37 | }, 38 | } 39 | `); 40 | }); 41 | }); 42 | -------------------------------------------------------------------------------- /src/blocks/blockGitignore.ts: -------------------------------------------------------------------------------- 1 | import { z } from "zod"; 2 | 3 | import { base } from "../base.js"; 4 | import { formatIgnoreFile } from "./files/formatIgnoreFile.js"; 5 | 6 | export const blockGitignore = base.createBlock({ 7 | about: { 8 | name: "Gitignore", 9 | }, 10 | addons: { 11 | ignores: z.array(z.string()).default([]), 12 | }, 13 | produce({ addons }) { 14 | const { ignores } = addons; 15 | 16 | return { 17 | files: { 18 | ".gitignore": formatIgnoreFile(["/node_modules", ...ignores].sort()), 19 | }, 20 | }; 21 | }, 22 | }); 23 | -------------------------------------------------------------------------------- /src/blocks/blockKnip.ts: -------------------------------------------------------------------------------- 1 | import removeUndefinedObjects from "remove-undefined-objects"; 2 | import { z } from "zod"; 3 | 4 | import { base } from "../base.js"; 5 | import { 6 | getPackageDependencies, 7 | getPackageDependency, 8 | } from "../data/packageData.js"; 9 | import { blockDevelopmentDocs } from "./blockDevelopmentDocs.js"; 10 | import { blockGitHubActionsCI } from "./blockGitHubActionsCI.js"; 11 | import { blockPackageJson } from "./blockPackageJson.js"; 12 | import { blockRemoveFiles } from "./blockRemoveFiles.js"; 13 | import { blockRemoveWorkflows } from "./blockRemoveWorkflows.js"; 14 | import { intakeFileAsJson } from "./intake/intakeFileAsJson.js"; 15 | 16 | const zStringArray = z.array(z.string()); 17 | 18 | export const blockKnip = base.createBlock({ 19 | about: { 20 | name: "Knip", 21 | }, 22 | addons: { 23 | entry: zStringArray.optional(), 24 | ignoreDependencies: zStringArray.optional(), 25 | project: zStringArray.optional(), 26 | }, 27 | intake({ files }) { 28 | const knipJson = intakeFileAsJson(files, ["knip.json"]); 29 | if (!knipJson) { 30 | return undefined; 31 | } 32 | 33 | return removeUndefinedObjects({ 34 | entry: zStringArray.safeParse(knipJson.entry).data, 35 | ignoreDependencies: zStringArray.safeParse(knipJson.ignoreDependencies) 36 | .data, 37 | project: zStringArray.safeParse(knipJson.project).data, 38 | }); 39 | }, 40 | produce({ addons }) { 41 | const { entry, ignoreDependencies, project } = addons; 42 | return { 43 | addons: [ 44 | blockDevelopmentDocs({ 45 | sections: { 46 | Linting: { 47 | contents: { 48 | items: [ 49 | `- \`pnpm lint:knip\` ([knip](https://github.com/webpro/knip)): Detects unused files, dependencies, and code exports`, 50 | ], 51 | }, 52 | }, 53 | }, 54 | }), 55 | blockGitHubActionsCI({ 56 | jobs: [ 57 | { 58 | name: "Lint Knip", 59 | steps: [{ run: "pnpm lint:knip" }], 60 | }, 61 | ], 62 | }), 63 | blockPackageJson({ 64 | properties: { 65 | devDependencies: getPackageDependencies("knip"), 66 | scripts: { 67 | "lint:knip": "knip", 68 | }, 69 | }, 70 | }), 71 | ], 72 | files: { 73 | "knip.json": JSON.stringify({ 74 | $schema: `https://unpkg.com/knip@${getPackageDependency("knip")}/schema.json`, 75 | entry: entry?.sort(), 76 | ignoreDependencies, 77 | ignoreExportsUsedInFile: { 78 | interface: true, 79 | type: true, 80 | }, 81 | project: project?.sort(), 82 | }), 83 | }, 84 | }; 85 | }, 86 | transition() { 87 | return { 88 | addons: [ 89 | blockRemoveFiles({ 90 | files: [".knip*", "knip.{c,m,t}*", "knip.js", "knip.jsonc"], 91 | }), 92 | blockRemoveWorkflows({ 93 | workflows: ["knip", "lint-knip"], 94 | }), 95 | ], 96 | }; 97 | }, 98 | }); 99 | -------------------------------------------------------------------------------- /src/blocks/blockMITLicense.ts: -------------------------------------------------------------------------------- 1 | import { base } from "../base.js"; 2 | import { blockPackageJson } from "./blockPackageJson.js"; 3 | import { blockREADME } from "./blockREADME.js"; 4 | 5 | export const blockMITLicense = base.createBlock({ 6 | about: { 7 | name: "MIT License", 8 | }, 9 | produce({ options }) { 10 | return { 11 | addons: [ 12 | blockREADME({ 13 | badges: [ 14 | { 15 | alt: "📝 License: MIT", 16 | href: `https://github.com/${options.owner}/${options.repository}/blob/main/LICENSE.md`, 17 | src: "https://img.shields.io/badge/%F0%9F%93%9D_license-MIT-21bb42.svg", 18 | }, 19 | ], 20 | }), 21 | blockPackageJson({ 22 | properties: { 23 | files: ["LICENSE.md"], 24 | license: "MIT", 25 | }, 26 | }), 27 | ], 28 | files: { 29 | "LICENSE.md": `# MIT License 30 | 31 | Permission is hereby granted, free of charge, to any person obtaining 32 | a copy of this software and associated documentation files (the 33 | 'Software'), to deal in the Software without restriction, including 34 | without limitation the rights to use, copy, modify, merge, publish, 35 | distribute, sublicense, and/or sell copies of the Software, and to 36 | permit persons to whom the Software is furnished to do so, subject to 37 | the following conditions: 38 | 39 | The above copyright notice and this permission notice shall be 40 | included in all copies or substantial portions of the Software. 41 | 42 | THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, 43 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 44 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. 45 | IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY 46 | CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, 47 | TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE 48 | SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 49 | `, 50 | }, 51 | }; 52 | }, 53 | }); 54 | -------------------------------------------------------------------------------- /src/blocks/blockMain.test.ts: -------------------------------------------------------------------------------- 1 | import { testBlock } from "bingo-stratum-testers"; 2 | import { describe, expect, it } from "vitest"; 3 | 4 | import { blockMain } from "./blockMain.js"; 5 | import { optionsBase } from "./options.fakes.js"; 6 | 7 | describe("blockMain", () => { 8 | it("without addons", () => { 9 | const creation = testBlock(blockMain, { options: optionsBase }); 10 | 11 | expect(creation).toMatchInlineSnapshot(` 12 | { 13 | "addons": [ 14 | { 15 | "addons": { 16 | "properties": { 17 | "main": "lib/index.js", 18 | }, 19 | }, 20 | "block": [Function], 21 | }, 22 | { 23 | "addons": { 24 | "runInCI": [ 25 | "node lib/index.js", 26 | ], 27 | }, 28 | "block": [Function], 29 | }, 30 | ], 31 | } 32 | `); 33 | }); 34 | 35 | it("with addons", () => { 36 | const creation = testBlock(blockMain, { 37 | addons: { 38 | filePath: "other.js", 39 | runArgs: ["--version"], 40 | }, 41 | options: optionsBase, 42 | }); 43 | 44 | expect(creation).toMatchInlineSnapshot(` 45 | { 46 | "addons": [ 47 | { 48 | "addons": { 49 | "properties": { 50 | "main": "other.js", 51 | }, 52 | }, 53 | "block": [Function], 54 | }, 55 | { 56 | "addons": { 57 | "runInCI": [ 58 | "node other.js --version", 59 | ], 60 | }, 61 | "block": [Function], 62 | }, 63 | ], 64 | } 65 | `); 66 | }); 67 | }); 68 | -------------------------------------------------------------------------------- /src/blocks/blockMain.ts: -------------------------------------------------------------------------------- 1 | import { z } from "zod"; 2 | 3 | import { base } from "../base.js"; 4 | import { blockPackageJson } from "./blockPackageJson.js"; 5 | import { blockTSup } from "./blockTSup.js"; 6 | 7 | export const blockMain = base.createBlock({ 8 | about: { 9 | name: "Main", 10 | }, 11 | addons: { 12 | filePath: z.string().optional(), 13 | runArgs: z.array(z.string()).default([]), 14 | }, 15 | produce({ addons }) { 16 | const { filePath = "lib/index.js", runArgs } = addons; 17 | 18 | return { 19 | addons: [ 20 | blockPackageJson({ 21 | properties: { 22 | main: filePath, 23 | }, 24 | }), 25 | blockTSup({ 26 | runInCI: [ 27 | `node ${filePath}${runArgs.map((arg) => ` ${arg}`).join("")}`, 28 | ], 29 | }), 30 | ], 31 | }; 32 | }, 33 | }); 34 | -------------------------------------------------------------------------------- /src/blocks/blockNcc.ts: -------------------------------------------------------------------------------- 1 | import { z } from "zod"; 2 | 3 | import { base } from "../base.js"; 4 | import { blockCSpell } from "./blockCSpell.js"; 5 | import { blockDevelopmentDocs } from "./blockDevelopmentDocs.js"; 6 | import { blockESLint } from "./blockESLint.js"; 7 | import { blockGitHubActionsCI } from "./blockGitHubActionsCI.js"; 8 | import { blockPackageJson } from "./blockPackageJson.js"; 9 | import { blockPrettier } from "./blockPrettier.js"; 10 | 11 | export const blockNcc = base.createBlock({ 12 | about: { 13 | name: "ncc", 14 | }, 15 | addons: { 16 | entry: z.string().optional(), 17 | }, 18 | intake({ options }) { 19 | return { 20 | entry: options.packageData?.scripts?.["build:release"]?.match( 21 | /ncc build (.+) -o dist/, 22 | )?.[1], 23 | }; 24 | }, 25 | produce({ addons }) { 26 | const { entry = "src/index.ts" } = addons; 27 | 28 | return { 29 | addons: [ 30 | blockCSpell({ 31 | ignorePaths: ["dist"], 32 | }), 33 | blockDevelopmentDocs({ 34 | sections: { 35 | Building: { 36 | contents: ` 37 | Run [TypeScript](https://typescriptlang.org) locally to type check and build source files from \`src/\` into output files in \`lib/\`: 38 | 39 | \`\`\`shell 40 | pnpm build 41 | \`\`\` 42 | 43 | Add \`--watch\` to run the builder in a watch mode that continuously cleans and recreates \`lib/\` as you save files: 44 | 45 | \`\`\`shell 46 | pnpm build --watch 47 | \`\`\` 48 | `, 49 | innerSections: [ 50 | { 51 | contents: ` 52 | Run [\`@vercel/ncc\`](https://github.com/vercel/ncc) to create an output \`dist/\` to be used in production. 53 | 54 | \`\`\`shell 55 | pnpm build:release 56 | \`\`\` 57 | `, 58 | heading: "Building for Release", 59 | }, 60 | ], 61 | }, 62 | }, 63 | }), 64 | blockESLint({ 65 | ignores: ["dist"], 66 | }), 67 | blockGitHubActionsCI({ 68 | jobs: [ 69 | { 70 | name: "Build", 71 | steps: [{ run: "pnpm build" }], 72 | }, 73 | { 74 | name: "Build (Release)", 75 | steps: [{ run: "pnpm build:release" }], 76 | }, 77 | ], 78 | }), 79 | blockPackageJson({ 80 | properties: { 81 | devDependencies: { 82 | "@vercel/ncc": "^0.38.3", 83 | }, 84 | scripts: { 85 | build: "tsc", 86 | "build:release": `ncc build ${entry} -o dist`, 87 | }, 88 | }, 89 | }), 90 | blockPrettier({ 91 | ignores: ["/dist"], 92 | }), 93 | ], 94 | }; 95 | }, 96 | }); 97 | -------------------------------------------------------------------------------- /src/blocks/blockNvmrc.test.ts: -------------------------------------------------------------------------------- 1 | import { testBlock } from "bingo-stratum-testers"; 2 | import { describe, expect, it } from "vitest"; 3 | 4 | import { blockNvmrc } from "./blockNvmrc.js"; 5 | import { blockPrettier } from "./blockPrettier.js"; 6 | import { optionsBase } from "./options.fakes.js"; 7 | 8 | describe("blockNvmrc", () => { 9 | it("only includes blockPackageJson addons when options.node does not exist", () => { 10 | const creation = testBlock(blockNvmrc, { options: optionsBase }); 11 | 12 | expect(creation).toEqual({ 13 | addons: [ 14 | blockPrettier({ 15 | overrides: [{ files: ".nvmrc", options: { parser: "yaml" } }], 16 | }), 17 | ], 18 | }); 19 | }); 20 | 21 | it("also includes files when options.node.pinned exists", () => { 22 | const creation = testBlock(blockNvmrc, { 23 | options: { 24 | ...optionsBase, 25 | node: { minimum: ">=18.3.0", pinned: "20.18.0" }, 26 | }, 27 | }); 28 | 29 | expect(creation).toEqual({ 30 | addons: [ 31 | blockPrettier({ 32 | overrides: [{ files: ".nvmrc", options: { parser: "yaml" } }], 33 | }), 34 | ], 35 | files: { 36 | ".nvmrc": `20.18.0\n`, 37 | }, 38 | }); 39 | }); 40 | }); 41 | -------------------------------------------------------------------------------- /src/blocks/blockNvmrc.ts: -------------------------------------------------------------------------------- 1 | import { base } from "../base.js"; 2 | import { blockPrettier } from "./blockPrettier.js"; 3 | 4 | export const blockNvmrc = base.createBlock({ 5 | about: { 6 | name: "Nvmrc", 7 | }, 8 | produce({ options }) { 9 | return { 10 | addons: [ 11 | blockPrettier({ 12 | overrides: [{ files: ".nvmrc", options: { parser: "yaml" } }], 13 | }), 14 | ], 15 | ...(options.node.pinned && { 16 | files: { 17 | ".nvmrc": `${options.node.pinned}\n`, 18 | }, 19 | }), 20 | }; 21 | }, 22 | }); 23 | -------------------------------------------------------------------------------- /src/blocks/blockOctoGuideStrict.ts: -------------------------------------------------------------------------------- 1 | import { base } from "../base.js"; 2 | import { blockOctoGuide } from "./blockOctoGuide.js"; 3 | 4 | export const blockOctoGuideStrict = base.createBlock({ 5 | about: { 6 | name: "OctoGuide Strict", 7 | }, 8 | produce() { 9 | return { 10 | addons: [ 11 | blockOctoGuide({ 12 | config: "strict", 13 | }), 14 | ], 15 | }; 16 | }, 17 | }); 18 | -------------------------------------------------------------------------------- /src/blocks/blockPnpmDedupe.ts: -------------------------------------------------------------------------------- 1 | import { base } from "../base.js"; 2 | import { blockDevelopmentDocs } from "./blockDevelopmentDocs.js"; 3 | import { blockGitHubActionsCI } from "./blockGitHubActionsCI.js"; 4 | import { blockPackageJson } from "./blockPackageJson.js"; 5 | import { blockRemoveWorkflows } from "./blockRemoveWorkflows.js"; 6 | 7 | export const blockPnpmDedupe = base.createBlock({ 8 | about: { 9 | name: "pnpm Dedupe", 10 | }, 11 | produce() { 12 | return { 13 | addons: [ 14 | blockDevelopmentDocs({ 15 | sections: { 16 | Linting: { 17 | contents: { 18 | items: [ 19 | `- \`pnpm lint:packages\` ([pnpm dedupe --check](https://pnpm.io/cli/dedupe)): Checks for unnecessarily duplicated packages in the \`pnpm-lock.yml\` file`, 20 | ], 21 | }, 22 | }, 23 | }, 24 | }), 25 | blockGitHubActionsCI({ 26 | jobs: [ 27 | { 28 | name: "Lint Packages", 29 | steps: [{ run: "pnpm lint:packages" }], 30 | }, 31 | ], 32 | }), 33 | blockPackageJson({ 34 | cleanupCommands: ["pnpm dedupe"], 35 | properties: { 36 | scripts: { 37 | "lint:packages": "pnpm dedupe --check", 38 | }, 39 | }, 40 | }), 41 | ], 42 | }; 43 | }, 44 | transition() { 45 | return { 46 | addons: [ 47 | blockRemoveWorkflows({ 48 | workflows: ["lint-packages"], 49 | }), 50 | ], 51 | }; 52 | }, 53 | }); 54 | -------------------------------------------------------------------------------- /src/blocks/blockPrettierPluginCurly.ts: -------------------------------------------------------------------------------- 1 | import { base } from "../base.js"; 2 | import { blockPrettier } from "./blockPrettier.js"; 3 | 4 | export const blockPrettierPluginCurly = base.createBlock({ 5 | about: { 6 | name: "Prettier Plugin Curly", 7 | }, 8 | produce() { 9 | return { 10 | addons: [ 11 | blockPrettier({ 12 | plugins: ["prettier-plugin-curly"], 13 | }), 14 | ], 15 | }; 16 | }, 17 | }); 18 | -------------------------------------------------------------------------------- /src/blocks/blockPrettierPluginPackageJson.ts: -------------------------------------------------------------------------------- 1 | import { base } from "../base.js"; 2 | import { blockPrettier } from "./blockPrettier.js"; 3 | 4 | export const blockPrettierPluginPackageJson = base.createBlock({ 5 | about: { 6 | name: "Prettier Plugin Package JSON", 7 | }, 8 | produce() { 9 | return { 10 | addons: [ 11 | blockPrettier({ 12 | plugins: ["prettier-plugin-packagejson"], 13 | }), 14 | ], 15 | }; 16 | }, 17 | }); 18 | -------------------------------------------------------------------------------- /src/blocks/blockPrettierPluginSh.ts: -------------------------------------------------------------------------------- 1 | import { base } from "../base.js"; 2 | import { blockPrettier } from "./blockPrettier.js"; 3 | 4 | export const blockPrettierPluginSh = base.createBlock({ 5 | about: { 6 | name: "Prettier Plugin Sh", 7 | }, 8 | produce() { 9 | return { 10 | addons: [ 11 | blockPrettier({ 12 | plugins: ["prettier-plugin-sh"], 13 | }), 14 | ], 15 | }; 16 | }, 17 | }); 18 | -------------------------------------------------------------------------------- /src/blocks/blockRemoveDependencies.test.ts: -------------------------------------------------------------------------------- 1 | import { testBlock } from "bingo-stratum-testers"; 2 | import { describe, expect, test, vi } from "vitest"; 3 | 4 | import { blockRemoveDependencies } from "./blockRemoveDependencies.js"; 5 | import { optionsBase } from "./options.fakes.js"; 6 | 7 | vi.mock("../utils/resolveBin.js", () => ({ 8 | resolveBin: (bin: string) => `path/to/${bin}`, 9 | })); 10 | 11 | describe("blockRemoveDependencies", () => { 12 | test("without addons or mode", () => { 13 | const creation = testBlock(blockRemoveDependencies, { 14 | options: optionsBase, 15 | }); 16 | 17 | expect(creation).toMatchInlineSnapshot(`{}`); 18 | }); 19 | 20 | test("with addons", () => { 21 | const creation = testBlock(blockRemoveDependencies, { 22 | addons: { 23 | dependencies: ["a", "b", "c"], 24 | }, 25 | options: optionsBase, 26 | }); 27 | 28 | expect(creation).toMatchInlineSnapshot(`{}`); 29 | }); 30 | 31 | test("with mode", () => { 32 | const creation = testBlock(blockRemoveDependencies, { 33 | mode: "transition", 34 | options: optionsBase, 35 | }); 36 | 37 | expect(creation).toMatchInlineSnapshot(`{}`); 38 | }); 39 | 40 | test("with addons and mode", () => { 41 | const creation = testBlock(blockRemoveDependencies, { 42 | addons: { 43 | dependencies: ["a", "b", "c"], 44 | }, 45 | mode: "transition", 46 | options: optionsBase, 47 | }); 48 | 49 | expect(creation).toMatchInlineSnapshot(` 50 | { 51 | "scripts": [ 52 | { 53 | "commands": [ 54 | "node path/to/remove-dependencies/bin/index.js a b c", 55 | ], 56 | "phase": 3, 57 | }, 58 | ], 59 | } 60 | `); 61 | }); 62 | }); 63 | -------------------------------------------------------------------------------- /src/blocks/blockRemoveDependencies.ts: -------------------------------------------------------------------------------- 1 | import { z } from "zod"; 2 | 3 | import { base } from "../base.js"; 4 | import { resolveBin } from "../utils/resolveBin.js"; 5 | import { CommandPhase } from "./phases.js"; 6 | 7 | export const blockRemoveDependencies = base.createBlock({ 8 | about: { 9 | name: "Remove Dependencies", 10 | }, 11 | addons: { 12 | dependencies: z.array(z.string()).optional(), 13 | }, 14 | // TODO: Make produce() optional, so this empty-ish produce() can be removed 15 | // https://github.com/JoshuaKGoldberg/bingo/issues/295 16 | produce() { 17 | return {}; 18 | }, 19 | transition({ addons }) { 20 | return { 21 | scripts: addons.dependencies 22 | ? [ 23 | { 24 | commands: [ 25 | `node ${resolveBin("remove-dependencies/bin/index.js")} ${addons.dependencies.join(" ")}`, 26 | ], 27 | phase: CommandPhase.Process, 28 | }, 29 | ] 30 | : undefined, 31 | }; 32 | }, 33 | }); 34 | -------------------------------------------------------------------------------- /src/blocks/blockRemoveFiles.test.ts: -------------------------------------------------------------------------------- 1 | import { testBlock } from "bingo-stratum-testers"; 2 | import { describe, expect, test, vi } from "vitest"; 3 | 4 | import { blockRemoveFiles } from "./blockRemoveFiles.js"; 5 | import { optionsBase } from "./options.fakes.js"; 6 | 7 | vi.mock("../utils/resolveBin.js", () => ({ 8 | resolveBin: (bin: string) => `path/to/${bin}`, 9 | })); 10 | 11 | describe("blockRemoveFiles", () => { 12 | test("without addons or mode", () => { 13 | const creation = testBlock(blockRemoveFiles, { 14 | options: optionsBase, 15 | }); 16 | 17 | expect(creation).toMatchInlineSnapshot(`{}`); 18 | }); 19 | 20 | test("with addons", () => { 21 | const creation = testBlock(blockRemoveFiles, { 22 | addons: { 23 | files: ["a", "b", "c"], 24 | }, 25 | options: optionsBase, 26 | }); 27 | 28 | expect(creation).toMatchInlineSnapshot(`{}`); 29 | }); 30 | 31 | test("with mode", () => { 32 | const creation = testBlock(blockRemoveFiles, { 33 | mode: "transition", 34 | options: optionsBase, 35 | }); 36 | 37 | expect(creation).toMatchInlineSnapshot(`{}`); 38 | }); 39 | 40 | test("with addons and mode", () => { 41 | const creation = testBlock(blockRemoveFiles, { 42 | addons: { 43 | files: ["a", "b", "c"], 44 | }, 45 | mode: "transition", 46 | options: optionsBase, 47 | }); 48 | 49 | expect(creation).toMatchInlineSnapshot(` 50 | { 51 | "scripts": [ 52 | { 53 | "commands": [ 54 | "node path/to/trash-cli/cli.js a b c", 55 | ], 56 | "phase": 0, 57 | "silent": true, 58 | }, 59 | ], 60 | } 61 | `); 62 | }); 63 | }); 64 | -------------------------------------------------------------------------------- /src/blocks/blockRemoveFiles.ts: -------------------------------------------------------------------------------- 1 | import { z } from "zod"; 2 | 3 | import { base } from "../base.js"; 4 | import { resolveBin } from "../utils/resolveBin.js"; 5 | import { CommandPhase } from "./phases.js"; 6 | 7 | export const blockRemoveFiles = base.createBlock({ 8 | about: { 9 | name: "Remove Files", 10 | }, 11 | addons: { 12 | files: z.array(z.string()).optional(), 13 | }, 14 | // TODO: Make produce() optional, so this empty-ish produce() can be removed 15 | // https://github.com/JoshuaKGoldberg/bingo/issues/295 16 | produce() { 17 | return {}; 18 | }, 19 | transition({ addons }) { 20 | return { 21 | scripts: addons.files 22 | ? [ 23 | { 24 | commands: [ 25 | `node ${resolveBin("trash-cli/cli.js")} ${addons.files.join(" ")}`, 26 | ], 27 | phase: CommandPhase.Migrations, 28 | silent: true, 29 | }, 30 | ] 31 | : undefined, 32 | }; 33 | }, 34 | }); 35 | -------------------------------------------------------------------------------- /src/blocks/blockRemoveWorkflows.test.ts: -------------------------------------------------------------------------------- 1 | import { testBlock } from "bingo-stratum-testers"; 2 | import { describe, expect, test } from "vitest"; 3 | 4 | import { blockRemoveWorkflows } from "./blockRemoveWorkflows.js"; 5 | import { optionsBase } from "./options.fakes.js"; 6 | 7 | describe("blockRemoveWorkflows", () => { 8 | test("without addons or mode", () => { 9 | const creation = testBlock(blockRemoveWorkflows, { 10 | options: optionsBase, 11 | }); 12 | 13 | expect(creation).toMatchInlineSnapshot(`{}`); 14 | }); 15 | 16 | test("with addons", () => { 17 | const creation = testBlock(blockRemoveWorkflows, { 18 | addons: { 19 | workflows: ["a", "b", "c"], 20 | }, 21 | options: optionsBase, 22 | }); 23 | 24 | expect(creation).toMatchInlineSnapshot(`{}`); 25 | }); 26 | 27 | test("with mode", () => { 28 | const creation = testBlock(blockRemoveWorkflows, { 29 | mode: "transition", 30 | options: optionsBase, 31 | }); 32 | 33 | expect(creation).toMatchInlineSnapshot(` 34 | { 35 | "addons": [ 36 | { 37 | "addons": { 38 | "files": undefined, 39 | }, 40 | "block": [Function], 41 | }, 42 | ], 43 | } 44 | `); 45 | }); 46 | 47 | test("with addons and mode", () => { 48 | const creation = testBlock(blockRemoveWorkflows, { 49 | addons: { 50 | workflows: ["a", "b", "c"], 51 | }, 52 | mode: "transition", 53 | options: optionsBase, 54 | }); 55 | 56 | expect(creation).toMatchInlineSnapshot(` 57 | { 58 | "addons": [ 59 | { 60 | "addons": { 61 | "files": [ 62 | ".github/workflows/a.yml", 63 | ".github/workflows/b.yml", 64 | ".github/workflows/c.yml", 65 | ], 66 | }, 67 | "block": [Function], 68 | }, 69 | ], 70 | } 71 | `); 72 | }); 73 | }); 74 | -------------------------------------------------------------------------------- /src/blocks/blockRemoveWorkflows.ts: -------------------------------------------------------------------------------- 1 | import { z } from "zod"; 2 | 3 | import { base } from "../base.js"; 4 | import { blockRemoveFiles } from "./blockRemoveFiles.js"; 5 | 6 | export const blockRemoveWorkflows = base.createBlock({ 7 | about: { 8 | name: "Remove Workflows", 9 | }, 10 | addons: { 11 | workflows: z.array(z.string()).optional(), 12 | }, 13 | // TODO: Make produce() optional, so this empty-ish produce() can be removed 14 | // https://github.com/JoshuaKGoldberg/bingo/issues/295 15 | produce() { 16 | return {}; 17 | }, 18 | transition({ addons }) { 19 | const { workflows } = addons; 20 | 21 | return { 22 | addons: [ 23 | blockRemoveFiles({ 24 | files: workflows?.map( 25 | (workflow) => `.github/workflows/${workflow}.yml`, 26 | ), 27 | }), 28 | ], 29 | }; 30 | }, 31 | }); 32 | -------------------------------------------------------------------------------- /src/blocks/blockRenovate.ts: -------------------------------------------------------------------------------- 1 | import { z } from "zod"; 2 | 3 | import { base } from "../base.js"; 4 | import { blockGitHubApps } from "./blockGitHubApps.js"; 5 | import { intakeFileAsJson } from "./intake/intakeFileAsJson.js"; 6 | 7 | const zIgnoreDeps = z.array(z.string()).default([]); 8 | 9 | export const blockRenovate = base.createBlock({ 10 | about: { 11 | name: "Renovate", 12 | }, 13 | addons: { 14 | ignoreDeps: zIgnoreDeps, 15 | }, 16 | intake({ files }) { 17 | const raw = intakeFileAsJson(files, [".github", "renovate.json"]); 18 | 19 | return { 20 | ignoreDeps: zIgnoreDeps.safeParse(raw?.ignoreDeps).data, 21 | }; 22 | }, 23 | produce({ addons }) { 24 | const { ignoreDeps } = addons; 25 | 26 | return { 27 | addons: [ 28 | blockGitHubApps({ 29 | apps: [ 30 | { 31 | name: "Renovate", 32 | url: "https://github.com/apps/renovate", 33 | }, 34 | ], 35 | }), 36 | ], 37 | files: { 38 | ".github": { 39 | "renovate.json": JSON.stringify({ 40 | $schema: "https://docs.renovatebot.com/renovate-schema.json", 41 | automerge: true, 42 | extends: [ 43 | ":preserveSemverRanges", 44 | "config:best-practices", 45 | "replacements:all", 46 | ], 47 | ignoreDeps: Array.from( 48 | new Set(["codecov/codecov-action", ...ignoreDeps]), 49 | ).sort(), 50 | labels: ["dependencies"], 51 | minimumReleaseAge: "7 days", 52 | patch: { enabled: false }, 53 | postUpdateOptions: ["pnpmDedupe"], 54 | }), 55 | }, 56 | }, 57 | }; 58 | }, 59 | }); 60 | -------------------------------------------------------------------------------- /src/blocks/blockRepositoryLabels.ts: -------------------------------------------------------------------------------- 1 | import { determineLabelChanges } from "set-github-repository-labels"; 2 | 3 | import { base } from "../base.js"; 4 | import { outcomeLabels } from "./outcomeLabels.js"; 5 | 6 | export const blockRepositoryLabels = base.createBlock({ 7 | about: { 8 | name: "Repository Labels", 9 | }, 10 | produce({ options }) { 11 | const changes = determineLabelChanges( 12 | options.existingLabels ?? [], 13 | outcomeLabels, 14 | ); 15 | const requestData = { 16 | owner: options.owner, 17 | repo: options.repository, 18 | }; 19 | 20 | return { 21 | requests: changes.map((change) => { 22 | switch (change.type) { 23 | case "delete": 24 | return { 25 | endpoint: "DELETE /repos/{owner}/{repo}/labels/{name}", 26 | id: `delete label '${change.name}'`, 27 | parameters: { 28 | ...requestData, 29 | name: change.name, 30 | }, 31 | type: "octokit", 32 | }; 33 | case "patch": 34 | return { 35 | endpoint: "PATCH /repos/{owner}/{repo}/labels/{name}", 36 | id: `patch label '${change.originalName}'`, 37 | parameters: { 38 | ...requestData, 39 | color: change.color, 40 | description: change.description, 41 | name: change.originalName, 42 | new_name: change.newName, 43 | }, 44 | type: "octokit", 45 | }; 46 | case "post": 47 | return { 48 | endpoint: "POST /repos/{owner}/{repo}/labels", 49 | id: `post label '${change.name}'`, 50 | parameters: { 51 | ...requestData, 52 | color: change.color, 53 | description: change.description, 54 | name: change.name, 55 | }, 56 | type: "octokit", 57 | }; 58 | } 59 | }), 60 | }; 61 | }, 62 | }); 63 | -------------------------------------------------------------------------------- /src/blocks/blockRepositorySecrets.test.ts: -------------------------------------------------------------------------------- 1 | import { testBlock } from "bingo-stratum-testers"; 2 | import { describe, expect, test } from "vitest"; 3 | 4 | import { blockRepositorySecrets } from "./blockRepositorySecrets.js"; 5 | import { optionsBase } from "./options.fakes.js"; 6 | 7 | describe("blockRepositorySecrets", () => { 8 | test("without addons", () => { 9 | const creation = testBlock(blockRepositorySecrets, { 10 | options: optionsBase, 11 | }); 12 | 13 | expect(creation).toMatchInlineSnapshot(` 14 | { 15 | "suggestions": undefined, 16 | } 17 | `); 18 | }); 19 | 20 | test("with addons", () => { 21 | const creation = testBlock(blockRepositorySecrets, { 22 | addons: { 23 | secrets: [ 24 | { 25 | description: "Secret description a.", 26 | name: "Secret Name A", 27 | }, 28 | { 29 | description: "Secret description b.", 30 | name: "Secret Name B", 31 | }, 32 | ], 33 | }, 34 | options: optionsBase, 35 | }); 36 | 37 | expect(creation).toMatchInlineSnapshot(` 38 | { 39 | "suggestions": [ 40 | "- populate the secrets on https://github.com/test-owner/test-repository/settings/secrets/actions: 41 | - Secret Name A (Secret description a.) 42 | - Secret Name B (Secret description b.)", 43 | ], 44 | } 45 | `); 46 | }); 47 | }); 48 | -------------------------------------------------------------------------------- /src/blocks/blockRepositorySecrets.ts: -------------------------------------------------------------------------------- 1 | import { z } from "zod"; 2 | 3 | import { base } from "../base.js"; 4 | import { getInstallationSuggestions } from "./getInstallationSuggestions.js"; 5 | 6 | export const blockRepositorySecrets = base.createBlock({ 7 | about: { 8 | name: "Repository Secrets", 9 | }, 10 | addons: { 11 | secrets: z 12 | .array( 13 | z.object({ 14 | description: z.string(), 15 | name: z.string(), 16 | }), 17 | ) 18 | .default([]), 19 | }, 20 | produce({ addons, options }) { 21 | return { 22 | suggestions: getInstallationSuggestions( 23 | "populate the secret", 24 | addons.secrets.map( 25 | (secret) => `${secret.name} (${secret.description})`, 26 | ), 27 | `https://github.com/${options.owner}/${options.repository}/settings/secrets/actions`, 28 | ), 29 | }; 30 | }, 31 | }); 32 | -------------------------------------------------------------------------------- /src/blocks/blockRepositorySettings.ts: -------------------------------------------------------------------------------- 1 | import { base } from "../base.js"; 2 | import { htmlToTextSafe } from "../utils/htmlToTextSafe.js"; 3 | 4 | export const blockRepositorySettings = base.createBlock({ 5 | about: { 6 | name: "Repository Settings", 7 | }, 8 | produce({ options }) { 9 | const description = htmlToTextSafe(options.description); 10 | 11 | return { 12 | requests: [ 13 | { 14 | endpoint: "PATCH /repos/{owner}/{repo}", 15 | parameters: { 16 | allow_auto_merge: true, 17 | allow_merge_commit: false, 18 | allow_rebase_merge: false, 19 | allow_squash_merge: true, 20 | delete_branch_on_merge: true, 21 | description, 22 | has_wiki: false, 23 | owner: options.owner, 24 | repo: options.repository, 25 | security_and_analysis: { 26 | secret_scanning: { 27 | status: "enabled", 28 | }, 29 | secret_scanning_push_protection: { 30 | status: "enabled", 31 | }, 32 | }, 33 | squash_merge_commit_message: "PR_BODY", 34 | squash_merge_commit_title: "PR_TITLE", 35 | }, 36 | type: "octokit", 37 | }, 38 | ], 39 | }; 40 | }, 41 | }); 42 | -------------------------------------------------------------------------------- /src/blocks/blockSecurityDocs.ts: -------------------------------------------------------------------------------- 1 | import { base } from "../base.js"; 2 | 3 | export const blockSecurityDocs = base.createBlock({ 4 | about: { 5 | name: "Security Docs", 6 | }, 7 | produce({ options }) { 8 | return { 9 | files: { 10 | ".github": { 11 | "SECURITY.md": `# Security Policy 12 | 13 | We take all security vulnerabilities seriously. 14 | If you have a vulnerability or other security issues to disclose: 15 | 16 | - Thank you very much, please do! 17 | - Please send them to us by emailing \`${options.email.github}\` 18 | 19 | We appreciate your efforts and responsible disclosure and will make every effort to acknowledge your contributions. 20 | `, 21 | }, 22 | }, 23 | }; 24 | }, 25 | }); 26 | -------------------------------------------------------------------------------- /src/blocks/blockTemplatedWith.test.ts: -------------------------------------------------------------------------------- 1 | import { testBlock } from "bingo-stratum-testers"; 2 | import { describe, expect, test, vi } from "vitest"; 3 | 4 | import { blockTemplatedWith } from "./blockTemplatedWith.js"; 5 | import { optionsBase } from "./options.fakes.js"; 6 | 7 | vi.mock("../utils/resolveBin.js", () => ({ 8 | resolveBin: (bin: string) => `path/to/${bin}`, 9 | })); 10 | 11 | describe("blockTemplatedWith", () => { 12 | test("production with unknown owner", () => { 13 | const creation = testBlock(blockTemplatedWith, { 14 | options: optionsBase, 15 | }); 16 | 17 | expect(creation).toMatchInlineSnapshot(` 18 | { 19 | "addons": [ 20 | { 21 | "addons": { 22 | "notices": [ 23 | " 24 | ", 25 | "> 💝 This package was templated with [\`create-typescript-app\`](https://github.com/JoshuaKGoldberg/create-typescript-app) using the [Bingo framework](https://create.bingo). 26 | ", 27 | ], 28 | }, 29 | "block": [Function], 30 | }, 31 | ], 32 | } 33 | `); 34 | }); 35 | 36 | test("production with JoshuaKGoldberg as owner", () => { 37 | const creation = testBlock(blockTemplatedWith, { 38 | options: { 39 | ...optionsBase, 40 | owner: "JoshuaKGoldberg", 41 | }, 42 | }); 43 | 44 | expect(creation).toMatchInlineSnapshot(` 45 | { 46 | "addons": [ 47 | { 48 | "addons": { 49 | "notices": [ 50 | "> 💝 This package was templated with [\`create-typescript-app\`](https://github.com/JoshuaKGoldberg/create-typescript-app) using the [Bingo framework](https://create.bingo). 51 | ", 52 | ], 53 | }, 54 | "block": [Function], 55 | }, 56 | ], 57 | } 58 | `); 59 | }); 60 | }); 61 | -------------------------------------------------------------------------------- /src/blocks/blockTemplatedWith.ts: -------------------------------------------------------------------------------- 1 | import { base } from "../base.js"; 2 | import { blockREADME } from "./blockREADME.js"; 3 | 4 | export const blockTemplatedWith = base.createBlock({ 5 | about: { 6 | name: "Templated With", 7 | }, 8 | produce({ options }) { 9 | return { 10 | addons: [ 11 | blockREADME({ 12 | notices: [ 13 | options.owner !== "JoshuaKGoldberg" && 14 | ` 15 | `, 16 | `> 💝 This package was templated with [\`create-typescript-app\`](https://github.com/JoshuaKGoldberg/create-typescript-app) using the [Bingo framework](https://create.bingo). 17 | `, 18 | ].filter((notice) => typeof notice === "string"), 19 | }), 20 | ], 21 | }; 22 | }, 23 | }); 24 | -------------------------------------------------------------------------------- /src/blocks/eslint/blockESLintPluginIntake.ts: -------------------------------------------------------------------------------- 1 | import { 2 | AST_NODE_TYPES, 3 | parse as parseAST, 4 | TSESTree, 5 | } from "@typescript-eslint/typescript-estree"; 6 | import JSON5 from "json5"; 7 | 8 | import { tryCatch } from "../../utils/tryCatch.js"; 9 | import { zConfigEmoji } from "./schemas.js"; 10 | 11 | export function blockESLintPluginIntake(sourceText: string) { 12 | const ast = tryCatch(() => 13 | parseAST(sourceText, { 14 | comment: true, 15 | loc: true, 16 | range: true, 17 | }), 18 | ); 19 | if (!ast) { 20 | return undefined; 21 | } 22 | 23 | const config = findConfig(ast.body); 24 | if (!config) { 25 | return undefined; 26 | } 27 | 28 | const configEmoji = findConfigEmoji(config.properties); 29 | if (!configEmoji) { 30 | return undefined; 31 | } 32 | 33 | const { data } = zConfigEmoji.safeParse( 34 | JSON5.parse(sourceText.slice(...configEmoji.range)), 35 | ); 36 | 37 | return data && { configEmoji: data }; 38 | 39 | function findConfig(body: TSESTree.ProgramStatement[]) { 40 | for (const node of body) { 41 | if ( 42 | node.type === AST_NODE_TYPES.VariableDeclaration && 43 | node.declarations[0].id.type === AST_NODE_TYPES.Identifier && 44 | node.declarations[0].id.name === "config" && 45 | node.declarations[0].init?.type === AST_NODE_TYPES.ObjectExpression 46 | ) { 47 | return node.declarations[0].init; 48 | } 49 | } 50 | } 51 | 52 | function findConfigEmoji(properties: TSESTree.ObjectLiteralElement[]) { 53 | for (const node of properties) { 54 | if ( 55 | node.type === AST_NODE_TYPES.Property && 56 | node.key.type === AST_NODE_TYPES.Identifier && 57 | node.key.name === "configEmoji" && 58 | node.value.type === AST_NODE_TYPES.ArrayExpression 59 | ) { 60 | return node.value; 61 | } 62 | } 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /src/blocks/eslint/schemas.ts: -------------------------------------------------------------------------------- 1 | import { z } from "zod"; 2 | 3 | export const zConfigEmoji = z 4 | .array(z.tuple([z.string(), z.string()])) 5 | .optional(); 6 | 7 | export const zRuleOptions = z.union([ 8 | z.literal("error"), 9 | z.literal("off"), 10 | z.literal("warn"), 11 | z.tuple([z.union([z.literal("error"), z.literal("warn")]), z.unknown()]), 12 | z.tuple([ 13 | z.union([z.literal("error"), z.literal("warn")]), 14 | z.unknown(), 15 | z.unknown(), 16 | ]), 17 | ]); 18 | 19 | export type RuleOptions = z.infer; 20 | 21 | export const zExtensionRuleGroup = z.object({ 22 | comment: z.string().optional(), 23 | entries: z.record(z.string(), zRuleOptions), 24 | }); 25 | 26 | export type ExtensionRuleGroup = z.infer; 27 | 28 | export const zExtensionRules = z.union([ 29 | z.record(z.string(), zRuleOptions), 30 | z.array(zExtensionRuleGroup), 31 | ]); 32 | 33 | export type ExtensionRules = z.infer; 34 | 35 | export const zExtension = z.object({ 36 | extends: z.array(z.string()).optional(), 37 | files: z.array(z.string()).optional(), 38 | languageOptions: z.unknown().optional(), 39 | linterOptions: z.unknown().optional(), 40 | plugins: z.record(z.string(), z.string()).optional(), 41 | rules: zExtensionRules.optional(), 42 | settings: z.record(z.string(), z.unknown()).optional(), 43 | }); 44 | 45 | export type Extension = z.infer; 46 | 47 | export const zPackageImport = z.object({ 48 | source: z.union([ 49 | z.string(), 50 | z.object({ packageName: z.string(), version: z.string() }), 51 | ]), 52 | specifier: z.string(), 53 | types: z.boolean().optional(), 54 | }); 55 | -------------------------------------------------------------------------------- /src/blocks/files/createJobName.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, test } from "vitest"; 2 | 3 | import { createJobName } from "./createJobName.js"; 4 | 5 | describe(createJobName, () => { 6 | test.each([ 7 | ["Build", "build"], 8 | ["Build?", "build"], 9 | ["Build with Spaces", "build_with_spaces"], 10 | ["Build (Release)", "build_release"], 11 | ])("%s becomes %s", (input, expected) => { 12 | expect(createJobName(input)).toBe(expected); 13 | }); 14 | }); 15 | -------------------------------------------------------------------------------- /src/blocks/files/createJobName.ts: -------------------------------------------------------------------------------- 1 | export function createJobName(label: string) { 2 | return label.replaceAll(/[?()]/g, "").replaceAll(" ", "_").toLowerCase(); 3 | } 4 | -------------------------------------------------------------------------------- /src/blocks/files/createMultiWorkflowFile.ts: -------------------------------------------------------------------------------- 1 | import { WorkflowsVersions } from "../../schemas.js"; 2 | import { resolveUses } from "../actions/resolveUses.js"; 3 | import { createJobName } from "./createJobName.js"; 4 | import { formatWorkflowYaml } from "./formatWorkflowYaml.js"; 5 | 6 | export interface MultiWorkflowFileOptions { 7 | jobs: MultiWorkflowJobOptions[]; 8 | name: string; 9 | workflowsVersions: undefined | WorkflowsVersions; 10 | } 11 | 12 | export interface MultiWorkflowJobOptions { 13 | checkoutWith?: Record; 14 | if?: string; 15 | name: string; 16 | steps: MultiWorkflowJobStep[]; 17 | } 18 | 19 | export type MultiWorkflowJobStep = { if?: string } & ( 20 | | { run: string } 21 | | { uses: string } 22 | ); 23 | 24 | export function createMultiWorkflowFile({ 25 | jobs, 26 | name, 27 | workflowsVersions, 28 | }: MultiWorkflowFileOptions) { 29 | return formatWorkflowYaml({ 30 | jobs: Object.fromEntries( 31 | jobs.map((job) => [ 32 | createJobName(job.name), 33 | { 34 | if: job.if, 35 | name: job.name, 36 | "runs-on": "ubuntu-latest", 37 | steps: [ 38 | { 39 | uses: resolveUses("actions/checkout", "v4", workflowsVersions), 40 | with: job.checkoutWith, 41 | }, 42 | { uses: "./.github/actions/prepare" }, 43 | ...job.steps, 44 | ], 45 | }, 46 | ]), 47 | ), 48 | name, 49 | on: { 50 | pull_request: null, 51 | push: { 52 | branches: ["main"], 53 | }, 54 | }, 55 | }); 56 | } 57 | -------------------------------------------------------------------------------- /src/blocks/files/createSoloWorkflowFile.ts: -------------------------------------------------------------------------------- 1 | import { createJobName } from "./createJobName.js"; 2 | import { formatWorkflowYaml } from "./formatWorkflowYaml.js"; 3 | 4 | interface WorkflowFileConcurrency { 5 | "cancel-in-progress"?: boolean; 6 | group: string; 7 | } 8 | 9 | interface WorkflowFileOn { 10 | discussion?: { 11 | types?: string[]; 12 | }; 13 | discussion_comment?: { 14 | types?: string[]; 15 | }; 16 | issue_comment?: { 17 | types?: string[]; 18 | }; 19 | issues?: { 20 | types?: string[]; 21 | }; 22 | pull_request?: 23 | | null 24 | | string 25 | | { 26 | branches?: string | string[]; 27 | types?: string[]; 28 | }; 29 | pull_request_review_comment?: { 30 | types: string[]; 31 | }; 32 | pull_request_target?: { 33 | types: string[]; 34 | }; 35 | push?: { 36 | branches: string[]; 37 | }; 38 | release?: { 39 | types: string[]; 40 | }; 41 | workflow_dispatch?: null | string; 42 | } 43 | interface WorkflowFileOptions { 44 | concurrency?: WorkflowFileConcurrency; 45 | if?: string; 46 | jobName?: string; 47 | name: string; 48 | on?: WorkflowFileOn; 49 | permissions?: WorkflowFilePermissions; 50 | steps: WorkflowFileStep[]; 51 | } 52 | 53 | interface WorkflowFilePermissions { 54 | contents?: string; 55 | discussions?: string; 56 | "id-token"?: string; 57 | issues?: string; 58 | "pull-requests"?: string; 59 | } 60 | 61 | interface WorkflowFileStep { 62 | env?: Record; 63 | id?: string; 64 | if?: string; 65 | name?: string; 66 | run?: string; 67 | uses?: string; 68 | with?: Record; 69 | } 70 | 71 | export function createSoloWorkflowFile({ 72 | concurrency, 73 | jobName, 74 | name, 75 | on, 76 | permissions, 77 | ...options 78 | }: WorkflowFileOptions) { 79 | return formatWorkflowYaml({ 80 | concurrency, 81 | jobs: { 82 | [createJobName(jobName ?? name)]: { 83 | ...(options.if && { if: options.if }), 84 | ...(jobName && { name: jobName }), 85 | "runs-on": "ubuntu-latest", 86 | steps: options.steps, 87 | }, 88 | }, 89 | name, 90 | on, 91 | permissions, 92 | }); 93 | } 94 | -------------------------------------------------------------------------------- /src/blocks/files/formatIgnoreFile.ts: -------------------------------------------------------------------------------- 1 | export function formatIgnoreFile(lines: (string | undefined)[]) { 2 | return [...lines.filter(Boolean), ""].join("\n"); 3 | } 4 | -------------------------------------------------------------------------------- /src/blocks/files/formatWorkflowYaml.ts: -------------------------------------------------------------------------------- 1 | import { formatYaml } from "./formatYaml.js"; 2 | 3 | export function formatWorkflowYaml(value: unknown) { 4 | return ( 5 | formatYaml(value) 6 | .replaceAll(/\n(\S)/g, "\n\n$1") 7 | // https://github.com/nodeca/js-yaml/pull/515 8 | .replaceAll(/: "\\n(.+)"/g, ": |\n$1") 9 | .replaceAll("\\n", "\n") 10 | .replaceAll("\\t", " ") 11 | ); 12 | } 13 | -------------------------------------------------------------------------------- /src/blocks/files/formatYaml.ts: -------------------------------------------------------------------------------- 1 | import jsYaml from "js-yaml"; 2 | 3 | import { removeUsesQuotes } from "./removeUsesQuotes.js"; 4 | 5 | const options: jsYaml.DumpOptions = { 6 | lineWidth: -1, 7 | noCompatMode: true, 8 | // https://github.com/nodeca/js-yaml/pull/515 9 | replacer(_, value: unknown) { 10 | if (typeof value !== "string" || !value.includes("\n\t\t")) { 11 | return value; 12 | } 13 | 14 | return value 15 | .replaceAll(": |-\n", ": |\n") 16 | .replaceAll("\n\t \t\t\t", "") 17 | .replaceAll(/\n\t\t\t\t\t\t$/g, ""); 18 | }, 19 | sortKeys: true, 20 | styles: { 21 | "!!null": "canonical", 22 | }, 23 | }; 24 | 25 | export function formatYaml(value: unknown) { 26 | return removeUsesQuotes(jsYaml.dump(value, options)).replaceAll( 27 | /\n(\S)/g, 28 | "\n\n$1", 29 | ); 30 | } 31 | -------------------------------------------------------------------------------- /src/blocks/files/removeUsesQuotes.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, test } from "vitest"; 2 | 3 | import { removeUsesQuotes } from "./removeUsesQuotes.js"; 4 | 5 | describe(removeUsesQuotes, () => { 6 | test.each([ 7 | [""], 8 | ["run: pnpm run build"], 9 | ["- uses: actions/checkout@v4"], 10 | ["- uses: 'actions/checkout@v4'", "- uses: actions/checkout@v4"], 11 | ["- uses: actions/checkout@abc # v4"], 12 | [ 13 | "- uses: 'actions/checkout@abc # v4'", 14 | "- uses: actions/checkout@abc # v4", 15 | ], 16 | ])("%s", (input, expected = input) => { 17 | expect(removeUsesQuotes(input)).toBe(expected); 18 | }); 19 | }); 20 | -------------------------------------------------------------------------------- /src/blocks/files/removeUsesQuotes.ts: -------------------------------------------------------------------------------- 1 | export function removeUsesQuotes(original: string) { 2 | return original.replaceAll(/ uses: '.+'/gu, (line) => 3 | line.replaceAll("'", ""), 4 | ); 5 | } 6 | -------------------------------------------------------------------------------- /src/blocks/getInstallationSuggestions.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it } from "vitest"; 2 | 3 | import { getInstallationSuggestions } from "./getInstallationSuggestions.js"; 4 | 5 | const description = "do the action"; 6 | const url = "https://example.com"; 7 | 8 | describe(getInstallationSuggestions, () => { 9 | it("returns undefined when there are no entries", () => { 10 | const actual = getInstallationSuggestions(description, [], url); 11 | 12 | expect(actual).toBeUndefined(); 13 | }); 14 | 15 | it("returns a non-plural list when there is one entry", () => { 16 | const actual = getInstallationSuggestions(description, ["entry"], url); 17 | 18 | expect(actual).toMatchInlineSnapshot(` 19 | [ 20 | "- do the action on https://example.com: 21 | - entry", 22 | ] 23 | `); 24 | }); 25 | 26 | it("returns a plural list when there are multiple entries", () => { 27 | const actual = getInstallationSuggestions( 28 | description, 29 | ["entry a", "entry b"], 30 | url, 31 | ); 32 | 33 | expect(actual).toMatchInlineSnapshot(` 34 | [ 35 | "- do the actions on https://example.com: 36 | - entry a 37 | - entry b", 38 | ] 39 | `); 40 | }); 41 | }); 42 | -------------------------------------------------------------------------------- /src/blocks/getInstallationSuggestions.ts: -------------------------------------------------------------------------------- 1 | export function getInstallationSuggestions( 2 | description: string, 3 | entries: string[], 4 | url: string, 5 | ) { 6 | return entries.length 7 | ? [ 8 | [ 9 | `- ${description}`, 10 | entries.length === 1 ? "" : "s", 11 | ` on ${url}:\n`, 12 | entries.map((entry) => ` - ${entry}`).join("\n"), 13 | ].join(""), 14 | ] 15 | : undefined; 16 | } 17 | -------------------------------------------------------------------------------- /src/blocks/intake/intakeFile.ts: -------------------------------------------------------------------------------- 1 | import { IntakeDirectory, IntakeFileEntry } from "bingo-fs"; 2 | 3 | export function intakeFile( 4 | files: IntakeDirectory, 5 | filePath: (string | string[])[], 6 | ): IntakeFileEntry | undefined { 7 | if (!filePath.length) { 8 | return undefined; 9 | } 10 | 11 | const nextPathCandidates = 12 | typeof filePath[0] === "string" ? [filePath[0]] : filePath[0]; 13 | const nextFilePath = nextPathCandidates.find( 14 | (candidate) => candidate in files, 15 | ); 16 | if (!nextFilePath) { 17 | return undefined; 18 | } 19 | 20 | const entry = files[nextFilePath]; 21 | 22 | if (filePath.length === 1) { 23 | return Array.isArray(entry) ? entry : undefined; 24 | } 25 | 26 | return typeof entry === "object" && !Array.isArray(entry) 27 | ? intakeFile(entry, filePath.slice(1)) 28 | : undefined; 29 | } 30 | -------------------------------------------------------------------------------- /src/blocks/intake/intakeFileAsJson.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it } from "vitest"; 2 | 3 | import { intakeFileAsJson } from "./intakeFileAsJson.js"; 4 | 5 | describe(intakeFileAsJson, () => { 6 | it("returns undefined when the file does not exist", () => { 7 | const actual = intakeFileAsJson({}, []); 8 | 9 | expect(actual).toBeUndefined(); 10 | }); 11 | 12 | it("returns undefined when the file does not have valid JSON", () => { 13 | const actual = intakeFileAsJson( 14 | { 15 | "file.json": ["{"], 16 | }, 17 | ["file.json"], 18 | ); 19 | 20 | expect(actual).toBeUndefined(); 21 | }); 22 | 23 | it("returns loaded file contents when the file has valid JSON", () => { 24 | const value = { key: "value" }; 25 | 26 | const actual = intakeFileAsJson( 27 | { 28 | "file.json": [JSON.stringify(value)], 29 | }, 30 | ["file.json"], 31 | ); 32 | 33 | expect(actual).toEqual(value); 34 | }); 35 | }); 36 | -------------------------------------------------------------------------------- /src/blocks/intake/intakeFileAsJson.ts: -------------------------------------------------------------------------------- 1 | import { IntakeDirectory } from "bingo-fs"; 2 | import JSON5 from "json5"; 3 | 4 | import { intakeFile } from "./intakeFile.js"; 5 | 6 | export function intakeFileAsJson(files: IntakeDirectory, filePath: string[]) { 7 | const file = intakeFile(files, filePath); 8 | 9 | try { 10 | return file && JSON5.parse | undefined>(file[0]); 11 | } catch { 12 | return undefined; 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/blocks/intake/intakeFileAsYaml.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it } from "vitest"; 2 | 3 | import { intakeFileAsYaml } from "./intakeFileAsYaml.js"; 4 | 5 | describe(intakeFileAsYaml, () => { 6 | it("returns undefined when the file does not exist", () => { 7 | const actual = intakeFileAsYaml({}, []); 8 | 9 | expect(actual).toBeUndefined(); 10 | }); 11 | 12 | it("returns undefined when the file does not have valid yml", () => { 13 | const actual = intakeFileAsYaml( 14 | { 15 | "file.yml": ["{"], 16 | }, 17 | ["file.yml"], 18 | ); 19 | 20 | expect(actual).toBeUndefined(); 21 | }); 22 | 23 | it("returns loaded file contents when the file has valid yml", () => { 24 | const actual = intakeFileAsYaml( 25 | { 26 | "file.yml": ["key: value"], 27 | }, 28 | ["file.yml"], 29 | ); 30 | 31 | expect(actual).toEqual({ key: "value" }); 32 | }); 33 | }); 34 | -------------------------------------------------------------------------------- /src/blocks/intake/intakeFileAsYaml.ts: -------------------------------------------------------------------------------- 1 | import { IntakeDirectory } from "bingo-fs"; 2 | import jsYaml from "js-yaml"; 3 | 4 | import { intakeFile } from "./intakeFile.js"; 5 | 6 | export function intakeFileAsYaml(files: IntakeDirectory, filePath: string[]) { 7 | const file = intakeFile(files, filePath); 8 | 9 | try { 10 | return file && jsYaml.load(file[0]); 11 | } catch { 12 | return undefined; 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/blocks/intake/intakeFileDefineConfig.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it } from "vitest"; 2 | 3 | import { intakeFileDefineConfig } from "./intakeFileDefineConfig.js"; 4 | 5 | describe("intake", () => { 6 | it("returns nothing when the filePath file does not exist", () => { 7 | const actual = intakeFileDefineConfig({}, ["file.config.ts"]); 8 | 9 | expect(actual).toBeUndefined(); 10 | }); 11 | 12 | it("returns nothing when the filePath file does not contain the expected defineConfig", () => { 13 | const actual = intakeFileDefineConfig( 14 | { 15 | "file.config.ts": [`invalid`], 16 | }, 17 | ["file.config.ts"], 18 | ); 19 | 20 | expect(actual).toBeUndefined(); 21 | }); 22 | 23 | it("returns nothing when the filePath file passes nothing to defineConfig", () => { 24 | const actual = intakeFileDefineConfig( 25 | { 26 | "file.config.ts": [`defineConfig()`], 27 | }, 28 | ["file.config.ts"], 29 | ); 30 | 31 | expect(actual).toBeUndefined(); 32 | }); 33 | 34 | it("returns nothing when the filePath file passes invalid data to defineConfig", () => { 35 | const actual = intakeFileDefineConfig( 36 | { 37 | "file.config.ts": [`defineConfig(invalid)`], 38 | }, 39 | ["file.config.ts"], 40 | ); 41 | 42 | expect(actual).toBeUndefined(); 43 | }); 44 | 45 | it("returns nothing when the filePath file passes a non-object to defineConfig", () => { 46 | const actual = intakeFileDefineConfig( 47 | { 48 | "file.config.ts": [`defineConfig("invalid")`], 49 | }, 50 | ["file.config.ts"], 51 | ); 52 | 53 | expect(actual).toBeUndefined(); 54 | }); 55 | 56 | it("returns values when they exist in the filePath file", () => { 57 | const actual = intakeFileDefineConfig( 58 | { 59 | "file.config.ts": [ 60 | `import { defineConfig } from "..."; 61 | 62 | export default defineConfig({ 63 | abc: 123, 64 | def: 456, 65 | }); 66 | `, 67 | ], 68 | }, 69 | ["file.config.ts"], 70 | ); 71 | 72 | expect(actual).toEqual({ 73 | abc: 123, 74 | def: 456, 75 | }); 76 | }); 77 | }); 78 | -------------------------------------------------------------------------------- /src/blocks/intake/intakeFileDefineConfig.ts: -------------------------------------------------------------------------------- 1 | import { IntakeDirectory } from "bingo-fs"; 2 | import JSON5 from "json5"; 3 | 4 | import { intakeFile } from "./intakeFile.js"; 5 | 6 | export function intakeFileDefineConfig( 7 | files: IntakeDirectory, 8 | filePath: (string | string[])[], 9 | ): Record | undefined { 10 | const file = intakeFile(files, filePath); 11 | if (!file) { 12 | return undefined; 13 | } 14 | 15 | const normalized = file[0].replaceAll(/[\n\r]/g, ""); 16 | const matched = /defineConfig\(\{(.+)\}\)\s*(?:;\s*)?$/u.exec(normalized); 17 | if (!matched) { 18 | return undefined; 19 | } 20 | 21 | const rawData = tryParseJSON5(`{${matched[1]}}`); 22 | if (!rawData || typeof rawData !== "object") { 23 | return undefined; 24 | } 25 | 26 | return rawData; 27 | } 28 | 29 | function tryParseJSON5(text: string) { 30 | try { 31 | // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion 32 | return JSON5.parse(text) as Record | undefined; 33 | } catch { 34 | return undefined; 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/blocks/options.fakes.ts: -------------------------------------------------------------------------------- 1 | import { BaseOptions } from "../base.js"; 2 | 3 | export const optionsBase = { 4 | access: "public", 5 | description: "Test description", 6 | directory: ".", 7 | documentation: { 8 | readme: { 9 | usage: "Test usage.", 10 | }, 11 | }, 12 | email: { 13 | github: "github@email.com", 14 | npm: "npm@email.com", 15 | }, 16 | emoji: "💖", 17 | node: { 18 | minimum: "20.12.0", 19 | }, 20 | owner: "test-owner", 21 | preset: "minimal", 22 | repository: "test-repository", 23 | title: "Test Title", 24 | } satisfies BaseOptions; 25 | -------------------------------------------------------------------------------- /src/blocks/outcomeLabels.ts: -------------------------------------------------------------------------------- 1 | /* spellchecker:disable */ 2 | export const outcomeLabels = [ 3 | { 4 | aliases: ["docs"], 5 | color: "0075ca", 6 | description: "Improvements or additions to docs 📝", 7 | name: "area: documentation", 8 | }, 9 | { 10 | color: "1177aa", 11 | description: 12 | "Improving how the repository's tests are run and/or code is tested 🧪", 13 | name: "area: testing", 14 | }, 15 | { 16 | color: "f9d0c4", 17 | description: "Managing the repository's maintenance 🛠️", 18 | name: "area: tooling", 19 | }, 20 | { 21 | color: "5319E7", 22 | description: "Good for newcomers, please hop on! 🙌", 23 | name: "good first issue", 24 | }, 25 | { 26 | color: "7a5901", 27 | description: "This doesn't seem right", 28 | name: "invalid", 29 | }, 30 | { 31 | aliases: ["help wanted"], 32 | color: "0E8A16", 33 | description: "Please, send a pull request to resolve this! 🙏", 34 | name: "status: accepting prs", 35 | }, 36 | { 37 | color: "eeeeee", 38 | description: "Issue is stale and/or no longer valid", 39 | name: "status: aged away", 40 | }, 41 | { 42 | color: "ddcccc", 43 | description: "Waiting for something else to be resolved 🙅", 44 | name: "status: blocked", 45 | }, 46 | { 47 | color: "cfd3d7", 48 | description: "This issue or pull request already exists", 49 | name: "status: duplicate", 50 | }, 51 | { 52 | color: "05104F", 53 | description: "Not yet ready for implementation or a pull request", 54 | name: "status: in discussion", 55 | }, 56 | { 57 | color: "D3F82D", 58 | description: "Further research required...? 🔎", 59 | name: "status: needs investigation", 60 | }, 61 | { 62 | color: "E4BC82", 63 | description: "Needs an action taken by the original poster", 64 | name: "status: waiting for author", 65 | }, 66 | { 67 | color: "ffffff", 68 | description: "This will not be worked on", 69 | name: "status: wontfix", 70 | }, 71 | { 72 | color: "d73a4a", 73 | description: "Something isn't working 🐛", 74 | name: "type: bug", 75 | }, 76 | { 77 | aliases: ["enhancement"], 78 | /* spellchecker: disable-next-line */ 79 | color: "a2eeef", 80 | description: "New enhancement or request 🚀", 81 | name: "type: feature", 82 | }, 83 | { 84 | color: "d876e3", 85 | description: "Further information is requested", 86 | name: "type: question", 87 | }, 88 | { 89 | color: "fde282", 90 | description: "Tech debt or other code/repository cleanups 🧹", 91 | name: "type: cleanup", 92 | }, 93 | ]; 94 | -------------------------------------------------------------------------------- /src/blocks/phases.ts: -------------------------------------------------------------------------------- 1 | // eslint-disable-next-line @eslint-community/eslint-comments/disable-enable-pair 2 | /* eslint-disable perfectionist/sort-objects */ 3 | 4 | export const CommandPhase = { 5 | Migrations: 0, 6 | Install: 1, 7 | Build: 2, 8 | Process: 3, 9 | Format: 4, 10 | } as const; 11 | -------------------------------------------------------------------------------- /src/constants.ts: -------------------------------------------------------------------------------- 1 | export const defaults = { 2 | node: { 3 | minimum: "18.3.0", 4 | pinned: "20.18.0", 5 | }, 6 | }; 7 | -------------------------------------------------------------------------------- /src/data/contributions.ts: -------------------------------------------------------------------------------- 1 | export const startingOwnerContributions = [ 2 | "code", 3 | "content", 4 | "doc", 5 | "ideas", 6 | "infra", 7 | "maintenance", 8 | "projectManagement", 9 | "tool", 10 | ]; 11 | -------------------------------------------------------------------------------- /src/data/packageData.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it, vi } from "vitest"; 2 | 3 | import { getPackageDependencies } from "./packageData.js"; 4 | 5 | vi.mock("node:module", () => ({ 6 | createRequire: () => () => ({ 7 | dependencies: { 8 | "package-dep": "0.0.2", 9 | }, 10 | devDependencies: { 11 | "package-dev-dep": "0.0.1", 12 | }, 13 | }), 14 | })); 15 | 16 | describe(getPackageDependencies, () => { 17 | it("returns a devDependency when it exists", () => { 18 | const actual = getPackageDependencies("package-dev-dep"); 19 | 20 | expect(actual).toEqual({ "package-dev-dep": "0.0.1" }); 21 | }); 22 | 23 | it("returns a dependency when it exists", () => { 24 | const actual = getPackageDependencies("package-dep"); 25 | 26 | expect(actual).toEqual({ "package-dep": "0.0.2" }); 27 | }); 28 | 29 | it("throws an error when neither exist", () => { 30 | const act = () => getPackageDependencies("package-unknown"); 31 | 32 | expect(act).toThrowError( 33 | "'package-unknown' is neither in package.json's dependencies nor its devDependencies.", 34 | ); 35 | }); 36 | }); 37 | -------------------------------------------------------------------------------- /src/data/packageData.ts: -------------------------------------------------------------------------------- 1 | import { createRequire } from "node:module"; 2 | 3 | const require = createRequire(import.meta.url); 4 | 5 | export const packageData = 6 | // Importing from above src/ would expand the TS build rootDir 7 | require("../../package.json") as typeof import("../../package.json"); 8 | 9 | export function getPackageDependencies(...names: string[]) { 10 | return Object.fromEntries( 11 | names.map((name) => { 12 | return [name, getPackageDependency(name)]; 13 | }), 14 | ); 15 | } 16 | 17 | export function getPackageDependency(name: string) { 18 | const version = 19 | getPackageInner("devDependencies", name) ?? 20 | getPackageInner("dependencies", name); 21 | 22 | if (!version) { 23 | throw new Error( 24 | `'${name}' is neither in package.json's dependencies nor its devDependencies.`, 25 | ); 26 | } 27 | 28 | return version; 29 | } 30 | 31 | function getPackageInner( 32 | key: "dependencies" | "devDependencies", 33 | name: string, 34 | ) { 35 | const inner = packageData[key]; 36 | 37 | return inner[name as keyof typeof inner] as string | undefined; 38 | } 39 | -------------------------------------------------------------------------------- /src/docsOptions.test.ts: -------------------------------------------------------------------------------- 1 | import * as fs from "node:fs/promises"; 2 | import { describe, expect, it } from "vitest"; 3 | 4 | import { base } from "./index.js"; 5 | 6 | // This test ensures all options are mentioned in either: 7 | // - docs/CLI.md: for options that can be used on the command line 8 | // - docs/Configuration Files.md: for options that can be used in a config file 9 | // 10 | // If this fails, it's likely due to adding, removing, or renaming an option. 11 | // You may need to manually change the docs to match those changes. 12 | // 13 | // For example, if you add an `example: z.boolean().optional` option to Base, 14 | // you'll need to add a row to docs/CLI.md like: 15 | // 16 | // ```md 17 | // | `--example` | `boolean` | same description from base.ts | `false` | 18 | // ``` 19 | describe("Docs: Options", () => { 20 | it("includes mentions of all options from the Base", async () => { 21 | const existingOptions = new Set( 22 | ( 23 | await Promise.all([ 24 | splitFileIntoOptions("docs/CLI.md"), 25 | splitFileIntoOptions("docs/Configuration Files.md"), 26 | ]) 27 | ).flat(), 28 | ); 29 | 30 | const missingOptions = Object.keys(base.options).filter( 31 | (key) => !existingOptions.has(key), 32 | ); 33 | 34 | expect(missingOptions).toEqual([]); 35 | }); 36 | }); 37 | 38 | async function splitFileIntoOptions(filePath: string) { 39 | const text = (await fs.readFile(filePath)).toString(); 40 | 41 | return text 42 | .split(/[\r\n]+/) 43 | .map((line) => /`(?:--)?(\w+)` /.exec(line)?.[1]) 44 | .filter((line) => typeof line === "string"); 45 | } 46 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import { template } from "./template.js"; 2 | 3 | export { template }; 4 | 5 | export const { createConfig } = template; 6 | 7 | export * from "./base.js"; 8 | export * from "./blocks/index.js"; 9 | export * from "./presets/index.js"; 10 | -------------------------------------------------------------------------------- /src/inputs/inputFromDirectory.ts: -------------------------------------------------------------------------------- 1 | import { createInput } from "bingo"; 2 | import { z } from "zod"; 3 | 4 | export const inputFromDirectory = createInput({ 5 | args: { 6 | directoryPath: z.string(), 7 | }, 8 | async produce({ args, fs }) { 9 | return await fs.readDirectory(args.directoryPath); 10 | }, 11 | }); 12 | -------------------------------------------------------------------------------- /src/inputs/inputFromOctokit.test.ts: -------------------------------------------------------------------------------- 1 | import { createMockFetchers, testInput } from "bingo-testers"; 2 | import { describe, expect, it, vi } from "vitest"; 3 | 4 | import { inputFromOctokit } from "./inputFromOctokit.js"; 5 | 6 | describe("inputFromOctokit", () => { 7 | it("returns data when the request resolves", async () => { 8 | const data = JSON.stringify({ found: true }); 9 | 10 | const actual = await testInput(inputFromOctokit, { 11 | args: { 12 | endpoint: "GET /repos/{owner}/{repo}/rulesets", 13 | options: {}, 14 | }, 15 | fetchers: createMockFetchers( 16 | vi.fn().mockResolvedValueOnce( 17 | // eslint-disable-next-line n/no-unsupported-features/node-builtins 18 | new Response(data), 19 | ), 20 | ), 21 | }); 22 | 23 | expect(actual).toEqual(data); 24 | }); 25 | 26 | it("returns undefined when the request rejects", async () => { 27 | const actual = await testInput(inputFromOctokit, { 28 | args: { 29 | endpoint: "GET /repos/{owner}/{repo}/rulesets", 30 | options: {}, 31 | }, 32 | fetchers: createMockFetchers( 33 | vi.fn().mockResolvedValueOnce( 34 | // eslint-disable-next-line n/no-unsupported-features/node-builtins 35 | new Response("", { 36 | status: 404, 37 | statusText: "Not found.", 38 | }), 39 | ), 40 | ), 41 | }); 42 | 43 | expect(actual).toBe(undefined); 44 | }); 45 | }); 46 | -------------------------------------------------------------------------------- /src/inputs/inputFromOctokit.ts: -------------------------------------------------------------------------------- 1 | import { createInput } from "bingo"; 2 | import { z } from "zod"; 3 | 4 | export const inputFromOctokit = createInput({ 5 | args: { 6 | endpoint: z.string(), 7 | options: z.record(z.string(), z.unknown()).optional(), 8 | }, 9 | // TODO: Strongly type this, then push it upstream to Bingo 10 | // https://github.com/JoshuaKGoldberg/bingo/issues/296 11 | async produce({ args, fetchers }): Promise { 12 | try { 13 | const response = await fetchers.octokit.request(args.endpoint, { 14 | headers: { 15 | "X-GitHub-Api-Version": "2022-11-28", 16 | }, 17 | request: { retries: 0 }, 18 | ...args.options, 19 | }); 20 | 21 | return response.data; 22 | } catch { 23 | return undefined; 24 | } 25 | }, 26 | }); 27 | -------------------------------------------------------------------------------- /src/options/readAccess.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it, vi } from "vitest"; 2 | 3 | import { readAccess } from "./readAccess.js"; 4 | 5 | describe(readAccess, () => { 6 | it("resolves with 'public' when package data does not exist", async () => { 7 | const getDescription = vi.fn().mockResolvedValue(undefined); 8 | 9 | const actual = await readAccess(getDescription); 10 | 11 | expect(actual).toBe("public"); 12 | }); 13 | 14 | it("resolves with 'public' when package data does not have publishConfig", async () => { 15 | const getDescription = vi.fn().mockResolvedValue({}); 16 | 17 | const actual = await readAccess(getDescription); 18 | 19 | expect(actual).toBe("public"); 20 | }); 21 | 22 | it("resolves with 'public' when package data has an empty publishConfig", async () => { 23 | const getDescription = vi.fn().mockResolvedValue({ 24 | publishConfig: {}, 25 | }); 26 | 27 | const actual = await readAccess(getDescription); 28 | 29 | expect(actual).toBe("public"); 30 | }); 31 | 32 | it("resolves with the access when package data has a publishConfig with access", async () => { 33 | const access = "restricted"; 34 | const getDescription = vi.fn().mockResolvedValue({ 35 | publishConfig: { access }, 36 | }); 37 | 38 | const actual = await readAccess(getDescription); 39 | 40 | expect(actual).toBe(access); 41 | }); 42 | }); 43 | -------------------------------------------------------------------------------- /src/options/readAccess.ts: -------------------------------------------------------------------------------- 1 | import { PartialPackageData } from "../types.js"; 2 | 3 | export async function readAccess( 4 | getPackageDataFull: () => Promise, 5 | ) { 6 | return (await getPackageDataFull())?.publishConfig?.access ?? "public"; 7 | } 8 | -------------------------------------------------------------------------------- /src/options/readAllContributors.test.ts: -------------------------------------------------------------------------------- 1 | import { createMockSystems } from "bingo-testers"; 2 | import { describe, expect, it, vi } from "vitest"; 3 | 4 | import { startingOwnerContributions } from "../data/contributions.js"; 5 | import { readAllContributors } from "./readAllContributors.js"; 6 | 7 | const mockInputFromFileJSON = vi.fn(); 8 | 9 | vi.mock("input-from-file-json", () => ({ 10 | get inputFromFileJSON() { 11 | return mockInputFromFileJSON; 12 | }, 13 | })); 14 | 15 | const mockInputFromOctokit = vi.fn(); 16 | 17 | vi.mock("../inputs/inputFromOctokit.js", () => ({ 18 | get inputFromOctokit() { 19 | return mockInputFromOctokit; 20 | }, 21 | })); 22 | 23 | const { take } = createMockSystems(); 24 | 25 | describe(readAllContributors, () => { 26 | it("returns contributors from .all-contributorsrc when it can be read", async () => { 27 | const contributors = ["a", "b", "c"]; 28 | mockInputFromFileJSON.mockResolvedValueOnce({ contributors }); 29 | 30 | const actual = await readAllContributors(take); 31 | 32 | expect(actual).toEqual(contributors); 33 | expect(mockInputFromOctokit).not.toHaveBeenCalled(); 34 | }); 35 | 36 | it("returns undefined when .all-contributorsrc cannot be read and GET /user resolves undefined", async () => { 37 | mockInputFromFileJSON.mockResolvedValueOnce(new Error("Oh no!")); 38 | mockInputFromOctokit.mockResolvedValueOnce(undefined); 39 | 40 | const actual = await readAllContributors(take); 41 | 42 | expect(actual).toBeUndefined(); 43 | }); 44 | 45 | it("returns the current user as a contributor when .all-contributorsrc cannot be read and GET /user resolves a user", async () => { 46 | mockInputFromFileJSON.mockResolvedValueOnce(new Error("Oh no!")); 47 | mockInputFromOctokit.mockResolvedValueOnce({ 48 | avatar_url: "avatar_url", 49 | blog: "blog", 50 | login: "login", 51 | name: "name", 52 | }); 53 | 54 | const actual = await readAllContributors(take); 55 | 56 | expect(actual).toEqual([ 57 | { 58 | avatar_url: "avatar_url", 59 | contributions: startingOwnerContributions, 60 | login: "login", 61 | name: "name", 62 | profile: "blog", 63 | }, 64 | ]); 65 | }); 66 | }); 67 | -------------------------------------------------------------------------------- /src/options/readAllContributors.ts: -------------------------------------------------------------------------------- 1 | import { TakeInput } from "bingo"; 2 | import { inputFromFileJSON } from "input-from-file-json"; 3 | 4 | import { startingOwnerContributions } from "../data/contributions.js"; 5 | import { inputFromOctokit } from "../inputs/inputFromOctokit.js"; 6 | import { AllContributorsData } from "../types.js"; 7 | 8 | export async function readAllContributors(take: TakeInput) { 9 | const contributions = (await take(inputFromFileJSON, { 10 | filePath: ".all-contributorsrc", 11 | })) as AllContributorsData | Error; 12 | 13 | if (!(contributions instanceof Error)) { 14 | return contributions.contributors; 15 | } 16 | 17 | const user = (await take(inputFromOctokit, { 18 | endpoint: "GET /user", 19 | })) as 20 | | undefined 21 | | { avatar_url: string; blog: string; login: string; name: string }; 22 | 23 | return ( 24 | user && [ 25 | { 26 | avatar_url: user.avatar_url, 27 | contributions: startingOwnerContributions, 28 | login: user.login, 29 | name: user.name, 30 | profile: user.blog, 31 | }, 32 | ] 33 | ); 34 | } 35 | -------------------------------------------------------------------------------- /src/options/readAuthor.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it, vi } from "vitest"; 2 | 3 | import { readAuthor } from "./readAuthor.js"; 4 | 5 | describe(readAuthor, () => { 6 | it("returns package author when it exists", async () => { 7 | const name = "test-author"; 8 | const getNpmDefaults = vi.fn(); 9 | 10 | const actual = await readAuthor( 11 | () => Promise.resolve({ name }), 12 | getNpmDefaults, 13 | undefined, 14 | ); 15 | 16 | expect(actual).toBe(name); 17 | expect(getNpmDefaults).not.toHaveBeenCalled(); 18 | }); 19 | 20 | it("returns npm defaults name when only it exists", async () => { 21 | const name = "test-name"; 22 | 23 | const actual = await readAuthor( 24 | () => Promise.resolve({}), 25 | () => Promise.resolve({ name }), 26 | undefined, 27 | ); 28 | 29 | expect(actual).toBe(name); 30 | }); 31 | 32 | it("returns owner when only it exists", async () => { 33 | const owner = "test-owner"; 34 | 35 | const actual = await readAuthor( 36 | () => Promise.resolve({}), 37 | () => Promise.resolve(undefined), 38 | owner, 39 | ); 40 | 41 | expect(actual).toBe(owner); 42 | }); 43 | 44 | it("returns undefined when no sources provide a value", async () => { 45 | const actual = await readAuthor( 46 | () => Promise.resolve({}), 47 | () => Promise.resolve(undefined), 48 | undefined, 49 | ); 50 | 51 | expect(actual).toBeUndefined(); 52 | }); 53 | }); 54 | -------------------------------------------------------------------------------- /src/options/readAuthor.ts: -------------------------------------------------------------------------------- 1 | import { PackageAuthor } from "./readPackageAuthor.js"; 2 | 3 | export async function readAuthor( 4 | getPackageAuthor: () => Promise, 5 | getNpmDefaults: () => Promise, 6 | owner: string | undefined, 7 | ) { 8 | return ( 9 | (await getPackageAuthor()).name ?? (await getNpmDefaults())?.name ?? owner 10 | ); 11 | } 12 | -------------------------------------------------------------------------------- /src/options/readBin.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it } from "vitest"; 2 | 3 | import { readBin } from "./readBin.js"; 4 | 5 | describe(readBin, () => { 6 | it("resolves with undefined when package data has no bin", async () => { 7 | const getPackageData = () => Promise.resolve({}); 8 | 9 | const actual = await readBin(getPackageData); 10 | 11 | expect(actual).toBe(undefined); 12 | }); 13 | 14 | it("resolves with a trimmed string when the package data has a string bin", async () => { 15 | const getPackageData = () => 16 | Promise.resolve({ 17 | bin: "./index.js", 18 | }); 19 | 20 | const actual = await readBin(getPackageData); 21 | 22 | expect(actual).toBe("index.js"); 23 | }); 24 | 25 | it("resolves with an object of trimmed bins when the package data has a string bin", async () => { 26 | const getPackageData = () => 27 | Promise.resolve({ 28 | bin: { 29 | absolute: "index.js", 30 | relative: "./index.js", 31 | }, 32 | }); 33 | 34 | const actual = await readBin(getPackageData); 35 | 36 | expect(actual).toEqual({ 37 | absolute: "index.js", 38 | relative: "index.js", 39 | }); 40 | }); 41 | }); 42 | -------------------------------------------------------------------------------- /src/options/readBin.ts: -------------------------------------------------------------------------------- 1 | import { PartialPackageData } from "../types.js"; 2 | import { trimPrecedingSlash } from "../utils/trimPrecedingSlash.js"; 3 | 4 | export async function readBin( 5 | getPackageData: () => Promise, 6 | ) { 7 | const { bin } = await getPackageData(); 8 | 9 | return typeof bin === "object" 10 | ? (Object.fromEntries( 11 | Object.entries(bin).map(([key, value]) => [ 12 | key, 13 | trimPrecedingSlash(value), 14 | ]), 15 | ) as typeof bin) 16 | : trimPrecedingSlash(bin); 17 | } 18 | -------------------------------------------------------------------------------- /src/options/readDescription.ts: -------------------------------------------------------------------------------- 1 | import { marked } from "marked"; 2 | 3 | import { packageData } from "../data/packageData.js"; 4 | import { PartialPackageData } from "../types.js"; 5 | import { htmlToTextSafe } from "../utils/htmlToTextSafe.js"; 6 | import { readDescriptionFromReadme } from "./readDescriptionFromReadme.js"; 7 | 8 | export async function readDescription( 9 | getPackageData: () => Promise, 10 | getReadme: () => Promise, 11 | getRepository: () => Promise, 12 | ) { 13 | // If we there is no package.json yet, this is probably setup mode. 14 | const { description: fromPackageJson } = await getPackageData(); 15 | if (!fromPackageJson) { 16 | return undefined; 17 | } 18 | 19 | // If only a a package.json exists, this is probably transition mode. 20 | // We can use the package.json's description as the only source of truth. 21 | const fromReadme = await readDescriptionFromReadme(getReadme); 22 | if (!fromReadme) { 23 | return marked.parseInline(fromPackageJson); 24 | } 25 | 26 | // If the package.json is create-typescript-app's but the repository isn't, 27 | // we're almost certainly in transition mode after cloning the template. 28 | if ( 29 | (await getRepository()) !== "create-typescript-app" && 30 | fromPackageJson === packageData.description 31 | ) { 32 | return undefined; 33 | } 34 | 35 | const fromPackageJsonNormalized = htmlToTextSafe( 36 | await marked.parseInline(fromPackageJson), 37 | ); 38 | const fromReadmeNormalized = htmlToTextSafe(fromReadme); 39 | 40 | // If the package.json and README.md don't match, we prefer the package.json, 41 | // as it's what is used in publishing to npm. 42 | if (fromReadmeNormalized !== fromPackageJsonNormalized) { 43 | return await marked.parseInline(fromPackageJson); 44 | } 45 | 46 | // Otherwise, if they do match, the README.md may have more rich HTML text. 47 | return fromReadme; 48 | } 49 | -------------------------------------------------------------------------------- /src/options/readDescriptionFromReadme.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it } from "vitest"; 2 | 3 | import { readDescriptionFromReadme } from "./readDescriptionFromReadme.js"; 4 | 5 | describe(readDescriptionFromReadme, () => { 6 | it("returns undefined when the paragraph starter is not found", async () => { 7 | const description = await readDescriptionFromReadme(() => 8 | Promise.resolve(""), 9 | ); 10 | 11 | expect(description).toBeUndefined(); 12 | }); 13 | 14 | it("returns undefined when the paragraph closer is not found", async () => { 15 | const description = await readDescriptionFromReadme(() => 16 | Promise.resolve(`

`), 17 | ); 18 | 19 | expect(description).toBeUndefined(); 20 | }); 21 | 22 | it("returns the description when it exists on one line inside the paragraph", async () => { 23 | const description = await readDescriptionFromReadme(() => 24 | Promise.resolve(`

Description.

`), 25 | ); 26 | 27 | expect(description).toBe("Description."); 28 | }); 29 | it("returns the description when it exists encoded across multiple lines inside the paragraph", async () => { 30 | const description = await readDescriptionFromReadme(() => 31 | Promise.resolve(` 32 |

33 | Description hooray. 34 |

`), 35 | ); 36 | 37 | expect(description).toBe("Description hooray."); 38 | }); 39 | }); 40 | -------------------------------------------------------------------------------- /src/options/readDescriptionFromReadme.ts: -------------------------------------------------------------------------------- 1 | const paragraphCloser = "

"; 2 | const paragraphStarter = `

`; 3 | 4 | export async function readDescriptionFromReadme( 5 | getReadme: () => Promise, 6 | ) { 7 | const readme = await getReadme(); 8 | 9 | const paragraphStart = readme.indexOf(paragraphStarter); 10 | if (paragraphStart === -1) { 11 | return undefined; 12 | } 13 | 14 | const paragraphEnd = readme.indexOf(paragraphCloser); 15 | if (paragraphEnd < paragraphStart + paragraphStarter.length + 2) { 16 | return undefined; 17 | } 18 | 19 | return readme 20 | .slice(paragraphStart + paragraphStarter.length, paragraphEnd) 21 | .replaceAll(/\s+/gu, " ") 22 | .trim(); 23 | } 24 | -------------------------------------------------------------------------------- /src/options/readDevelopmentDocumentation.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it } from "vitest"; 2 | 3 | import { readDevelopmentDocumentation } from "./readDevelopmentDocumentation.js"; 4 | 5 | describe(readDevelopmentDocumentation, () => { 6 | it("returns undefined when no .github/DEVELOPMENT.md exists", async () => { 7 | const documentation = await readDevelopmentDocumentation(() => 8 | Promise.resolve(undefined), 9 | ); 10 | 11 | expect(documentation).toBeUndefined(); 12 | }); 13 | 14 | it("filters known headings when .github/DEVELOPMENT.md exists", async () => { 15 | const documentation = await readDevelopmentDocumentation(() => 16 | Promise.resolve(`# Development\nremoved\n\n## Unknown\n\nKept.\n`), 17 | ); 18 | 19 | expect(documentation).toMatchInlineSnapshot(` 20 | "## Unknown 21 | 22 | Kept. 23 | " 24 | `); 25 | }); 26 | }); 27 | -------------------------------------------------------------------------------- /src/options/readDevelopmentDocumentation.ts: -------------------------------------------------------------------------------- 1 | import { TakeInput } from "bingo"; 2 | import { inputFromFile } from "input-from-file"; 3 | 4 | import { swallowError } from "../utils/swallowError.js"; 5 | 6 | const knownHeadings = new Set([ 7 | "building", 8 | "development", 9 | "formatting", 10 | "linting", 11 | "testing", 12 | "type checking", 13 | ]); 14 | 15 | export async function readDevelopmentDocumentation(take: TakeInput) { 16 | const existing = swallowError( 17 | await take(inputFromFile, { 18 | filePath: ".github/DEVELOPMENT.md", 19 | }), 20 | ); 21 | if (!existing) { 22 | return undefined; 23 | } 24 | 25 | return existing 26 | .split(/\n\n(?=##\s)/) 27 | .filter((section) => !knownHeadings.has(parseHeading(section))) 28 | .join("\n\n"); 29 | } 30 | 31 | function parseHeading(section: string) { 32 | return section 33 | .split("\n")[0] 34 | .replace(/^#+\s+/, "") 35 | .trim() 36 | .toLowerCase(); 37 | } 38 | -------------------------------------------------------------------------------- /src/options/readDocumentation.ts: -------------------------------------------------------------------------------- 1 | import { Documentation } from "../schemas.js"; 2 | 3 | export async function readDocumentation( 4 | getDevelopmentDocumentation: () => Promise, 5 | getReadmeAdditional: () => Promise, 6 | getReadmeExplainer: () => Promise, 7 | getReadmeFootnotes: () => Promise, 8 | getReadmeUsage: () => Promise, 9 | ): Promise { 10 | const [additional, explainer, footnotes, development, usage] = 11 | await Promise.all([ 12 | getReadmeAdditional(), 13 | getReadmeExplainer(), 14 | getReadmeFootnotes(), 15 | getDevelopmentDocumentation(), 16 | getReadmeUsage(), 17 | ]); 18 | 19 | return { 20 | development, 21 | readme: { 22 | additional, 23 | explainer, 24 | footnotes, 25 | usage, 26 | }, 27 | }; 28 | } 29 | -------------------------------------------------------------------------------- /src/options/readEmailFromCodeOfConduct.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it, vi } from "vitest"; 2 | 3 | import { readEmailFromCodeOfConduct } from "./readEmailFromCodeOfConduct.js"; 4 | 5 | describe(readEmailFromCodeOfConduct, () => { 6 | it("resolves undefined when CODE_OF_CONDUCT.md cannot be read", async () => { 7 | const take = vi.fn().mockResolvedValueOnce(new Error("Oh no!")); 8 | 9 | const actual = await readEmailFromCodeOfConduct(take); 10 | 11 | expect(actual).toBeUndefined(); 12 | }); 13 | 14 | it("resolves undefined when CODE_OF_CONDUCT.md is not the Contributor Code of Conduct", async () => { 15 | const take = vi.fn().mockResolvedValueOnce("# Some Other Code of Conduct"); 16 | 17 | const actual = await readEmailFromCodeOfConduct(take); 18 | 19 | expect(actual).toBeUndefined(); 20 | }); 21 | 22 | it("resolves undefined when CODE_OF_CONDUCT.md is a Contributor Code of Conduct without an email", async () => { 23 | const take = vi.fn() 24 | .mockResolvedValueOnce(`# Contributor Covenant Code of Conduct 25 | 26 | for enforcement at. 27 | `); 28 | 29 | const actual = await readEmailFromCodeOfConduct(take); 30 | 31 | expect(actual).toBeUndefined(); 32 | }); 33 | 34 | it("resolves the email when CODE_OF_CONDUCT.md is a Contributor Code of Conduct with an email", async () => { 35 | const email = "test-email"; 36 | const take = vi.fn() 37 | .mockResolvedValueOnce(`# Contributor Covenant Code of Conduct 38 | 39 | reported to the community leaders responsible for enforcement at 40 | ${email}. 41 | All complaints will be reviewed and investigated promptly and fairly. 42 | `); 43 | 44 | const actual = await readEmailFromCodeOfConduct(take); 45 | 46 | expect(actual).toBe(email); 47 | }); 48 | }); 49 | -------------------------------------------------------------------------------- /src/options/readEmailFromCodeOfConduct.ts: -------------------------------------------------------------------------------- 1 | import { TakeInput } from "bingo"; 2 | import { inputFromFile } from "input-from-file"; 3 | 4 | export async function readEmailFromCodeOfConduct(take: TakeInput) { 5 | const codeOfConduct = await take(inputFromFile, { 6 | filePath: ".github/CODE_OF_CONDUCT.md", 7 | }); 8 | 9 | return typeof codeOfConduct === "string" && 10 | codeOfConduct.includes("Contributor Covenant Code of Conduct") 11 | ? /for enforcement at[\r\n]+(.+)\.[\r\n]+All/.exec(codeOfConduct)?.[1] 12 | : undefined; 13 | } 14 | -------------------------------------------------------------------------------- /src/options/readEmailFromGit.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it, vi } from "vitest"; 2 | 3 | import { readEmailFromGit } from "./readEmailFromGit.js"; 4 | 5 | describe(readEmailFromGit, () => { 6 | it("resolves the git config email when it can be found", async () => { 7 | const email = "test-email"; 8 | const take = vi.fn().mockResolvedValueOnce({ stdout: email }); 9 | 10 | const actual = await readEmailFromGit(take); 11 | 12 | expect(actual).toBe(email); 13 | }); 14 | 15 | it("resolves undefined when there is no git config email", async () => { 16 | const take = vi.fn().mockResolvedValueOnce({ stdout: undefined }); 17 | 18 | const actual = await readEmailFromGit(take); 19 | 20 | expect(actual).toBeUndefined(); 21 | }); 22 | }); 23 | -------------------------------------------------------------------------------- /src/options/readEmailFromGit.ts: -------------------------------------------------------------------------------- 1 | import { TakeInput } from "bingo"; 2 | import { inputFromScript } from "input-from-script"; 3 | 4 | export async function readEmailFromGit(take: TakeInput) { 5 | return ( 6 | await take(inputFromScript, { command: "git config --get user.email" }) 7 | ).stdout?.toString(); 8 | } 9 | -------------------------------------------------------------------------------- /src/options/readEmailFromNpm.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it, vi } from "vitest"; 2 | 3 | import { readEmailFromNpm } from "./readEmailFromNpm.js"; 4 | 5 | describe(readEmailFromNpm, () => { 6 | it("resolves the npm defaults email when it exists", async () => { 7 | const email = "test-email"; 8 | const getNpmDefaults = vi.fn().mockResolvedValueOnce({ email }); 9 | const getPackageAuthor = vi.fn(); 10 | 11 | const actual = await readEmailFromNpm(getNpmDefaults, getPackageAuthor); 12 | 13 | expect(actual).toBe(email); 14 | expect(getPackageAuthor).not.toHaveBeenCalled(); 15 | }); 16 | 17 | it("resolves the package author email when only it exists", async () => { 18 | const email = "test-email"; 19 | const getNpmDefaults = vi.fn().mockResolvedValueOnce(undefined); 20 | const getPackageAuthor = vi.fn().mockResolvedValueOnce({ email }); 21 | 22 | const actual = await readEmailFromNpm(getNpmDefaults, getPackageAuthor); 23 | 24 | expect(actual).toBe(email); 25 | }); 26 | }); 27 | -------------------------------------------------------------------------------- /src/options/readEmailFromNpm.ts: -------------------------------------------------------------------------------- 1 | import { UserInfo } from "npm-user"; 2 | 3 | import { PackageAuthor } from "./readPackageAuthor.js"; 4 | 5 | export async function readEmailFromNpm( 6 | getNpmDefaults: () => Promise, 7 | getPackageAuthor: () => Promise, 8 | ) { 9 | return (await getNpmDefaults())?.email ?? (await getPackageAuthor()).email; 10 | } 11 | -------------------------------------------------------------------------------- /src/options/readEmails.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it } from "vitest"; 2 | 3 | import { readEmails } from "./readEmails.js"; 4 | 5 | const emailCoC = "test-email-coc"; 6 | const emailGit = "test-email-git"; 7 | const emailNpm = "test-email-npm"; 8 | 9 | describe(readEmails, () => { 10 | it("resolves undefined when no sources provide an email", async () => { 11 | const actual = await readEmails( 12 | () => Promise.resolve(undefined), 13 | () => Promise.resolve(undefined), 14 | () => Promise.resolve(undefined), 15 | () => Promise.resolve({}), 16 | ); 17 | 18 | expect(actual).toBeUndefined(); 19 | }); 20 | 21 | it("resolves github and npm as the code of conduct email when only the code of conduct email exists", async () => { 22 | const actual = await readEmails( 23 | () => Promise.resolve(emailCoC), 24 | () => Promise.resolve(undefined), 25 | () => Promise.resolve(undefined), 26 | () => Promise.resolve({}), 27 | ); 28 | 29 | expect(actual).toEqual({ github: emailCoC, npm: emailCoC }); 30 | }); 31 | 32 | it("resolves github and npm as the npm email when only the npm email exists", async () => { 33 | const actual = await readEmails( 34 | () => Promise.resolve(undefined), 35 | () => Promise.resolve(undefined), 36 | () => Promise.resolve(emailNpm), 37 | () => Promise.resolve({}), 38 | ); 39 | 40 | expect(actual).toEqual({ github: emailNpm, npm: emailNpm }); 41 | }); 42 | 43 | it("resolves github as the code of conduct email and npm as the npm email when only those emails exist", async () => { 44 | const actual = await readEmails( 45 | () => Promise.resolve(emailCoC), 46 | () => Promise.resolve(undefined), 47 | () => Promise.resolve(emailNpm), 48 | () => Promise.resolve({}), 49 | ); 50 | 51 | expect(actual).toEqual({ github: emailCoC, npm: emailNpm }); 52 | }); 53 | 54 | it("resolves github and npm as their emails when only those emails exist", async () => { 55 | const actual = await readEmails( 56 | () => Promise.resolve(undefined), 57 | () => Promise.resolve(emailGit), 58 | () => Promise.resolve(emailNpm), 59 | () => Promise.resolve({}), 60 | ); 61 | 62 | expect(actual).toEqual({ github: emailGit, npm: emailNpm }); 63 | }); 64 | 65 | it("resolves package author email as the github and npm emails when only the package author email exists", async () => { 66 | const actual = await readEmails( 67 | () => Promise.resolve(undefined), 68 | () => Promise.resolve(undefined), 69 | () => Promise.resolve(undefined), 70 | () => Promise.resolve({ email: emailNpm }), 71 | ); 72 | 73 | expect(actual).toEqual({ github: emailNpm, npm: emailNpm }); 74 | }); 75 | }); 76 | -------------------------------------------------------------------------------- /src/options/readEmails.ts: -------------------------------------------------------------------------------- 1 | import { PackageAuthor } from "./readPackageAuthor.js"; 2 | 3 | export async function readEmails( 4 | getEmailFromCodeOfConduct: () => Promise, 5 | getEmailFromGit: () => Promise, 6 | getEmailFromNpm: () => Promise, 7 | getPackageAuthor: () => Promise, 8 | ) { 9 | const github = 10 | (await getEmailFromCodeOfConduct()) ?? (await getEmailFromGit()); 11 | const npm = 12 | (await getPackageAuthor()).email ?? (await getEmailFromNpm()) ?? github; 13 | 14 | return npm ? { github: github || npm, npm } : undefined; 15 | } 16 | -------------------------------------------------------------------------------- /src/options/readEmoji.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it, vi } from "vitest"; 2 | 3 | import { readEmoji } from "./readEmoji.js"; 4 | 5 | describe(readEmoji, () => { 6 | it("resolves with undefined when description is undefined", async () => { 7 | const getDescription = vi.fn().mockResolvedValue(undefined); 8 | 9 | const actual = await readEmoji(getDescription); 10 | 11 | expect(actual).toBe("💖"); 12 | }); 13 | 14 | it("resolves with undefined when the description does not have any emoji", async () => { 15 | const getDescription = () => Promise.resolve("Hello world."); 16 | 17 | const actual = await readEmoji(getDescription); 18 | 19 | expect(actual).toBe("💖"); 20 | }); 21 | 22 | it("resolves with the emoji when the description has one emoji", async () => { 23 | const getDescription = () => Promise.resolve("Hello. 😊"); 24 | 25 | const actual = await readEmoji(getDescription); 26 | 27 | expect(actual).toBe("😊"); 28 | }); 29 | 30 | it("resolves with the last emoji when the description has multiple emoji", async () => { 31 | const getDescription = () => Promise.resolve("Hello 🌍. 😊"); 32 | 33 | const actual = await readEmoji(getDescription); 34 | 35 | expect(actual).toBe("😊"); 36 | }); 37 | }); 38 | -------------------------------------------------------------------------------- /src/options/readEmoji.ts: -------------------------------------------------------------------------------- 1 | export async function readEmoji( 2 | getDescription: () => Promise, 3 | ) { 4 | return ( 5 | (await getDescription())?.match(/\p{Extended_Pictographic}/gu)?.at(-1) ?? 6 | "💖" 7 | ); 8 | } 9 | -------------------------------------------------------------------------------- /src/options/readExistingLabels.test.ts: -------------------------------------------------------------------------------- 1 | import { githubDefaultLabels } from "github-default-labels"; 2 | import { describe, expect, it, vi } from "vitest"; 3 | 4 | import { inputFromOctokit } from "../inputs/inputFromOctokit.js"; 5 | import { readExistingLabels } from "./readExistingLabels.js"; 6 | 7 | const owner = "TestOwner"; 8 | const repository = "test-repository"; 9 | 10 | describe(readExistingLabels, () => { 11 | it("returns default labels when owner is undefined", async () => { 12 | const take = vi.fn(); 13 | 14 | const actual = await readExistingLabels( 15 | take, 16 | () => Promise.resolve(undefined), 17 | () => Promise.resolve(repository), 18 | ); 19 | 20 | expect(actual).toBe(githubDefaultLabels); 21 | expect(take).not.toHaveBeenCalled(); 22 | }); 23 | 24 | it("returns default labels when repository is undefined", async () => { 25 | const take = vi.fn(); 26 | 27 | const actual = await readExistingLabels( 28 | take, 29 | () => Promise.resolve(owner), 30 | () => Promise.resolve(undefined), 31 | ); 32 | 33 | expect(actual).toBe(githubDefaultLabels); 34 | expect(take).not.toHaveBeenCalled(); 35 | }); 36 | 37 | it("returns default labels when owner and repository are defined but the GET call fails", async () => { 38 | const take = vi.fn().mockResolvedValueOnce(undefined); 39 | 40 | const actual = await readExistingLabels( 41 | take, 42 | () => Promise.resolve(owner), 43 | () => Promise.resolve(repository), 44 | ); 45 | 46 | expect(actual).toBe(githubDefaultLabels); 47 | expect(take).toHaveBeenCalledWith(inputFromOctokit, { 48 | endpoint: "GET /repos/{owner}/{repo}/labels", 49 | options: { 50 | owner, 51 | repo: repository, 52 | }, 53 | }); 54 | }); 55 | 56 | it("returns the repository's labels when owner and repository are defined and the GET call succeeds", async () => { 57 | const labels = [ 58 | { color: "ffffff", description: "Welcome!", name: "good first issue" }, 59 | ]; 60 | const take = vi.fn().mockResolvedValueOnce(labels); 61 | 62 | const actual = await readExistingLabels( 63 | take, 64 | () => Promise.resolve(owner), 65 | () => Promise.resolve(repository), 66 | ); 67 | 68 | expect(actual).toEqual(labels); 69 | expect(take).toHaveBeenCalledWith(inputFromOctokit, { 70 | endpoint: "GET /repos/{owner}/{repo}/labels", 71 | options: { 72 | owner, 73 | repo: repository, 74 | }, 75 | }); 76 | }); 77 | }); 78 | -------------------------------------------------------------------------------- /src/options/readExistingLabels.ts: -------------------------------------------------------------------------------- 1 | import { TakeInput } from "bingo"; 2 | import { githubDefaultLabels } from "github-default-labels"; 3 | import { OutcomeLabel } from "set-github-repository-labels"; 4 | 5 | import { inputFromOctokit } from "../inputs/inputFromOctokit.js"; 6 | 7 | export async function readExistingLabels( 8 | take: TakeInput, 9 | getOwner: () => Promise, 10 | getRepository: () => Promise, 11 | ) { 12 | const [owner, repository] = await Promise.all([getOwner(), getRepository()]); 13 | 14 | // When transitioning an existing repo, it should already have labels 15 | const existingLabelsActual = 16 | owner && 17 | repository && 18 | ((await take(inputFromOctokit, { 19 | endpoint: "GET /repos/{owner}/{repo}/labels", 20 | options: { 21 | owner, 22 | repo: repository, 23 | }, 24 | })) as OutcomeLabel[] | undefined); 25 | 26 | if (existingLabelsActual) { 27 | // The labels API includes more properties than we use 28 | return existingLabelsActual.map((label) => ({ 29 | color: label.color, 30 | description: label.description, 31 | name: label.name, 32 | })); 33 | } 34 | 35 | // In setup mode, options evaluate before creating the repository. 36 | // We'd want the owner's defaults in case they've customized them... 37 | // ...except, GitHub doesn't have an API for this 🙁. 38 | // We instead go with the known default labels: 39 | return githubDefaultLabels; 40 | } 41 | -------------------------------------------------------------------------------- /src/options/readFileAsJson.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it, vi } from "vitest"; 2 | 3 | import { readFileAsJson } from "./readFileAsJson.js"; 4 | 5 | const mockReadFile = vi.fn(); 6 | 7 | vi.mock("node:fs/promises", () => ({ 8 | get readFile() { 9 | return mockReadFile; 10 | }, 11 | })); 12 | 13 | describe(readFileAsJson, () => { 14 | it("returns the file's parsed contents when it exists", async () => { 15 | const data = { abc: 123 }; 16 | 17 | mockReadFile.mockResolvedValue(JSON.stringify(data)); 18 | 19 | const actual = await readFileAsJson("filePath.json"); 20 | 21 | expect(actual).toEqual(data); 22 | }); 23 | 24 | it("throws an error when the file doesn't exist", async () => { 25 | const error = new Error("Oh no!"); 26 | 27 | mockReadFile.mockRejectedValue(error); 28 | 29 | await expect(() => readFileAsJson("filePath.json")).rejects.toEqual( 30 | new Error( 31 | `Could not read file from filePath.json as JSON. Please ensure the file exists and is valid JSON.`, 32 | { cause: error }, 33 | ), 34 | ); 35 | }); 36 | }); 37 | -------------------------------------------------------------------------------- /src/options/readFileAsJson.ts: -------------------------------------------------------------------------------- 1 | import * as fs from "node:fs/promises"; 2 | 3 | export async function readFileAsJson(filePath: string) { 4 | try { 5 | return JSON.parse((await fs.readFile(filePath)).toString()) as unknown; 6 | } catch (error) { 7 | throw new Error( 8 | `Could not read file from ${filePath} as JSON. Please ensure the file exists and is valid JSON.`, 9 | { cause: error }, 10 | ); 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /src/options/readFileSafe.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it, vi } from "vitest"; 2 | 3 | import { readFileSafe } from "./readFileSafe.js"; 4 | 5 | const mockReadFile = vi.fn(); 6 | 7 | vi.mock("node:fs/promises", () => ({ 8 | get readFile() { 9 | return mockReadFile; 10 | }, 11 | })); 12 | 13 | describe(readFileSafe, () => { 14 | it("outputs the file content as string when it exists", async () => { 15 | mockReadFile.mockResolvedValue("File content as string"); 16 | const result = await readFileSafe("/path/to/file.ext", "fallback"); 17 | expect(result).toBe("File content as string"); 18 | }); 19 | 20 | it("returns fallback when readFile fails", async () => { 21 | mockReadFile.mockRejectedValue("Oops"); 22 | const result = await readFileSafe("/path/to/nowhere.ext", "fallback"); 23 | expect(result).toBe("fallback"); 24 | }); 25 | }); 26 | -------------------------------------------------------------------------------- /src/options/readFileSafe.ts: -------------------------------------------------------------------------------- 1 | import * as fs from "node:fs/promises"; 2 | 3 | export async function readFileSafe(filePath: string | URL, fallback: string) { 4 | try { 5 | return (await fs.readFile(filePath)).toString(); 6 | } catch { 7 | return fallback; 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /src/options/readFunding.ts: -------------------------------------------------------------------------------- 1 | import { TakeInput } from "bingo"; 2 | import { inputFromFile } from "input-from-file"; 3 | 4 | import { swallowError } from "../utils/swallowError.js"; 5 | 6 | export async function readFunding(take: TakeInput) { 7 | return swallowError( 8 | await take(inputFromFile, { filePath: ".github/FUNDING.yml" }), 9 | ) 10 | ?.split(":")[1] 11 | ?.trim(); 12 | } 13 | -------------------------------------------------------------------------------- /src/options/readGitDefaults.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it, vi } from "vitest"; 2 | 3 | import { readGitDefaults } from "./readGitDefaults.js"; 4 | 5 | const mockGitRemoteOriginUrl = vi.fn(); 6 | 7 | vi.mock("git-remote-origin-url", () => ({ 8 | get default() { 9 | return mockGitRemoteOriginUrl; 10 | }, 11 | })); 12 | 13 | describe(readGitDefaults, () => { 14 | it("resolves undefined when get-url origin has no stdout", async () => { 15 | const take = vi.fn().mockResolvedValueOnce({}); 16 | 17 | const actual = await readGitDefaults(take); 18 | 19 | expect(actual).toBeUndefined(); 20 | }); 21 | 22 | it("resolves the parsed url when get-url origin url succeeds", async () => { 23 | const take = vi.fn().mockResolvedValueOnce({ 24 | stdout: "https://github.com/JoshuaKGoldberg/create-typescript-app.git", 25 | }); 26 | 27 | const actual = await readGitDefaults(take); 28 | 29 | expect(actual).toMatchObject({ 30 | name: "create-typescript-app", 31 | owner: "JoshuaKGoldberg", 32 | }); 33 | }); 34 | }); 35 | -------------------------------------------------------------------------------- /src/options/readGitDefaults.ts: -------------------------------------------------------------------------------- 1 | import { TakeInput } from "bingo"; 2 | import gitUrlParse from "git-url-parse"; 3 | import { inputFromScript } from "input-from-script"; 4 | 5 | export async function readGitDefaults(take: TakeInput) { 6 | const url = await take(inputFromScript, { 7 | command: "git remote get-url origin", 8 | }); 9 | 10 | return url.stdout ? gitUrlParse(url.stdout.toString()) : undefined; 11 | } 12 | -------------------------------------------------------------------------------- /src/options/readGuide.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it } from "vitest"; 2 | 3 | import { readGuide } from "./readGuide.js"; 4 | 5 | describe(readGuide, () => { 6 | it("resolves with undefined when .github/DEVELOPMENT.md cannot be read", async () => { 7 | const guide = await readGuide(() => Promise.resolve(new Error("Oh no!"))); 8 | 9 | expect(guide).toBeUndefined(); 10 | }); 11 | 12 | it("resolves with undefined when .github/DEVELOPMENT.md does not contain a guided walkthrough", async () => { 13 | const guide = await readGuide(() => Promise.resolve("")); 14 | 15 | expect(guide).toBeUndefined(); 16 | }); 17 | 18 | it("reads the href and title when the tag exists", async () => { 19 | const guide = await readGuide(() => 20 | Promise.resolve(`# Development 21 | 22 | > If you'd like a more guided walkthrough, see [Contributing to a create-typescript-app Repository](https://www.joshuakgoldberg.com/blog/contributing-to-a-create-typescript-app-repository). 23 | > It'll walk you through the common activities you'll need to contribute. 24 | `), 25 | ); 26 | 27 | expect(guide).toEqual({ 28 | href: "https://www.joshuakgoldberg.com/blog/contributing-to-a-create-typescript-app-repository", 29 | title: "Contributing to a create-typescript-app Repository", 30 | }); 31 | }); 32 | }); 33 | -------------------------------------------------------------------------------- /src/options/readGuide.ts: -------------------------------------------------------------------------------- 1 | import { TakeInput } from "bingo"; 2 | import { inputFromFile } from "input-from-file"; 3 | 4 | export async function readGuide(take: TakeInput) { 5 | const development = await take(inputFromFile, { 6 | filePath: ".github/DEVELOPMENT.md", 7 | }); 8 | 9 | if (development instanceof Error) { 10 | return undefined; 11 | } 12 | 13 | const tag = /> .*guided walkthrough, see \[((?!\[).+)\]\((.+)\)/i.exec( 14 | development, 15 | ); 16 | 17 | if (!tag) { 18 | return undefined; 19 | } 20 | 21 | return { 22 | href: tag[2], 23 | title: tag[1], 24 | }; 25 | } 26 | -------------------------------------------------------------------------------- /src/options/readKeywords.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it } from "vitest"; 2 | 3 | import { readKeywords } from "./readKeywords.js"; 4 | 5 | describe(readKeywords, () => { 6 | it("resolves with undefined when there are no existing keywords", async () => { 7 | const actual = await readKeywords(() => Promise.resolve({})); 8 | 9 | expect(actual).toBeUndefined(); 10 | }); 11 | 12 | it("resolves with deduplicated and sorted keywords when there are existing keywords", async () => { 13 | const actual = await readKeywords(() => 14 | Promise.resolve({ 15 | keywords: ["b", "a", "c d", "b", "a"], 16 | }), 17 | ); 18 | 19 | expect(actual).toEqual(["a", "b", "c d"]); 20 | }); 21 | }); 22 | -------------------------------------------------------------------------------- /src/options/readKeywords.ts: -------------------------------------------------------------------------------- 1 | import { PartialPackageData } from "../types.js"; 2 | 3 | export async function readKeywords( 4 | getPackageData: () => Promise, 5 | ) { 6 | const { keywords } = await getPackageData(); 7 | 8 | return ( 9 | keywords && Array.from(new Set((await getPackageData()).keywords)).sort() 10 | ); 11 | } 12 | -------------------------------------------------------------------------------- /src/options/readLogo.ts: -------------------------------------------------------------------------------- 1 | import { readLogoSizing } from "./readLogoSizing.js"; 2 | 3 | export async function readLogo(getReadme: () => Promise) { 4 | const tag = /\n/.exec(await getReadme())?.[0]; 5 | 6 | if (!tag) { 7 | return undefined; 8 | } 9 | 10 | const alt = 11 | /alt=['"](.+)['"]\s*src=/.exec(tag)?.[1].split(/['"]?\s*\w+=/)[0] ?? 12 | "Project logo"; 13 | 14 | if (/All Contributors: \d+/.test(alt)) { 15 | return undefined; 16 | } 17 | 18 | const src = /src\s*=(.+)['"/]>/ 19 | .exec(tag)?.[1] 20 | ?.split(/\s*\w+=/)[0] 21 | .replaceAll(/^['"]|['"]$/g, ""); 22 | 23 | if (!src || src.includes("//img.shields.io")) { 24 | return undefined; 25 | } 26 | 27 | return { 28 | alt, 29 | src, 30 | ...readLogoSizing(src), 31 | }; 32 | } 33 | -------------------------------------------------------------------------------- /src/options/readLogoSizing.ts: -------------------------------------------------------------------------------- 1 | import { imageSize } from "image-size"; 2 | 3 | const maximum = 128; 4 | 5 | export interface OptionsLogoSizing { 6 | height?: number; 7 | width?: number; 8 | } 9 | 10 | export function readLogoSizing( 11 | src: string | Uint8Array, 12 | ): OptionsLogoSizing | undefined { 13 | const size = imageSizeSafe(src); 14 | if (!size) { 15 | return undefined; 16 | } 17 | 18 | if (!size.height) { 19 | return size.width ? { width: Math.min(size.width, maximum) } : undefined; 20 | } 21 | 22 | if (!size.width) { 23 | return { height: Math.min(size.height, maximum) }; 24 | } 25 | 26 | if (size.height <= maximum && size.width <= maximum) { 27 | return { height: size.height, width: size.width }; 28 | } 29 | 30 | return size.height > size.width 31 | ? { height: maximum, width: (size.width / size.height) * maximum } 32 | : { height: (size.height / size.width) * maximum, width: maximum }; 33 | } 34 | 35 | function imageSizeSafe(src: string | Uint8Array) { 36 | try { 37 | // TODO: imageSize does not go through take(input*), making it harder to test. 38 | // It takes either a string (fs access) or buffer data (not in bingo-fs). 39 | // https://github.com/JoshuaKGoldberg/create-typescript-app/issues/1993 40 | return imageSize(src); 41 | } catch { 42 | return undefined; 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/options/readNode.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it, vi } from "vitest"; 2 | 3 | import { defaults } from "../constants.js"; 4 | import { readNode } from "./readNode.js"; 5 | 6 | describe(readNode, () => { 7 | describe("minimum", () => { 8 | const getNvmrc = vi.fn(); 9 | 10 | it("defaults to the default minimum when engines.node does not exist", async () => { 11 | const { minimum } = await readNode(getNvmrc, () => 12 | Promise.resolve({ engines: {} }), 13 | ); 14 | 15 | expect(minimum).toBe(defaults.node.minimum); 16 | }); 17 | 18 | it("defaults to the default minimum when engines.node does not contain a valid value", async () => { 19 | const { minimum } = await readNode(getNvmrc, () => 20 | Promise.resolve({ 21 | engines: { 22 | node: "invalid", 23 | }, 24 | }), 25 | ); 26 | 27 | expect(minimum).toBe(defaults.node.minimum); 28 | }); 29 | 30 | it("uses the engines value when engines.node contains a valid value", async () => { 31 | const node = "23.4.5"; 32 | 33 | const { minimum } = await readNode(getNvmrc, () => 34 | Promise.resolve({ 35 | engines: { node }, 36 | }), 37 | ); 38 | 39 | expect(minimum).toBe(node); 40 | }); 41 | }); 42 | 43 | describe("pinned", () => { 44 | const getPackageDataFull = vi.fn().mockResolvedValue({}); 45 | 46 | it("defaults to the default pinned when nvmrc does not exist", async () => { 47 | const { pinned } = await readNode( 48 | () => Promise.resolve(new Error("")), 49 | getPackageDataFull, 50 | ); 51 | 52 | expect(pinned).toBe(defaults.node.pinned); 53 | }); 54 | 55 | it("defaults to the default pinned when nvmrc does not contain text", async () => { 56 | const { pinned } = await readNode( 57 | () => Promise.resolve("\n"), 58 | getPackageDataFull, 59 | ); 60 | 61 | expect(pinned).toBe(defaults.node.pinned); 62 | }); 63 | 64 | it("uses the trimmed nvmrc text value when nvmrc contains text", async () => { 65 | const nvmrc = "23.4.5"; 66 | 67 | const { pinned } = await readNode( 68 | () => Promise.resolve(`${nvmrc}\n`), 69 | getPackageDataFull, 70 | ); 71 | 72 | expect(pinned).toBe(nvmrc); 73 | }); 74 | }); 75 | }); 76 | -------------------------------------------------------------------------------- /src/options/readNode.ts: -------------------------------------------------------------------------------- 1 | import { defaults } from "../constants.js"; 2 | import { PartialPackageData } from "../types.js"; 3 | import { swallowError } from "../utils/swallowError.js"; 4 | 5 | export async function readNode( 6 | getNvmrc: () => Promise, 7 | getPackageDataFull: () => Promise, 8 | ) { 9 | const { engines } = await getPackageDataFull(); 10 | 11 | return { 12 | minimum: 13 | (engines?.node && /[\d+.]+/.exec(engines.node))?.[0] ?? 14 | defaults.node.minimum, 15 | pinned: swallowError(await getNvmrc())?.trim() || defaults.node.pinned, 16 | }; 17 | } 18 | -------------------------------------------------------------------------------- /src/options/readNpmDefaults.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it, vi } from "vitest"; 2 | 3 | import { readNpmDefaults } from "./readNpmDefaults.js"; 4 | 5 | const mockNpmUser = vi.fn(); 6 | 7 | vi.mock("npm-user", () => ({ 8 | get default() { 9 | return mockNpmUser; 10 | }, 11 | })); 12 | 13 | const user = "test-user"; 14 | 15 | describe(readNpmDefaults, () => { 16 | it("returns the corresponding npm user when whoami resolves a value", async () => { 17 | mockNpmUser.mockResolvedValueOnce(user); 18 | const getWhoami = vi.fn().mockResolvedValueOnce({ stdout: "test-whoami" }); 19 | 20 | const actual = await readNpmDefaults(getWhoami); 21 | 22 | expect(actual).toBe(user); 23 | }); 24 | 25 | it("returns undefined when whoami resolves undefined", async () => { 26 | const getWhoami = vi.fn().mockResolvedValueOnce(undefined); 27 | 28 | const actual = await readNpmDefaults(getWhoami); 29 | 30 | expect(actual).toBeUndefined(); 31 | expect(mockNpmUser).not.toHaveBeenCalled(); 32 | }); 33 | 34 | it("returns undefined when whoami resolves no value", async () => { 35 | const getWhoami = vi.fn().mockResolvedValueOnce({}); 36 | 37 | const actual = await readNpmDefaults(getWhoami); 38 | 39 | expect(actual).toBeUndefined(); 40 | expect(mockNpmUser).not.toHaveBeenCalled(); 41 | }); 42 | 43 | it("returns undefined when whoami resolves a value but npmUser rejects", async () => { 44 | const getWhoami = vi.fn().mockResolvedValueOnce(user); 45 | mockNpmUser.mockRejectedValueOnce(new Error("Oh no!")); 46 | 47 | const actual = await readNpmDefaults(getWhoami); 48 | 49 | expect(actual).toBeUndefined(); 50 | }); 51 | }); 52 | -------------------------------------------------------------------------------- /src/options/readNpmDefaults.ts: -------------------------------------------------------------------------------- 1 | import { ExecaError, Result } from "execa"; 2 | import npmUser from "npm-user"; 3 | 4 | import { swallowErrorAsync } from "../utils/swallowErrorAsync.js"; 5 | 6 | // TODO: npmUser does not go through take(input*), making it harder to test. 7 | // https://github.com/JoshuaKGoldberg/create-typescript-app/issues/1990 8 | export async function readNpmDefaults( 9 | getNpmWhoami: () => Promise, 10 | ) { 11 | const whoami = await getNpmWhoami(); 12 | return typeof whoami?.stdout === "string" 13 | ? await swallowErrorAsync(npmUser(whoami.stdout)) 14 | : undefined; 15 | } 16 | -------------------------------------------------------------------------------- /src/options/readOwner.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it, vi } from "vitest"; 2 | 3 | import { readOwner } from "./readOwner.js"; 4 | 5 | describe(readOwner, () => { 6 | it("returns git defaults organization when it exists", async () => { 7 | const take = vi.fn(); 8 | const organization = "test-organization"; 9 | const getGitDefaults = vi.fn().mockResolvedValueOnce({ organization }); 10 | const getPackageAuthor = vi.fn(); 11 | 12 | const actual = await readOwner(take, getGitDefaults, getPackageAuthor); 13 | 14 | expect(actual).toBe(organization); 15 | expect(take).not.toHaveBeenCalled(); 16 | expect(getPackageAuthor).not.toHaveBeenCalled(); 17 | }); 18 | 19 | it("returns package data author when only it exists", async () => { 20 | const take = vi.fn(); 21 | const name = "test-author"; 22 | const getGitDefaults = vi.fn(); 23 | const getPackageAuthor = vi.fn().mockResolvedValueOnce({ name }); 24 | 25 | const actual = await readOwner(take, getGitDefaults, getPackageAuthor); 26 | 27 | expect(actual).toBe(name); 28 | expect(take).not.toHaveBeenCalled(); 29 | }); 30 | 31 | it("returns the gh config value when only it resolves", async () => { 32 | const user = "test-user"; 33 | const take = vi.fn().mockResolvedValueOnce({ stdout: user }); 34 | const getGitDefaults = vi.fn(); 35 | const getPackageAuthor = vi.fn().mockResolvedValueOnce({}); 36 | 37 | const actual = await readOwner(take, getGitDefaults, getPackageAuthor); 38 | 39 | expect(actual).toBe(user); 40 | }); 41 | 42 | it("returns undefined when no values exist", async () => { 43 | const take = vi.fn().mockResolvedValueOnce({}); 44 | const getGitDefaults = vi.fn(); 45 | const getPackageAuthor = vi.fn().mockResolvedValueOnce({}); 46 | 47 | const actual = await readOwner(take, getGitDefaults, getPackageAuthor); 48 | 49 | expect(actual).toBe(undefined); 50 | }); 51 | }); 52 | -------------------------------------------------------------------------------- /src/options/readOwner.ts: -------------------------------------------------------------------------------- 1 | import { TakeInput } from "bingo"; 2 | import { GitUrl } from "git-url-parse"; 3 | import { inputFromScript } from "input-from-script"; 4 | 5 | import { PackageAuthor } from "./readPackageAuthor.js"; 6 | 7 | export async function readOwner( 8 | take: TakeInput, 9 | getGitDefaults: () => Promise, 10 | getPackageAuthor: () => Promise, 11 | ) { 12 | return ( 13 | (await getGitDefaults())?.organization ?? 14 | (await getPackageAuthor()).name ?? 15 | ( 16 | await take(inputFromScript, { 17 | command: "gh config get user -h github.com", 18 | }) 19 | ).stdout?.toString() 20 | ); 21 | } 22 | -------------------------------------------------------------------------------- /src/options/readPackageAuthor.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it } from "vitest"; 2 | 3 | import { readPackageAuthor } from "./readPackageAuthor.js"; 4 | 5 | describe(readPackageAuthor, () => { 6 | it.each([ 7 | [{}, {}], 8 | [{ name: "abc123" }, { author: "abc123" }], 9 | [ 10 | { email: "def@ghi.com", name: "abc123" }, 11 | { author: "abc123 " }, 12 | ], 13 | [ 14 | { email: "def@ghi.com", name: "abc123" }, 15 | { author: "abc123 " }, 16 | ], 17 | [ 18 | { email: "def@ghi.com", name: "abc123" }, 19 | { author: { email: "def@ghi.com", name: "abc123" } }, 20 | ], 21 | ])("returns %s when given %s", async (expected, packageDataFull) => { 22 | const actual = await readPackageAuthor(() => 23 | Promise.resolve(packageDataFull), 24 | ); 25 | 26 | expect(actual).toEqual(expected); 27 | }); 28 | }); 29 | -------------------------------------------------------------------------------- /src/options/readPackageAuthor.ts: -------------------------------------------------------------------------------- 1 | import parse from "parse-author"; 2 | 3 | import { PartialPackageData } from "../types.js"; 4 | 5 | export interface PackageAuthor { 6 | email?: string | undefined; 7 | name?: string | undefined; 8 | } 9 | 10 | export async function readPackageAuthor( 11 | getPackageDataFull: () => Promise, 12 | ): Promise { 13 | const packageData = await getPackageDataFull(); 14 | 15 | switch (typeof packageData.author) { 16 | case "object": 17 | return packageData.author; 18 | 19 | case "string": 20 | return parse(packageData.author); 21 | 22 | default: 23 | return {}; 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/options/readPackageData.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it, vi } from "vitest"; 2 | 3 | import { readPackageData } from "./readPackageData.js"; 4 | 5 | describe(readPackageData, () => { 6 | it("returns {} when reading package.json results in an error", async () => { 7 | const take = vi.fn().mockResolvedValueOnce(new Error("Oh no!")); 8 | 9 | const actual = await readPackageData(take); 10 | 11 | expect(actual).toEqual({}); 12 | }); 13 | 14 | it("returns {} when package.json is empty", async () => { 15 | const take = vi.fn().mockResolvedValueOnce(""); 16 | 17 | const actual = await readPackageData(take); 18 | 19 | expect(actual).toEqual({}); 20 | }); 21 | 22 | it("returns file data when there is a package.json", async () => { 23 | const packageData = { name: "test-name" }; 24 | const take = vi.fn().mockResolvedValueOnce(packageData); 25 | 26 | const actual = await readPackageData(take); 27 | 28 | expect(actual).toBe(packageData); 29 | }); 30 | }); 31 | -------------------------------------------------------------------------------- /src/options/readPackageData.ts: -------------------------------------------------------------------------------- 1 | import { TakeInput } from "bingo"; 2 | import { inputFromFileJSON } from "input-from-file-json"; 3 | 4 | import { PartialPackageData } from "../types.js"; 5 | import { swallowError } from "../utils/swallowError.js"; 6 | 7 | export async function readPackageData( 8 | take: TakeInput, 9 | ): Promise { 10 | return ( 11 | swallowError( 12 | await take(inputFromFileJSON, { 13 | filePath: "./package.json", 14 | }), 15 | ) || {} 16 | ); 17 | } 18 | -------------------------------------------------------------------------------- /src/options/readPnpm.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it } from "vitest"; 2 | 3 | import { readPnpm } from "./readPnpm.js"; 4 | 5 | describe(readPnpm, () => { 6 | it("returns 10.4.0 when there is no existing packageManager", async () => { 7 | const actual = await readPnpm(() => Promise.resolve({})); 8 | 9 | expect(actual).toEqual("10.4.0"); 10 | }); 11 | 12 | it("returns 10.4.0 when an existing packageManager is not pnpm", async () => { 13 | const actual = await readPnpm(() => 14 | Promise.resolve({ 15 | packageManager: "yarn@1.2.3", 16 | }), 17 | ); 18 | 19 | expect(actual).toEqual("10.4.0"); 20 | }); 21 | 22 | it("returns the existing pnpm version when an existing packageManager is pnpm", async () => { 23 | const actual = await readPnpm(() => 24 | Promise.resolve({ 25 | packageManager: "pnpm@10.11.12", 26 | }), 27 | ); 28 | 29 | expect(actual).toEqual("10.11.12"); 30 | }); 31 | }); 32 | -------------------------------------------------------------------------------- /src/options/readPnpm.ts: -------------------------------------------------------------------------------- 1 | import { PartialPackageData } from "../types.js"; 2 | 3 | export async function readPnpm(packageData: () => Promise) { 4 | const { packageManager } = await packageData(); 5 | 6 | return packageManager?.startsWith("pnpm@") 7 | ? packageManager.slice("pnpm@".length) 8 | : "10.4.0"; 9 | } 10 | -------------------------------------------------------------------------------- /src/options/readReadmeAdditional.ts: -------------------------------------------------------------------------------- 1 | import { indicatorsTemplatedBy } from "./readReadmeFootnotes.js"; 2 | 3 | const indicatorAfterAllContributors = //; 4 | const indicatorAfterAllContributorsSpellCheck = 5 | //; 6 | 7 | export async function readReadmeAdditional(getReadme: () => Promise) { 8 | const readme = await getReadme(); 9 | if (!readme) { 10 | return undefined; 11 | } 12 | 13 | const indexAfterContributors = 14 | indicatorAfterAllContributorsSpellCheck.exec(readme) ?? 15 | indicatorAfterAllContributors.exec(readme); 16 | if (!indexAfterContributors) { 17 | return undefined; 18 | } 19 | 20 | const indexOfFirstTemplatedBy = indicatorsTemplatedBy.reduce( 21 | (smallest, indicator) => { 22 | const indexOf = indicator.exec(readme)?.index; 23 | return indexOf ? Math.min(smallest, indexOf) : smallest; 24 | }, 25 | readme.length, 26 | ); 27 | 28 | return readme 29 | .slice( 30 | indexAfterContributors.index + indexAfterContributors[0].length, 31 | indexOfFirstTemplatedBy, 32 | ) 33 | .trim(); 34 | } 35 | -------------------------------------------------------------------------------- /src/options/readReadmeExplainer.ts: -------------------------------------------------------------------------------- 1 | const lastTagMatchers = [`">`, "/p>", "/>"]; 2 | 3 | export async function readReadmeExplainer(getReadme: () => Promise) { 4 | const readme = await getReadme(); 5 | 6 | const indexOfFirstH2 = readme.indexOf("##"); 7 | const indexOfUsageH2 = /## Usage/.exec(readme)?.index; 8 | const beforeH2s = readme.slice(0, indexOfUsageH2 ?? indexOfFirstH2); 9 | const [indexOfLastTag, lastTagMatcher] = lastLastIndexOf( 10 | beforeH2s, 11 | lastTagMatchers, 12 | ); 13 | if (!lastTagMatcher) { 14 | return undefined; 15 | } 16 | 17 | const endingIndex = 18 | indexOfUsageH2 ?? /## (?:Contrib|Develop)/.exec(readme)?.index; 19 | 20 | return readme 21 | .slice(indexOfLastTag + lastTagMatcher.length, endingIndex) 22 | .trim(); 23 | } 24 | 25 | function lastLastIndexOf(text: string, matchers: string[]) { 26 | let pair: [number, string | undefined] = [-1, undefined]; 27 | 28 | for (const matcher of matchers) { 29 | const indexOf = text.lastIndexOf(matcher); 30 | if (indexOf > pair[0]) { 31 | pair = [indexOf, matcher]; 32 | } 33 | } 34 | 35 | return pair; 36 | } 37 | -------------------------------------------------------------------------------- /src/options/readReadmeFootnotes.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it } from "vitest"; 2 | 3 | import { readReadmeFootnotes } from "./readReadmeFootnotes.js"; 4 | 5 | describe(readReadmeFootnotes, () => { 6 | it("resolves undefined when there is no existing readme content", async () => { 7 | const getReadme = () => Promise.resolve(""); 8 | 9 | const result = await readReadmeFootnotes(getReadme); 10 | 11 | expect(result).toBeUndefined(); 12 | }); 13 | 14 | it("resolves undefined when there is no templated by notice", async () => { 15 | const getReadme = () => Promise.resolve(`# My Package`); 16 | 17 | const result = await readReadmeFootnotes(getReadme); 18 | 19 | expect(result).toBeUndefined(); 20 | }); 21 | 22 | it("resolves undefined when there is no content after a quote templated by notice", async () => { 23 | const getReadme = () => 24 | Promise.resolve(`# My Package 25 | 26 | > 💖 This package was templated with etc. etc. 27 | 28 | `); 29 | 30 | const result = await readReadmeFootnotes(getReadme); 31 | 32 | expect(result).toBeUndefined(); 33 | }); 34 | 35 | it("resolves the content when there plain text content after a quote templated by notice", async () => { 36 | const getReadme = () => 37 | Promise.resolve(`# My Package 38 | 39 | > 💖 This package was templated with etc. etc. 40 | 41 | After. 42 | `); 43 | 44 | const result = await readReadmeFootnotes(getReadme); 45 | 46 | expect(result).toBe("After."); 47 | }); 48 | 49 | it("resolves the content when there are footnotes after a quote templated by notice", async () => { 50 | const getReadme = () => 51 | Promise.resolve(`# My Package 52 | 53 | > 💖 This package was templated with etc. etc. 54 | 55 | [^1]: After. 56 | `); 57 | 58 | const result = await readReadmeFootnotes(getReadme); 59 | 60 | expect(result).toBe("[^1]: After."); 61 | }); 62 | 63 | it("resolves the content when there are footnotes after a comment and quote templated by notice", async () => { 64 | const getReadme = () => 65 | Promise.resolve(`# My Package 66 | 67 | 68 | 69 | > 💖 This package was templated with etc. etc. 70 | 71 | [^1]: After. 72 | `); 73 | 74 | const result = await readReadmeFootnotes(getReadme); 75 | 76 | expect(result).toBe("[^1]: After."); 77 | }); 78 | }); 79 | -------------------------------------------------------------------------------- /src/options/readReadmeFootnotes.ts: -------------------------------------------------------------------------------- 1 | export const indicatorsTemplatedBy = [ 2 | /> .* This package (?:is|was) (?:based|build|templated) (?:on|with)/, 3 | /