├── .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 | /