├── .editorconfig ├── .gitattributes ├── .github ├── ISSUE_TEMPLATE │ ├── bug_report.md │ ├── config.yml │ └── feature_request.md ├── dependabot.yml └── workflows │ ├── ci.yml │ ├── codeql.yml │ ├── dependency-review.yml │ ├── gh_pages.yml │ ├── integration.yml │ ├── lock-maintenance.yml │ ├── scorecards.yml │ └── stale.yml ├── .gitignore ├── .mocharc.cjs ├── .prettierignore ├── .prettierignore-doc ├── .prettierrc ├── .vscode ├── launch.json └── settings.json ├── .xo-config.json ├── LICENSE ├── eslint.config.js ├── jsdoc.json ├── package-lock.json ├── package.json ├── readme.md ├── src ├── actions │ ├── fs.ts │ ├── help.ts │ ├── lifecycle.ts │ ├── package-json.ts │ ├── spawn-command.ts │ └── user.ts ├── constants.ts ├── generator.ts ├── index.ts ├── questions.d.ts ├── types-utils.d.ts ├── types.d.ts └── util │ ├── deprecate.js │ ├── prompt-suggestion.ts │ └── storage.ts ├── test ├── base.test.ts ├── deprecate.test.ts ├── environment.test.ts ├── fixtures │ ├── config.json │ ├── conflict.js │ ├── dummy-project │ │ ├── .yo-rc.json │ │ └── subdir │ │ │ └── .gitkeep │ ├── file-conflict.txt │ ├── file-contains-utf8.yml │ ├── foo-copy.js │ ├── foo-template.js │ ├── foo.js │ ├── generator-defaults │ │ ├── app │ │ │ ├── index.js │ │ │ ├── options.js │ │ │ ├── prompts.js │ │ │ └── templates │ │ │ │ └── foo-template.js │ │ └── package.json │ ├── generator-mocha │ │ ├── main.js │ │ └── package.json │ ├── options-generator │ │ ├── main.js │ │ └── package.json │ ├── testFile.tar.gz │ └── yeoman-logo.png ├── fs.test.ts ├── generators-compose-workflow.test.ts ├── generators.test.ts ├── integration.test.ts ├── package-json.test.ts ├── prompt-suggestion.test.ts ├── spawn-command.test.ts ├── storage.test.ts ├── user.test.ts └── utils.ts ├── tsconfig.json ├── tsconfig.spec.json └── vitest.config.ts /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = space 5 | indent_size = 2 6 | charset = utf-8 7 | trim_trailing_whitespace = true 8 | insert_final_newline = true 9 | 10 | [*.md] 11 | trim_trailing_whitespace = false 12 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | * text=auto 2 | *.js text eol=lf 3 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: needs triage 6 | assignees: '' 7 | --- 8 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | blank_issues_enabled: false 2 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: needs triage, enhancement 6 | assignees: '' 7 | --- 8 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: 'npm' 4 | directory: '/' 5 | schedule: 6 | interval: daily 7 | open-pull-requests-limit: 5 8 | ignore: 9 | - dependency-name: '@types/node' 10 | versions: ['>=17'] 11 | - dependency-name: '*' 12 | update-types: ['version-update:semver-minor', 'version-update:semver-patch'] 13 | groups: 14 | vitest: 15 | patterns: 16 | - '@vitest/*' 17 | - 'vitest' 18 | 19 | - package-ecosystem: 'github-actions' 20 | directory: '/' 21 | schedule: 22 | interval: 'weekly' 23 | open-pull-requests-limit: 5 24 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: NPM Test 2 | 3 | on: 4 | push: 5 | branches-ignore: 6 | - 'dependabot/**' 7 | pull_request: 8 | branches: 9 | - '*' 10 | 11 | permissions: 12 | contents: read 13 | 14 | jobs: 15 | build: 16 | runs-on: ${{ matrix.os }} 17 | 18 | strategy: 19 | fail-fast: false 20 | matrix: 21 | os: [ubuntu-latest, windows-latest, macos-latest] 22 | node-version: [20, 22, 23, 24] 23 | 24 | steps: 25 | - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 26 | - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 27 | with: 28 | node-version: ${{ matrix.node-version }} 29 | - run: npm ci 30 | - run: npm test 31 | -------------------------------------------------------------------------------- /.github/workflows/codeql.yml: -------------------------------------------------------------------------------- 1 | # For most projects, this workflow file will not need changing; you simply need 2 | # to commit it to your repository. 3 | # 4 | # You may wish to alter this file to override the set of languages analyzed, 5 | # or to provide custom queries or build logic. 6 | # 7 | # ******** NOTE ******** 8 | # We have attempted to detect the languages in your repository. Please check 9 | # the `language` matrix defined below to confirm you have the correct set of 10 | # supported CodeQL languages. 11 | # 12 | name: 'CodeQL' 13 | 14 | on: 15 | push: 16 | branches: ['main'] 17 | pull_request: 18 | # The branches below must be a subset of the branches above 19 | branches: ['main'] 20 | schedule: 21 | - cron: '0 0 * * 1' 22 | 23 | permissions: 24 | contents: read 25 | 26 | jobs: 27 | analyze: 28 | name: Analyze 29 | runs-on: ubuntu-latest 30 | permissions: 31 | actions: read 32 | contents: read 33 | security-events: write 34 | 35 | strategy: 36 | fail-fast: false 37 | matrix: 38 | language: ['javascript', 'typescript'] 39 | # CodeQL supports [ $supported-codeql-languages ] 40 | # Learn more about CodeQL language support at https://aka.ms/codeql-docs/language-support 41 | 42 | steps: 43 | - name: Checkout repository 44 | uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 45 | 46 | # Initializes the CodeQL tools for scanning. 47 | - name: Initialize CodeQL 48 | uses: github/codeql-action/init@ff0a06e83cb2de871e5a09832bc6a81e7276941f # v3.28.18 49 | with: 50 | languages: ${{ matrix.language }} 51 | # If you wish to specify custom queries, you can do so here or in a config file. 52 | # By default, queries listed here will override any specified in a config file. 53 | # Prefix the list here with "+" to use these queries and those in the config file. 54 | 55 | # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). 56 | # If this step fails, then you should remove it and run the build manually (see below) 57 | - name: Autobuild 58 | uses: github/codeql-action/autobuild@ff0a06e83cb2de871e5a09832bc6a81e7276941f # v3.28.18 59 | 60 | # ℹ️ Command-line programs to run using the OS shell. 61 | # 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun 62 | 63 | # If the Autobuild fails above, remove it and uncomment the following three lines. 64 | # modify them (or add more) to build your code if your project, please refer to the EXAMPLE below for guidance. 65 | 66 | # - run: | 67 | # echo "Run, Build Application using script" 68 | # ./location_of_script_within_repo/buildscript.sh 69 | 70 | - name: Perform CodeQL Analysis 71 | uses: github/codeql-action/analyze@ff0a06e83cb2de871e5a09832bc6a81e7276941f # v3.28.18 72 | with: 73 | category: '/language:${{matrix.language}}' 74 | -------------------------------------------------------------------------------- /.github/workflows/dependency-review.yml: -------------------------------------------------------------------------------- 1 | # Dependency Review Action 2 | # 3 | # This Action will scan dependency manifest files that change as part of a Pull Request, 4 | # surfacing known-vulnerable versions of the packages declared or updated in the PR. 5 | # Once installed, if the workflow run is marked as required, 6 | # PRs introducing known-vulnerable packages will be blocked from merging. 7 | # 8 | # Source repository: https://github.com/actions/dependency-review-action 9 | name: 'Dependency Review' 10 | on: [pull_request] 11 | 12 | permissions: 13 | contents: read 14 | 15 | jobs: 16 | dependency-review: 17 | runs-on: ubuntu-latest 18 | steps: 19 | - name: 'Checkout Repository' 20 | uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 21 | - name: 'Dependency Review' 22 | uses: actions/dependency-review-action@da24556b548a50705dd671f47852072ea4c105d9 # v4.7.1 23 | -------------------------------------------------------------------------------- /.github/workflows/gh_pages.yml: -------------------------------------------------------------------------------- 1 | name: Update api docs 2 | on: 3 | push: 4 | branches: 5 | - docs 6 | release: 7 | types: [published] 8 | workflow_dispatch: 9 | inputs: 10 | path: 11 | description: 'Destination path to generate' 12 | required: false 13 | 14 | permissions: 15 | contents: write # for peter-evans/create-pull-request to create branch 16 | pull-requests: write # for peter-evans/create-pull-request to create a PR 17 | 18 | jobs: 19 | build: 20 | permissions: 21 | contents: write # for peter-evans/create-pull-request to create branch 22 | pull-requests: write # for peter-evans/create-pull-request to create a PR 23 | name: Update api docs 24 | runs-on: ubuntu-latest 25 | steps: 26 | - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 27 | with: 28 | path: source 29 | - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 30 | with: 31 | ref: gh-pages 32 | path: yeoman-generator-doc 33 | - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 34 | with: 35 | node-version: 20 36 | - run: npm ci 37 | working-directory: source 38 | - name: Cleanup 39 | working-directory: yeoman-generator-doc 40 | run: rm *.html 41 | - name: Generate doc 42 | run: npm run doc 43 | working-directory: source 44 | env: 45 | DOC_FOLDER: ${{ github.event.inputs.path }} 46 | - name: Detect version 47 | run: echo "::set-output name=version::$(node -e "console.log(require('./package.json').version);")" 48 | working-directory: source 49 | id: version 50 | - name: Create commit 51 | working-directory: yeoman-generator-doc 52 | if: always() 53 | run: | 54 | git add . 55 | git config --local user.email "github-actions[bot]@users.noreply.github.com" 56 | git config --local user.name "Github Actions" 57 | git diff --cached 58 | git commit -a -m "Update api for ${{steps.version.outputs.version}}" || true 59 | - name: Create Pull Request 60 | if: always() 61 | id: cpr 62 | uses: peter-evans/create-pull-request@271a8d0340265f705b14b6d32b9829c1cb33d45e # v7.0.8 63 | with: 64 | token: ${{ secrets.GITHUB_TOKEN }} 65 | commit-message: 'Update api for ${{steps.version.outputs.version}}' 66 | title: 'Update api for ${{steps.version.outputs.version}}' 67 | body: | 68 | Update api docs 69 | labels: automated pr 70 | branch: gh-pages-master 71 | path: yeoman-generator-doc 72 | - name: Check outputs 73 | run: | 74 | echo "Pull Request Number - ${{ env.PULL_REQUEST_NUMBER }}" 75 | echo "Pull Request Number - ${{ steps.cpr.outputs.pr_number }}" 76 | -------------------------------------------------------------------------------- /.github/workflows/integration.yml: -------------------------------------------------------------------------------- 1 | name: Integration Build 2 | 3 | on: 4 | push: 5 | branches-ignore: 6 | - '*' 7 | - 'dependabot/**' 8 | pull_request: 9 | branches: 10 | - '*' 11 | 12 | permissions: 13 | contents: read 14 | 15 | jobs: 16 | build: 17 | runs-on: ${{ matrix.os }} 18 | 19 | strategy: 20 | matrix: 21 | os: [ubuntu-latest] 22 | node-version: [20] 23 | 24 | steps: 25 | - name: Checkout yeoman-test 26 | uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 27 | with: 28 | repository: yeoman/yeoman-test 29 | path: yeoman-test 30 | 31 | - name: Checkout yeoman-generator 32 | uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 33 | with: 34 | path: yeoman-generator 35 | 36 | - name: Checkout yeoman-environment 37 | uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 38 | with: 39 | path: yeoman-environment 40 | 41 | - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 42 | with: 43 | node-version: ${{ matrix.node-version }} 44 | 45 | - name: Run yeoman-test test 46 | run: | 47 | cd $GITHUB_WORKSPACE/yeoman-test 48 | mv package.json package.json.original 49 | jq 'del(.peerDependencies)' package.json.original > package.json 50 | cat package.json 51 | npm install 52 | npm install ${{ github.repository }}#$GITHUB_SHA yeoman/environment#main 53 | npm test 54 | 55 | - name: Run yeoman-generator test 56 | if: always() 57 | run: | 58 | cd $GITHUB_WORKSPACE/yeoman-generator 59 | npm ci 60 | npm install yeoman/yeoman-test#main yeoman/environment#main 61 | npm test 62 | 63 | - name: Run yeoman-environment test 64 | if: always() 65 | run: | 66 | cd $GITHUB_WORKSPACE/yeoman-environment 67 | npm ci 68 | npm install yeoman-generator-7@${{ github.repository }}#$GITHUB_SHA yeoman/yeoman-test#main 69 | npm test 70 | -------------------------------------------------------------------------------- /.github/workflows/lock-maintenance.yml: -------------------------------------------------------------------------------- 1 | name: Package lock maintenance 2 | on: 3 | workflow_dispatch: 4 | 5 | permissions: 6 | contents: read 7 | 8 | jobs: 9 | build: 10 | permissions: 11 | contents: write # for peter-evans/create-pull-request to create branch 12 | pull-requests: write # for peter-evans/create-pull-request to create a PR 13 | name: Bump transitional dependencies 14 | runs-on: ubuntu-latest 15 | steps: 16 | - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 17 | - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 18 | with: 19 | node-version: 24 20 | cache: npm 21 | - name: Create commit 22 | run: | 23 | rm package-lock.json 24 | npm install 25 | - name: Create Pull Request 26 | uses: peter-evans/create-pull-request@271a8d0340265f705b14b6d32b9829c1cb33d45e # v7.0.8 27 | with: 28 | token: ${{ secrets.GITHUB_TOKEN }} 29 | commit-message: 'Bump transitional dependencies' 30 | committer: 'Github Actions <41898282+github-actions[bot]@users.noreply.github.com>' 31 | author: 'Github Actions <41898282+github-actions[bot]@users.noreply.github.com>' 32 | title: 'Bump transitional dependencies' 33 | body: Transitional dependencies bump. 34 | labels: 'dependencies' 35 | -------------------------------------------------------------------------------- /.github/workflows/scorecards.yml: -------------------------------------------------------------------------------- 1 | # This workflow uses actions that are not certified by GitHub. They are provided 2 | # by a third-party and are governed by separate terms of service, privacy 3 | # policy, and support documentation. 4 | 5 | name: Scorecard supply-chain security 6 | on: 7 | # For Branch-Protection check. Only the default branch is supported. See 8 | # https://github.com/ossf/scorecard/blob/main/docs/checks.md#branch-protection 9 | branch_protection_rule: 10 | # To guarantee Maintained check is occasionally updated. See 11 | # https://github.com/ossf/scorecard/blob/main/docs/checks.md#maintained 12 | schedule: 13 | - cron: '20 7 * * 2' 14 | push: 15 | branches: ['main'] 16 | 17 | # Declare default permissions as read only. 18 | permissions: read-all 19 | 20 | jobs: 21 | analysis: 22 | name: Scorecard analysis 23 | runs-on: ubuntu-latest 24 | permissions: 25 | # Needed to upload the results to code-scanning dashboard. 26 | security-events: write 27 | # Needed to publish results and get a badge (see publish_results below). 28 | id-token: write 29 | contents: read 30 | actions: read 31 | # To allow GraphQL ListCommits to work 32 | issues: read 33 | pull-requests: read 34 | # To detect SAST tools 35 | checks: read 36 | 37 | steps: 38 | - name: 'Checkout code' 39 | uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 40 | with: 41 | persist-credentials: false 42 | 43 | - name: 'Run analysis' 44 | uses: ossf/scorecard-action@05b42c624433fc40578a4040d5cf5e36ddca8cde # v2.4.2 45 | with: 46 | results_file: results.sarif 47 | results_format: sarif 48 | # (Optional) "write" PAT token. Uncomment the `repo_token` line below if: 49 | # - you want to enable the Branch-Protection check on a *public* repository, or 50 | # - you are installing Scorecards on a *private* repository 51 | # To create the PAT, follow the steps in https://github.com/ossf/scorecard-action#authentication-with-pat. 52 | # repo_token: ${{ secrets.SCORECARD_TOKEN }} 53 | 54 | # Public repositories: 55 | # - Publish results to OpenSSF REST API for easy access by consumers 56 | # - Allows the repository to include the Scorecard badge. 57 | # - See https://github.com/ossf/scorecard-action#publishing-results. 58 | # For private repositories: 59 | # - `publish_results` will always be set to `false`, regardless 60 | # of the value entered here. 61 | publish_results: true 62 | 63 | # Upload the results as artifacts (optional). Commenting out will disable uploads of run results in SARIF 64 | # format to the repository Actions tab. 65 | - name: 'Upload artifact' 66 | uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 67 | with: 68 | name: SARIF file 69 | path: results.sarif 70 | retention-days: 5 71 | 72 | # Upload the results to GitHub's code scanning dashboard. 73 | - name: 'Upload to code-scanning' 74 | uses: github/codeql-action/upload-sarif@ff0a06e83cb2de871e5a09832bc6a81e7276941f # v3.28.18 75 | with: 76 | sarif_file: results.sarif 77 | -------------------------------------------------------------------------------- /.github/workflows/stale.yml: -------------------------------------------------------------------------------- 1 | name: 'Close stale issues' 2 | on: 3 | schedule: 4 | - cron: '0 0 * * *' 5 | 6 | permissions: 7 | contents: read 8 | 9 | jobs: 10 | stale: 11 | permissions: 12 | issues: write # for actions/stale to close stale issues 13 | pull-requests: write # for actions/stale to close stale PRs 14 | runs-on: ubuntu-latest 15 | steps: 16 | - uses: actions/stale@5bef64f19d7facfb25b37b414482c7164d639639 # v9.1.0 17 | with: 18 | repo-token: ${{ secrets.GITHUB_TOKEN }} 19 | stale-issue-message: 'This issue is stale because it has been open with no activity. Remove stale label or comment or this will be closed' 20 | days-before-stale: 30 21 | days-before-close: 5 22 | stale-issue-label: 'stale' 23 | exempt-issue-labels: 'not stale' 24 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | coverage 3 | .nyc_output 4 | yarn.lock 5 | .project 6 | dist 7 | test/**/package-lock.json 8 | -------------------------------------------------------------------------------- /.mocharc.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | extension: ['test.ts'], 3 | }; 4 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | dist 2 | coverage 3 | .nyc_output 4 | **/fixtures/** 5 | -------------------------------------------------------------------------------- /.prettierignore-doc: -------------------------------------------------------------------------------- 1 | node_modules 2 | .git 3 | **/scripts/** 4 | **/styles/** 5 | 6 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | # Prettier configuration 2 | 3 | printWidth: 120 4 | singleQuote: true 5 | tabWidth: 2 6 | useTabs: false 7 | trailingComma: all 8 | 9 | # js and ts rules: 10 | arrowParens: avoid 11 | 12 | # jsx and tsx rules: 13 | bracketSameLine: false 14 | bracketSpacing: true 15 | 16 | endOfLine: auto 17 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "0.2.0", 3 | "configurations": [ 4 | { 5 | "type": "node", 6 | "request": "launch", 7 | "name": "Mocha All", 8 | "program": "${workspaceFolder}/node_modules/mocha/bin/_mocha", 9 | "args": ["--timeout", "999999", "--colors", "${workspaceFolder}/test"], 10 | "console": "integratedTerminal", 11 | "internalConsoleOptions": "neverOpen" 12 | }, 13 | { 14 | "type": "node", 15 | "request": "launch", 16 | "name": "Mocha Current File", 17 | "program": "${workspaceFolder}/node_modules/mocha/bin/_mocha", 18 | "args": ["--timeout", "999999", "--colors", "${file}"], 19 | "console": "integratedTerminal", 20 | "internalConsoleOptions": "neverOpen" 21 | } 22 | ] 23 | } 24 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "debug.javascript.terminalOptions": { 3 | "skipFiles": ["node_modules/**", "dist/**"] 4 | }, 5 | "mochaExplorer.logpanel": true, 6 | "mochaExplorer.files": ["test/*.test.ts"], 7 | "mochaExplorer.ui": "tdd", 8 | "mochaExplorer.configFile": ".mocharc.cjs", 9 | "mochaExplorer.require": "esmocha/mocha", 10 | "mochaExplorer.nodeArgv": ["--loader=esmocha/loader"] 11 | } 12 | -------------------------------------------------------------------------------- /.xo-config.json: -------------------------------------------------------------------------------- 1 | { 2 | "space": true, 3 | "envs": ["es2020", "node", "mocha"], 4 | "prettier": true, 5 | "ignores": ["test/fixtures"], 6 | "printWidth": 120, 7 | "parserOptions": { 8 | "project": "./tsconfig.spec.json" 9 | }, 10 | "rules": { 11 | "@typescript-eslint/no-unsafe-argument": "off", 12 | "@typescript-eslint/no-unsafe-assignment": "off", 13 | "@typescript-eslint/no-unsafe-call": "off", 14 | "@typescript-eslint/no-unsafe-return": "off", 15 | "no-await-in-loop": "off", 16 | "prefer-spread": "off" 17 | }, 18 | "overrides": [ 19 | { 20 | "files": "test/*", 21 | "rules": { 22 | "max-nested-callbacks": "off", 23 | "prefer-rest-params": "off", 24 | "unicorn/prefer-spread": "off", 25 | "unicorn/no-array-callback-reference": "off", 26 | "unicorn/no-object-as-default-parameter": "off", 27 | "unicorn/no-this-assignment": "off", 28 | "@typescript-eslint/no-empty-function": "off", 29 | "@typescript-eslint/no-unused-expressions": "off", 30 | "@typescript-eslint/restrict-plus-operands": "off", 31 | "@typescript-eslint/naming-convention": "off" 32 | } 33 | } 34 | ] 35 | } 36 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2015, Google 2 | All rights reserved. 3 | 4 | Redistribution and use in source and binary forms, with or without 5 | modification, are permitted provided that the following conditions are met: 6 | 7 | 1. Redistributions of source code must retain the above copyright notice, this 8 | list of conditions and the following disclaimer. 9 | 2. Redistributions in binary form must reproduce the above copyright notice, 10 | this list of conditions and the following disclaimer in the documentation 11 | and/or other materials provided with the distribution. 12 | 13 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND 14 | ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED 15 | WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 16 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR 17 | ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES 18 | (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; 19 | LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND 20 | ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 21 | (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS 22 | SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 23 | -------------------------------------------------------------------------------- /eslint.config.js: -------------------------------------------------------------------------------- 1 | // @ts-check 2 | import configs from '@yeoman/eslint'; 3 | import { config } from 'typescript-eslint'; 4 | 5 | export default config(...configs, { 6 | rules: { 7 | 'unicorn/prevent-abbreviations': 'off', 8 | 'unicorn/no-thenable': 'off', 9 | 'unicorn/prefer-event-target': 'off', 10 | 'unicorn/no-object-as-default-parameter': 'off', 11 | '@typescript-eslint/consistent-type-imports': 'error', 12 | 'prefer-arrow-callback': 'error', 13 | }, 14 | }); 15 | -------------------------------------------------------------------------------- /jsdoc.json: -------------------------------------------------------------------------------- 1 | { 2 | "source": { 3 | "include": ["readme.md", "lib"], 4 | "includePattern": ".+\\.js(doc)?$" 5 | }, 6 | "opts": { 7 | "recurse": true, 8 | "destination": "../yeoman-generator-doc", 9 | "template": "node_modules/tui-jsdoc-template", 10 | "package": "package.json" 11 | }, 12 | "templates": { 13 | "logo": { 14 | "url": "https://raw.githubusercontent.com/yeoman/yeoman.github.io/source/app/assets/img/logo.png", 15 | "width": "127px", 16 | "height": "14px" 17 | }, 18 | "name": "Generator", 19 | "footerText": "BSD license Copyright (c) Google", 20 | "default": { 21 | "includeDate": false 22 | } 23 | }, 24 | "plugins": ["plugins/markdown"] 25 | } 26 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "yeoman-generator", 3 | "version": "8.0.0-beta.5", 4 | "description": "Rails-inspired generator system that provides scaffolding for your apps", 5 | "keywords": [ 6 | "development", 7 | "dev", 8 | "build", 9 | "tool", 10 | "cli", 11 | "scaffold", 12 | "scaffolding", 13 | "generate", 14 | "generator", 15 | "yeoman", 16 | "app" 17 | ], 18 | "homepage": "http://yeoman.io", 19 | "repository": "yeoman/generator", 20 | "license": "BSD-2-Clause", 21 | "author": "Yeoman", 22 | "type": "module", 23 | "exports": { 24 | ".": { 25 | "types": "./dist/index.d.js", 26 | "default": "./dist/index.js" 27 | }, 28 | "./typed": { 29 | "types": "./dist/index.d.js", 30 | "import": "./dist/index.js" 31 | } 32 | }, 33 | "main": "./dist/index.js", 34 | "types": "./dist/index.d.js", 35 | "files": [ 36 | "dist" 37 | ], 38 | "scripts": { 39 | "build": "tsc && npm run copy-types", 40 | "copy-types": "cpy \"src/**/*.d.(|c|m)ts\" dist/", 41 | "coverage": "c8 report --reporter=text-lcov | coveralls", 42 | "doc": "npm run doc:generate && npm run doc:fix && npm run doc:prettier", 43 | "doc:fix": "sed -i -e 's:^[[:space:]]*[[:space:]]*$::g' $npm_package_config_doc_path$DOC_FOLDER/global.html || true", 44 | "doc:generate": "jsdoc -c jsdoc.json -d $npm_package_config_doc_path$DOC_FOLDER", 45 | "doc:prettier": "prettier $npm_package_config_doc_path$DOC_FOLDER --write --ignore-path .prettierignore-doc", 46 | "fix": "eslint . --fix && prettier . --write", 47 | "prepare": "npm run build", 48 | "pretest": "eslint . && prettier . --check && npm run build", 49 | "test": "vitest run --coverage" 50 | }, 51 | "config": { 52 | "doc_path": "../yeoman-generator-doc/" 53 | }, 54 | "dependencies": { 55 | "@types/lodash-es": "^4.17.12", 56 | "@yeoman/namespace": "^1.0.1", 57 | "chalk": "^5.4.1", 58 | "debug": "^4.4.1", 59 | "execa": "^9.5.3", 60 | "latest-version": "^9.0.0", 61 | "lodash-es": "^4.17.21", 62 | "mem-fs-editor": "^11.1.4", 63 | "minimist": "^1.2.8", 64 | "read-package-up": "^11.0.0", 65 | "semver": "^7.7.2", 66 | "simple-git": "^3.27.0", 67 | "sort-keys": "^5.1.0", 68 | "text-table": "^0.2.0", 69 | "type-fest": "^4.41.0" 70 | }, 71 | "devDependencies": { 72 | "@types/debug": "^4.1.12", 73 | "@types/ejs": "^3.1.5", 74 | "@types/minimist": "^1.2.5", 75 | "@types/semver": "^7.7.0", 76 | "@types/sinon": "^17.0.4", 77 | "@types/text-table": "^0.2.5", 78 | "@vitest/coverage-v8": "^3.1.3", 79 | "@yeoman/adapter": "^2.1.1", 80 | "@yeoman/eslint": "0.2.0", 81 | "@yeoman/transform": "^2.1.0", 82 | "cpy-cli": "^5.0.0", 83 | "ejs": "^3.1.10", 84 | "eslint": "9.12.0", 85 | "inquirer": "^12.6.1", 86 | "jsdoc": "^4.0.4", 87 | "prettier": "3.5.3", 88 | "prettier-plugin-packagejson": "2.5.12", 89 | "sinon": "^20.0.0", 90 | "typescript": "5.8.3", 91 | "vitest": "^3.1.3", 92 | "yeoman-assert": "^3.1.1", 93 | "yeoman-environment": "^5.0.0-beta.0", 94 | "yeoman-test": "^10.1.1" 95 | }, 96 | "peerDependencies": { 97 | "@types/node": ">=18.18.5", 98 | "@yeoman/types": "^1.1.1", 99 | "mem-fs": "^4.0.0" 100 | }, 101 | "peerDependenciesMeta": { 102 | "@types/node": { 103 | "optional": true 104 | } 105 | }, 106 | "engines": { 107 | "node": "^20.17.0 || >=22.9.0" 108 | }, 109 | "overrides": { 110 | "yeoman-test": { 111 | "yeoman-generator": "file:." 112 | } 113 | } 114 | } 115 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | # Generator [![npm](https://badge.fury.io/js/yeoman-generator.svg)](http://badge.fury.io/js/yeoman-generator) [![Integration Build](https://github.com/yeoman/generator/actions/workflows/integration.yml/badge.svg)](https://github.com/yeoman/generator/actions/workflows/integration.yml) [![Coverage Status](https://coveralls.io/repos/yeoman/generator/badge.svg)](https://coveralls.io/r/yeoman/generator) [![Gitter](https://img.shields.io/badge/Gitter-Join_the_Yeoman_chat_%E2%86%92-00d06f.svg)](https://gitter.im/yeoman/yeoman) 2 | 3 | > Rails-inspired generator system that provides scaffolding for your apps 4 | 5 | ![](https://raw.githubusercontent.com/yeoman/media/master/optimized/yeoman-masthead.png) 6 | 7 | ## Getting Started 8 | 9 | If you're interested in writing your own Yeoman generator we recommend reading [the official getting started guide](http://yeoman.io/authoring/). The guide covers all the basics you need to get started. 10 | 11 | A generator can be as complex as you want it to be. It can simply copy a bunch of boilerplate files, or it can be more advanced asking the user's preferences to scaffold a tailor made project. This decision is up to you. 12 | 13 | The fastest way to get started is to use [generator-generator](https://github.com/yeoman/generator-generator), a Yeoman generator to generate a Yeoman generator. 14 | 15 | After reading the getting started guide, you might want to read the code source or visit our [API documentation](http://yeoman.io/generator/) for a list of all methods available. 16 | 17 | [API documentation for v4.x](https://yeoman.github.io/generator/4.x). 18 | 19 | ### Debugging 20 | 21 | See the [debugging guide](http://yeoman.io/authoring/debugging.html). 22 | 23 | ## Contributing 24 | 25 | We love contributors! See our [contribution guideline](http://yeoman.io/contributing/) to get started. 26 | 27 | ## Sponsors 28 | 29 | Love Yeoman work and community? Help us keep it alive by donating funds to cover project expenses!
30 | [[Become a sponsor](https://opencollective.com/yeoman#support)] 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | 96 | 97 | 98 | 99 | 100 | 101 | 102 | 103 | 104 | 105 | 106 | 107 | 108 | 109 | 110 | 111 | 112 | 113 | 114 | 115 | 116 | 117 | 118 | 119 | 120 | 121 | 122 | 123 | ## License 124 | 125 | [BSD license](http://opensource.org/licenses/bsd-license.php) 126 | Copyright (c) Google 127 | -------------------------------------------------------------------------------- /src/actions/fs.ts: -------------------------------------------------------------------------------- 1 | /* eslint max-params: [1, 6] */ 2 | import assert from 'node:assert'; 3 | import { type CopyOptions, type MemFsEditor } from 'mem-fs-editor'; 4 | import type { Data as TemplateData, Options as TemplateOptions } from 'ejs'; 5 | import type { OverloadParameters, OverloadReturnType } from '../types-utils.js'; 6 | import type { BaseGenerator } from '../generator.js'; 7 | 8 | export type Template = { 9 | /** 10 | * Template file, absolute or relative to templatePath(). 11 | */ 12 | source: string; 13 | /** 14 | * Conditional if the template should be written. 15 | * @param TemplateData 16 | * @param Generator 17 | * @returns 18 | */ 19 | when?: (data: D, generator: G) => boolean; 20 | /** 21 | * Destination, absolute or relative to destinationPath(). 22 | */ 23 | destination?: string; 24 | /** 25 | * Mem-fs-editor copy options 26 | */ 27 | copyOptions?: CopyOptions; 28 | /** 29 | * Ejs data 30 | */ 31 | templateData?: TemplateData; 32 | /** 33 | * Ejs options 34 | */ 35 | templateOptions?: TemplateOptions; 36 | }; 37 | 38 | export type Templates = Array>; 39 | 40 | function applyToFirstStringArg( 41 | customizer: (arg1: string) => string, 42 | args: Type, 43 | ): Type { 44 | args[0] = Array.isArray(args[0]) ? args[0].map(arg => customizer(arg)) : customizer(args[0]); 45 | return args; 46 | } 47 | 48 | function applyToFirstAndSecondStringArg( 49 | customizer1: (arg1: string) => string, 50 | customizer2: (arg1: string) => string, 51 | args: Type, 52 | ): Type { 53 | args = applyToFirstStringArg(customizer1, args); 54 | args[1] = customizer2(args[1]); 55 | return args; 56 | } 57 | 58 | export class FsMixin { 59 | fs!: MemFsEditor; 60 | 61 | /** 62 | * Read file from templates folder. 63 | * mem-fs-editor method's shortcut, for more information see [mem-fs-editor]{@link https://github.com/SBoudrias/mem-fs-editor}. 64 | * Shortcut for this.fs!.read(this.templatePath(filepath)) 65 | */ 66 | readTemplate( 67 | this: BaseGenerator, 68 | ...args: OverloadParameters 69 | ): OverloadReturnType { 70 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment 71 | // @ts-expect-error 72 | return this.fs.read(...applyToFirstStringArg(this.templatePath.bind(this), args)); 73 | } 74 | 75 | /** 76 | * Copy file from templates folder to destination folder. 77 | * mem-fs-editor method's shortcut, for more information see [mem-fs-editor]{@link https://github.com/SBoudrias/mem-fs-editor}. 78 | * Shortcut for this.fs!.copy(this.templatePath(from), this.destinationPath(to)) 79 | */ 80 | copyTemplate( 81 | this: BaseGenerator, 82 | ...args: OverloadParameters 83 | ): OverloadReturnType { 84 | const [from, to, options = {}, ...remaining] = args; 85 | 86 | return this.fs.copy( 87 | from, 88 | this.destinationPath(to), 89 | { fromBasePath: this.templatePath(), ...options }, 90 | ...remaining, 91 | ); 92 | } 93 | 94 | /** 95 | * Copy file from templates folder to destination folder. 96 | * mem-fs-editor method's shortcut, for more information see [mem-fs-editor]{@link https://github.com/SBoudrias/mem-fs-editor}. 97 | * Shortcut for this.fs!.copy(this.templatePath(from), this.destinationPath(to)) 98 | */ 99 | async copyTemplateAsync( 100 | this: BaseGenerator, 101 | ...args: OverloadParameters 102 | ): OverloadReturnType { 103 | return this.fs.copyAsync( 104 | ...applyToFirstAndSecondStringArg(this.templatePath.bind(this), this.destinationPath.bind(this), args), 105 | ); 106 | } 107 | 108 | /** 109 | * Read file from destination folder 110 | * mem-fs-editor method's shortcut, for more information see [mem-fs-editor]{@link https://github.com/SBoudrias/mem-fs-editor}. 111 | * Shortcut for this.fs!.read(this.destinationPath(filepath)). 112 | */ 113 | readDestination( 114 | this: BaseGenerator, 115 | ...args: OverloadParameters 116 | ): OverloadReturnType { 117 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment 118 | // @ts-expect-error 119 | return this.fs.read(...applyToFirstStringArg(this.destinationPath.bind(this), args)); 120 | } 121 | 122 | /** 123 | * Read JSON file from destination folder 124 | * mem-fs-editor method's shortcut, for more information see [mem-fs-editor]{@link https://github.com/SBoudrias/mem-fs-editor}. 125 | * Shortcut for this.fs!.readJSON(this.destinationPath(filepath)). 126 | */ 127 | readDestinationJSON( 128 | this: BaseGenerator, 129 | ...args: OverloadParameters 130 | ): OverloadReturnType { 131 | return this.fs.readJSON(...applyToFirstStringArg(this.destinationPath.bind(this), args)); 132 | } 133 | 134 | /** 135 | * Write file to destination folder 136 | * mem-fs-editor method's shortcut, for more information see [mem-fs-editor]{@link https://github.com/SBoudrias/mem-fs-editor}. 137 | * Shortcut for this.fs!.write(this.destinationPath(filepath)). 138 | */ 139 | writeDestination( 140 | this: BaseGenerator, 141 | ...args: OverloadParameters 142 | ): OverloadReturnType { 143 | return this.fs.write(...applyToFirstStringArg(this.destinationPath.bind(this), args)); 144 | } 145 | 146 | /** 147 | * Write json file to destination folder 148 | * mem-fs-editor method's shortcut, for more information see [mem-fs-editor]{@link https://github.com/SBoudrias/mem-fs-editor}. 149 | * Shortcut for this.fs!.writeJSON(this.destinationPath(filepath)). 150 | */ 151 | writeDestinationJSON( 152 | this: BaseGenerator, 153 | ...args: OverloadParameters 154 | ): OverloadReturnType { 155 | return this.fs.writeJSON(...applyToFirstStringArg(this.destinationPath.bind(this), args)); 156 | } 157 | 158 | /** 159 | * Delete file from destination folder 160 | * mem-fs-editor method's shortcut, for more information see [mem-fs-editor]{@link https://github.com/SBoudrias/mem-fs-editor}. 161 | * Shortcut for this.fs!.delete(this.destinationPath(filepath)). 162 | */ 163 | deleteDestination( 164 | this: BaseGenerator, 165 | ...args: OverloadParameters 166 | ): OverloadReturnType { 167 | return this.fs.delete(...applyToFirstStringArg(this.destinationPath.bind(this), args)); 168 | } 169 | 170 | /** 171 | * Copy file from destination folder to another destination folder. 172 | * mem-fs-editor method's shortcut, for more information see [mem-fs-editor]{@link https://github.com/SBoudrias/mem-fs-editor}. 173 | * Shortcut for this.fs!.copy(this.destinationPath(from), this.destinationPath(to)). 174 | */ 175 | copyDestination( 176 | this: BaseGenerator, 177 | ...args: OverloadParameters 178 | ): OverloadReturnType { 179 | const [from, to, options = {}, ...remaining] = args; 180 | 181 | return this.fs.copy( 182 | from, 183 | this.destinationPath(to), 184 | { fromBasePath: this.destinationPath(), ...options }, 185 | ...remaining, 186 | ); 187 | } 188 | 189 | /** 190 | * Move file from destination folder to another destination folder. 191 | * mem-fs-editor method's shortcut, for more information see [mem-fs-editor]{@link https://github.com/SBoudrias/mem-fs-editor}. 192 | * Shortcut for this.fs!.move(this.destinationPath(from), this.destinationPath(to)). 193 | */ 194 | moveDestination( 195 | this: BaseGenerator, 196 | ...args: OverloadParameters 197 | ): OverloadReturnType { 198 | const [from, to, options, ...remaining] = args; 199 | 200 | return this.fs.move( 201 | from, 202 | this.destinationPath(to), 203 | { fromBasePath: this.destinationPath(), ...options }, 204 | ...remaining, 205 | ); 206 | } 207 | 208 | /** 209 | * Exists file on destination folder. 210 | * mem-fs-editor method's shortcut, for more information see [mem-fs-editor]{@link https://github.com/SBoudrias/mem-fs-editor}. 211 | * Shortcut for this.fs!.exists(this.destinationPath(filepath)). 212 | */ 213 | existsDestination( 214 | this: BaseGenerator, 215 | ...args: OverloadParameters 216 | ): OverloadReturnType { 217 | return this.fs.exists(...applyToFirstStringArg(this.destinationPath.bind(this), args)); 218 | } 219 | 220 | /** 221 | * Copy a template from templates folder to the destination. 222 | * 223 | * @param source - template file, absolute or relative to templatePath(). 224 | * @param destination - destination, absolute or relative to destinationPath(). 225 | * @param templateData - ejs data 226 | * @param templateOptions - ejs options 227 | * @param copyOptions - mem-fs-editor copy options 228 | */ 229 | renderTemplate( 230 | this: BaseGenerator, 231 | source: string | string[] = '', 232 | destination: string | string[] = source, 233 | templateData?: string | D, 234 | templateOptions?: TemplateOptions, 235 | copyOptions?: CopyOptions, 236 | ) { 237 | if (templateData === undefined || typeof templateData === 'string') { 238 | templateData = this._templateData(templateData); 239 | } 240 | 241 | templateOptions = { context: this, ...templateOptions }; 242 | 243 | source = Array.isArray(source) ? source : [source]; 244 | const templatePath = this.templatePath(...source); 245 | destination = Array.isArray(destination) ? destination : [destination]; 246 | const destinationPath = this.destinationPath(...destination); 247 | 248 | this.fs.copyTpl(templatePath, destinationPath, templateData as TemplateData, templateOptions, { 249 | fromBasePath: this.templatePath(), 250 | ...copyOptions, 251 | }); 252 | } 253 | 254 | /** 255 | * Copy a template from templates folder to the destination. 256 | * 257 | * @param source - template file, absolute or relative to templatePath(). 258 | * @param destination - destination, absolute or relative to destinationPath(). 259 | * @param templateData - ejs data 260 | * @param templateOptions - ejs options 261 | * @param copyOptions - mem-fs-editor copy options 262 | */ 263 | async renderTemplateAsync( 264 | this: BaseGenerator, 265 | source: string | string[] = '', 266 | destination: string | string[] = source, 267 | templateData?: string | D, 268 | templateOptions?: TemplateOptions, 269 | copyOptions?: CopyOptions, 270 | ) { 271 | if (templateData === undefined || typeof templateData === 'string') { 272 | templateData = this._templateData(templateData); 273 | } 274 | 275 | templateOptions = { context: this, ...templateOptions }; 276 | 277 | source = Array.isArray(source) ? source : [source]; 278 | const templatePath = this.templatePath(...source); 279 | destination = Array.isArray(destination) ? destination : [destination]; 280 | const destinationPath = this.destinationPath(...destination); 281 | 282 | return this.fs.copyTplAsync(templatePath, destinationPath, templateData as TemplateData, templateOptions, { 283 | fromBasePath: this.templatePath(), 284 | ...copyOptions, 285 | }); 286 | } 287 | 288 | /** 289 | * Copy templates from templates folder to the destination. 290 | */ 291 | renderTemplates( 292 | this: BaseGenerator, 293 | templates: Templates, 294 | templateData?: string | D, 295 | ) { 296 | assert(Array.isArray(templates), 'Templates must an array'); 297 | if (templateData === undefined || typeof templateData === 'string') { 298 | templateData = this._templateData(templateData); 299 | } 300 | 301 | for (const template of templates) { 302 | const { templateData: eachData = templateData, source, destination } = template; 303 | if (!template.when || template.when(eachData as D, this)) { 304 | this.renderTemplate(source, destination, eachData, template.templateOptions, { 305 | fromBasePath: this.templatePath(), 306 | ...template.copyOptions, 307 | }); 308 | } 309 | } 310 | } 311 | 312 | /** 313 | * Copy templates from templates folder to the destination. 314 | * 315 | * @param templates - template file, absolute or relative to templatePath(). 316 | * @param templateData - ejs data 317 | */ 318 | async renderTemplatesAsync( 319 | this: BaseGenerator, 320 | templates: Templates, 321 | templateData?: string | D, 322 | ) { 323 | assert(Array.isArray(templates), 'Templates must an array'); 324 | if (templateData === undefined || typeof templateData === 'string') { 325 | templateData = this._templateData(templateData); 326 | } 327 | 328 | return Promise.all( 329 | templates.map(async template => { 330 | const { templateData: eachData = templateData, source, destination } = template; 331 | if (!template.when || template.when(eachData as D, this)) { 332 | return this.renderTemplateAsync(source, destination, eachData, template.templateOptions, { 333 | fromBasePath: this.templatePath(), 334 | ...template.copyOptions, 335 | }); 336 | } 337 | 338 | return; 339 | }), 340 | ); 341 | } 342 | 343 | /** 344 | * Utility method to get a formatted data for templates. 345 | * 346 | * @param path - path to the storage key. 347 | * @return data to be passed to the templates. 348 | */ 349 | _templateData(this: BaseGenerator, path?: string): D { 350 | if (path) { 351 | return this.config.getPath(path) as any as D; 352 | } 353 | 354 | const allConfig: D = this.config.getAll() as D; 355 | if (this.generatorConfig) { 356 | Object.assign(allConfig as any, this.generatorConfig.getAll()); 357 | } 358 | 359 | if (this.instanceConfig) { 360 | Object.assign(allConfig as any, this.instanceConfig.getAll()); 361 | } 362 | 363 | return allConfig; 364 | } 365 | } 366 | -------------------------------------------------------------------------------- /src/actions/help.ts: -------------------------------------------------------------------------------- 1 | import path from 'node:path'; 2 | import fs from 'node:fs'; 3 | import table from 'text-table'; 4 | import type { ArgumentSpec, CliOptionSpec } from '../types.js'; 5 | import type { BaseGenerator } from '../generator.js'; 6 | 7 | function formatArg(config: ArgumentSpec) { 8 | let arg = `<${config.name}>`; 9 | 10 | if (!config.required) { 11 | arg = `[${arg}]`; 12 | } 13 | 14 | return arg; 15 | } 16 | 17 | export class HelpMixin { 18 | declare readonly _options: Record; 19 | declare readonly _arguments: ArgumentSpec[]; 20 | 21 | /** 22 | * Tries to get the description from a USAGE file one folder above the 23 | * source root otherwise uses a default description 24 | * 25 | * @return Help message of the generator 26 | */ 27 | help(this: BaseGenerator): string { 28 | const filepath = path.resolve(this.sourceRoot(), '../USAGE'); 29 | const exists = fs.existsSync(filepath); 30 | 31 | let out = ['Usage:', ` ${this.usage()}`, '']; 32 | 33 | // Build options 34 | if (Object.keys(this._options).length > 0) { 35 | out = [...out, 'Options:', this.optionsHelp(), '']; 36 | } 37 | 38 | // Build arguments 39 | if (this._arguments.length > 0) { 40 | out = [...out, 'Arguments:', this.argumentsHelp(), '']; 41 | } 42 | 43 | // Append USAGE file is any 44 | if (exists) { 45 | out.push(fs.readFileSync(filepath, 'utf8')); 46 | } 47 | 48 | return out.join('\n'); 49 | } 50 | 51 | /** 52 | * Output usage information for this given generator, depending on its arguments 53 | * or options 54 | * 55 | * @return Usage information of the generator 56 | */ 57 | usage(this: BaseGenerator): string { 58 | const options = Object.keys(this._options).length > 0 ? '[options]' : ''; 59 | let name = this._namespace; 60 | let args = ''; 61 | 62 | if (this._arguments.length > 0) { 63 | args = `${this._arguments.map(arg => formatArg(arg)).join(' ')} `; 64 | } 65 | 66 | name = name.replace(/^yeoman:/, ''); 67 | let out = `yo ${name} ${args}${options}`; 68 | 69 | if (this.description) { 70 | out += `\n\n${this.description}`; 71 | } 72 | 73 | return out; 74 | } 75 | 76 | /** 77 | * Simple setter for custom `description` to append on help output. 78 | * 79 | * @param description 80 | */ 81 | desc(this: BaseGenerator, description: string) { 82 | this.description = description || ''; 83 | return this; 84 | } 85 | 86 | /** 87 | * Get help text for arguments 88 | * @returns Text of options in formatted table 89 | */ 90 | argumentsHelp(this: BaseGenerator): string { 91 | const rows = this._arguments.map(config => { 92 | return [ 93 | '', 94 | config.name ?? '', 95 | config.description ? `# ${config.description}` : '', 96 | config.type ? `Type: ${config.type.name}` : '', 97 | `Required: ${config.required}`, 98 | ]; 99 | }); 100 | 101 | return table(rows); 102 | } 103 | 104 | /** 105 | * Get help text for options 106 | * @returns Text of options in formatted table 107 | */ 108 | optionsHelp(this: BaseGenerator): string { 109 | const rows = Object.values(this._options) 110 | .filter((opt: any) => !opt.hide) 111 | .map((opt: any) => { 112 | return [ 113 | '', 114 | opt.alias ? `-${opt.alias}, ` : '', 115 | `--${opt.name}`, 116 | opt.description ? `# ${opt.description}` : '', 117 | opt.default !== undefined && opt.default !== '' ? `Default: ${opt.default}` : '', 118 | ]; 119 | }); 120 | 121 | return table(rows); 122 | } 123 | } 124 | -------------------------------------------------------------------------------- /src/actions/lifecycle.ts: -------------------------------------------------------------------------------- 1 | import { dirname, isAbsolute, resolve as pathResolve, relative } from 'node:path'; 2 | import { pathToFileURL } from 'node:url'; 3 | import { createRequire } from 'node:module'; 4 | import { Transform } from 'node:stream'; 5 | import { stat } from 'node:fs/promises'; 6 | import createDebug from 'debug'; 7 | import type { BaseGenerator, ComposeOptions as EnvironmentComposeOptions, GetGeneratorOptions } from '@yeoman/types'; 8 | import { toNamespace } from '@yeoman/namespace'; 9 | import { type FileTransform, isFileTransform } from 'mem-fs'; 10 | import { type MemFsEditorFile } from 'mem-fs-editor'; 11 | import { isFilePending } from 'mem-fs-editor/state'; 12 | import type { BaseOptions, ComposeOptions, GeneratorPipelineOptions, Priority, Task, TaskOptions } from '../types.js'; 13 | import type Generator from '../index.js'; 14 | import type BaseGeneratorImpl from '../generator.js'; 15 | 16 | const debug = createDebug('yeoman:generator'); 17 | 18 | type TaskStatus = { 19 | cancelled: boolean; 20 | timestamp: Date; 21 | }; 22 | 23 | // Ensure a prototype method is a candidate run by default 24 | const methodIsValid = function (name: string) { 25 | return !name.startsWith('_') && name !== 'constructor'; 26 | }; 27 | 28 | export abstract class TasksMixin { 29 | // Queues map: generator's queue name => grouped-queue's queue name (custom name) 30 | readonly _queues!: Record; 31 | 32 | customLifecycle?: boolean; 33 | runningState?: { namespace: string; queueName: string; methodName: string }; 34 | _taskStatus?: TaskStatus; 35 | 36 | /** 37 | * Register priorities for this generator 38 | */ 39 | registerPriorities(this: BaseGeneratorImpl, priorities: Priority[]) { 40 | priorities = priorities.filter(({ priorityName, edit, ...priority }) => { 41 | if (edit) { 42 | const queue = this._queues[priorityName]; 43 | if (!queue) { 44 | throw new Error(`Error editing priority ${priorityName}, not found`); 45 | } 46 | 47 | Object.assign(queue, { ...priority, edit: undefined }); 48 | } 49 | 50 | return !edit; 51 | }); 52 | 53 | const customPriorities = priorities.map(customPriority => ({ ...customPriority })); 54 | // Sort customPriorities, a referenced custom queue must be added before the one that reference it. 55 | customPriorities.sort((a, b) => { 56 | if (a.priorityName === b.priorityName) { 57 | throw new Error(`Duplicate custom queue ${a.priorityName}`); 58 | } 59 | 60 | if (a.priorityName === b.before) { 61 | return -1; 62 | } 63 | 64 | if (b.priorityName === a.before) { 65 | return 1; 66 | } 67 | 68 | return 0; 69 | }); 70 | 71 | for (const customQueue of customPriorities) { 72 | customQueue.queueName = customQueue.queueName ?? `${this._namespace}#${customQueue.priorityName}`; 73 | debug(`Registering custom queue ${customQueue.queueName}`); 74 | this._queues[customQueue.priorityName] = customQueue; 75 | 76 | const beforeQueue = customQueue.before ? this._queues[customQueue.before].queueName : undefined; 77 | this.env.addPriority(customQueue.queueName, beforeQueue); 78 | } 79 | } 80 | 81 | /** 82 | * Schedule methods on a run queue. 83 | * 84 | * @param method: Method to be scheduled or object with function properties. 85 | * @param methodName Name of the method (task) to be scheduled. 86 | * @param queueName Name of the queue to be scheduled on. 87 | * @param reject Reject callback. 88 | */ 89 | queueMethod(method: Task['method'], methodName: string, queueName: Task['queueName'], reject: Task['reject']): void; 90 | queueMethod( 91 | method: Record, 92 | methodName: string | Task['reject'], 93 | reject?: Task['reject'], 94 | ): void; 95 | queueMethod( 96 | this: BaseGeneratorImpl, 97 | method: Task['method'] | Record, 98 | methodName: string | Task['reject'], 99 | queueName?: Task['queueName'] | Task['reject'], 100 | reject?: Task['reject'], 101 | ): void { 102 | if (typeof queueName === 'function') { 103 | reject = queueName; 104 | queueName = undefined; 105 | } else { 106 | queueName = queueName ?? 'default'; 107 | } 108 | 109 | if (typeof method !== 'function') { 110 | if (typeof methodName === 'function') { 111 | reject = methodName; 112 | methodName = undefined; 113 | } 114 | 115 | this.queueTaskGroup(method, { 116 | queueName: methodName, 117 | reject, 118 | }); 119 | return; 120 | } 121 | 122 | this.queueTask({ 123 | method, 124 | taskName: methodName as string, 125 | queueName, 126 | reject, 127 | }); 128 | } 129 | 130 | /** 131 | * Schedule tasks from a group on a run queue. 132 | * 133 | * @param taskGroup: Object containing tasks. 134 | * @param taskOptions options. 135 | */ 136 | queueTaskGroup(this: BaseGeneratorImpl, taskGroup: Record, taskOptions: TaskOptions): void { 137 | for (const task of this.extractTasksFromGroup(taskGroup, taskOptions)) { 138 | this.queueTask(task); 139 | } 140 | } 141 | 142 | /** 143 | * Get task sources property descriptors. 144 | */ 145 | getTaskSourcesPropertyDescriptors(this: BaseGeneratorImpl): any { 146 | if (this.features.inheritTasks) { 147 | const queueNames = Object.keys(this._queues); 148 | let currentPrototype = Object.getPrototypeOf(this); 149 | let propertyDescriptors = []; 150 | while (currentPrototype) { 151 | propertyDescriptors.unshift(...Object.entries(Object.getOwnPropertyDescriptors(currentPrototype))); 152 | currentPrototype = Object.getPrototypeOf(currentPrototype); 153 | } 154 | 155 | const { taskPrefix = '' } = this.features; 156 | propertyDescriptors = propertyDescriptors.filter( 157 | ([name]) => name.startsWith(taskPrefix) && queueNames.includes(name.slice(taskPrefix.length)), 158 | ); 159 | return Object.fromEntries(propertyDescriptors); 160 | } 161 | 162 | return Object.getOwnPropertyDescriptors(Object.getPrototypeOf(this)); 163 | } 164 | 165 | /** 166 | * Extract tasks from a priority. 167 | * 168 | * @param name: The method name to schedule. 169 | */ 170 | extractTasksFromPriority(this: BaseGeneratorImpl, name: string, taskOptions: TaskOptions = {}): Task[] { 171 | const priority = this._queues[name]; 172 | taskOptions = { 173 | ...priority, 174 | cancellable: true, 175 | run: false, 176 | ...taskOptions, 177 | }; 178 | 179 | if (taskOptions.auto && priority && priority.skip) { 180 | return []; 181 | } 182 | 183 | const { taskPrefix = this.features.taskPrefix ?? '' } = taskOptions; 184 | const propertyName = `${taskPrefix}${name}`; 185 | const property = taskOptions.taskOrigin 186 | ? Object.getOwnPropertyDescriptor(taskOptions.taskOrigin, propertyName) 187 | : this.getTaskSourcesPropertyDescriptors()[propertyName]; 188 | 189 | if (!property) return []; 190 | 191 | const item: Task['method'] = property.value ?? property.get?.call(this); 192 | 193 | // Name points to a function; single task 194 | if (typeof item === 'function') { 195 | return [{ ...taskOptions, taskName: name, method: item }]; 196 | } 197 | 198 | if (!item || !priority) { 199 | return []; 200 | } 201 | 202 | return this.extractTasksFromGroup(item, taskOptions); 203 | } 204 | 205 | /** 206 | * Extract tasks from group. 207 | * 208 | * @param group Task group. 209 | * @param taskOptions options. 210 | */ 211 | extractTasksFromGroup( 212 | this: BaseGeneratorImpl, 213 | group: Record, 214 | taskOptions: TaskOptions, 215 | ): Task[] { 216 | return Object.entries(group) 217 | .map(([taskName, method]) => { 218 | if (typeof method !== 'function' || !methodIsValid(taskName)) return; 219 | return { 220 | ...taskOptions, 221 | method, 222 | taskName, 223 | }; 224 | }) 225 | .filter(Boolean) as Task[]; 226 | } 227 | 228 | /** 229 | * Schedule a generator's method on a run queue. 230 | * 231 | * @param name: The method name to schedule. 232 | * @param taskOptions options. 233 | */ 234 | queueOwnTask(this: BaseGeneratorImpl, name: string, taskOptions: TaskOptions): void { 235 | for (const task of this.extractTasksFromPriority(name, taskOptions)) this.queueTask(task); 236 | } 237 | 238 | /** 239 | * Get task names. 240 | */ 241 | getTaskNames(this: BaseGeneratorImpl): string[] { 242 | const methods = Object.keys(this.getTaskSourcesPropertyDescriptors()); 243 | let validMethods = methods.filter(method => methodIsValid(method)); 244 | const { taskPrefix } = this.features; 245 | 246 | validMethods = taskPrefix 247 | ? validMethods.filter(method => method.startsWith(taskPrefix)).map(method => method.slice(taskPrefix.length)) 248 | : validMethods.filter(method => !method.startsWith('#')); 249 | 250 | if (this.features.tasksMatchingPriority) { 251 | const queueNames = Object.keys(this._queues); 252 | validMethods = validMethods.filter(method => queueNames.includes(method)); 253 | } 254 | 255 | return validMethods; 256 | } 257 | 258 | /** 259 | * Schedule every generator's methods on a run queue. 260 | */ 261 | queueOwnTasks(this: BaseGeneratorImpl, taskOptions: TaskOptions): void { 262 | this._running = true; 263 | this._taskStatus = { cancelled: false, timestamp: new Date() }; 264 | 265 | const validMethods = this.getTaskNames(); 266 | if (validMethods.length === 0 && this._prompts.length === 0 && !this.customLifecycle) { 267 | throw new Error('This Generator is empty. Add at least one method for it to run.'); 268 | } 269 | 270 | this.emit('before:queueOwnTasks'); 271 | 272 | if (this._prompts.length > 0) { 273 | this.queueTask({ 274 | method: async () => (this as any).prompt(this._prompts, this.config), 275 | taskName: 'Prompt registered questions', 276 | queueName: 'prompting', 277 | cancellable: true, 278 | }); 279 | 280 | if (validMethods.length === 0) { 281 | this.queueTask({ 282 | method: () => { 283 | (this as any).renderTemplate(); 284 | }, 285 | taskName: 'Empty generator: copy templates', 286 | queueName: 'writing', 287 | cancellable: true, 288 | }); 289 | } 290 | } 291 | 292 | for (const methodName of validMethods) this.queueOwnTask(methodName, taskOptions); 293 | 294 | this.emit('queueOwnTasks'); 295 | } 296 | 297 | /** 298 | * Schedule tasks on a run queue. 299 | * 300 | * @param task: Task to be queued. 301 | */ 302 | queueTask(this: BaseGeneratorImpl, task: Task): void { 303 | const { queueName = 'default', taskName: methodName, run, once } = task; 304 | 305 | const { _taskStatus: taskStatus, _namespace: namespace } = this; 306 | 307 | debug(`Queueing ${namespace}#${methodName} with options %o`, { ...task, method: undefined }); 308 | this.env.queueTask(queueName, async () => this.executeTask(task, undefined, taskStatus), { 309 | once: once ? methodName : undefined, 310 | startQueue: run, 311 | }); 312 | } 313 | 314 | /** 315 | * Execute a task. 316 | * 317 | * @param task: Task to be executed. 318 | * @param args: Task arguments. 319 | * @param taskStatus. 320 | */ 321 | async executeTask( 322 | this: BaseGeneratorImpl, 323 | task: Task, 324 | args = task.args, 325 | taskStatus: TaskStatus | undefined = this._taskStatus, 326 | ): Promise { 327 | const { reject, queueName = 'default', taskName: methodName, method } = task; 328 | const { _namespace: namespace } = this; 329 | 330 | debug(`Running ${namespace}#${methodName}`); 331 | this.emit(`method:${methodName}`); 332 | const taskCancelled = task.cancellable && taskStatus?.cancelled; 333 | if (taskCancelled) { 334 | return; 335 | } 336 | const [priorityName, priority] = 337 | Object.entries(this._queues).find(([_, queue]) => queue.queueName === queueName) ?? []; 338 | args ??= priority?.args ?? this.args; 339 | try { 340 | args = typeof args === 'function' ? args(this as any) : args; 341 | } catch (error: any) { 342 | throw new Error(`Error while building arguments for ${namespace}#${methodName}: ${error.message}`, { 343 | cause: error, 344 | }); 345 | } 346 | this.runningState = { namespace, queueName, methodName }; 347 | try { 348 | await method.apply(this, args); 349 | delete this.runningState; 350 | const eventName = `done$${namespace || 'unknownnamespace'}#${methodName}`; 351 | debug(`Done event ${eventName}`); 352 | this.env.emit(eventName, { 353 | namespace, 354 | generator: this, 355 | queueName, 356 | priorityName, 357 | }); 358 | } catch (error: unknown) { 359 | const errorMessage = `An error occured while running ${namespace}#${methodName}`; 360 | if (this.log?.error) { 361 | this.log.error(errorMessage); 362 | } else { 363 | debug(errorMessage); 364 | } 365 | 366 | if (reject) { 367 | debug('Rejecting task promise, queue will continue normally'); 368 | reject(error); 369 | return; 370 | } 371 | 372 | throw error; 373 | } finally { 374 | delete this.runningState; 375 | } 376 | } 377 | 378 | /** 379 | * Ignore cancellable tasks. 380 | */ 381 | cancelCancellableTasks(this: BaseGeneratorImpl): void { 382 | this._running = false; 383 | // Task status references is registered at each running task 384 | if (this._taskStatus) { 385 | this._taskStatus.cancelled = true; 386 | } 387 | 388 | // Create a new task status. 389 | delete this._taskStatus; 390 | } 391 | 392 | /** 393 | * Queue generator tasks. 394 | */ 395 | async queueTasks(this: BaseGeneratorImpl): Promise { 396 | const thisAny = this as any; 397 | const thisPrototype = Object.getPrototypeOf(thisAny); 398 | 399 | let beforeQueueCallback: (() => Promise) | undefined; 400 | if (this.features.taskPrefix) { 401 | // We want beforeQueue if beforeQueue belongs to the object or to the imediatelly extended class. 402 | beforeQueueCallback = 403 | Object.hasOwn(thisAny, 'beforeQueue') || Object.hasOwn(thisPrototype, 'beforeQueue') 404 | ? thisAny.beforeQueue 405 | : undefined; 406 | } 407 | 408 | if (!beforeQueueCallback) { 409 | // Fallback to _beforeQueue, 410 | beforeQueueCallback = 411 | Object.hasOwn(thisAny, '_beforeQueue') || Object.hasOwn(thisPrototype, '_beforeQueue') 412 | ? thisAny._beforeQueue 413 | : undefined; 414 | } 415 | 416 | if (beforeQueueCallback) { 417 | await beforeQueueCallback.call(this); 418 | } 419 | 420 | await this._queueTasks(); 421 | } 422 | 423 | async _queueTasks(this: BaseGeneratorImpl): Promise { 424 | debug(`Queueing generator ${this._namespace} with generator version ${this.yoGeneratorVersion}`); 425 | this.queueOwnTasks({ auto: true }); 426 | } 427 | 428 | /** 429 | * Start the generator again. 430 | */ 431 | startOver(this: BaseGeneratorImpl, options?: BaseOptions): void { 432 | this.cancelCancellableTasks(); 433 | if (options) { 434 | Object.assign(this.options, options); 435 | } 436 | 437 | this.queueOwnTasks({ auto: true }); 438 | } 439 | 440 | /** 441 | * Compose this generator with another one. 442 | * @param generator The path to the generator module or an object (see examples) 443 | * @param args Arguments passed to the Generator 444 | * @param options The options passed to the Generator 445 | * @param immediately Boolean whether to queue the Generator immediately 446 | * @return The composed generator 447 | * 448 | * @example Using a peerDependency generator 449 | * await this.composeWith('bootstrap', { sass: true }); 450 | * 451 | * @example Using a direct dependency generator 452 | * await this.composeWith(path.resolve(_dirname, 'generator-bootstrap/app/main.js'), { sass: true }); 453 | * 454 | * @example Passing a Generator class 455 | * await this.composeWith({ Generator: MyGenerator, path: '../generator-bootstrap/app/main.js' }, { sass: true }); 456 | */ 457 | async composeWith( 458 | generator: string | { Generator: any; path: string }, 459 | immediately?: boolean, 460 | ): Promise; 461 | async composeWith(generator: string[], immediately?: boolean): Promise; 462 | async composeWith( 463 | generator: string | { Generator: any; path: string }, 464 | options: Partial>, 465 | immediately?: boolean, 466 | ): Promise; 467 | async composeWith( 468 | generator: string[], 469 | options: Partial>, 470 | immediately?: boolean, 471 | ): Promise; 472 | async composeWith( 473 | generator: string | { Generator: any; path: string }, 474 | args: string[], 475 | options?: Partial>, 476 | immediately?: boolean, 477 | ): Promise; 478 | async composeWith( 479 | generator: string[], 480 | args: string[], 481 | options?: Partial>, 482 | immediately?: boolean, 483 | ): Promise; 484 | async composeWith( 485 | generator: string, 486 | options?: ComposeOptions, 487 | ): Promise; 488 | 489 | async composeWith( 490 | this: BaseGeneratorImpl, 491 | generator: string | string[] | { Generator: any; path: string }, 492 | args?: 493 | | string[] 494 | | (Partial> | { arguments?: string[]; args?: string[] }) 495 | | boolean 496 | | ComposeOptions, 497 | options?: Partial> | boolean, 498 | immediately = false, 499 | ): Promise { 500 | if (Array.isArray(generator)) { 501 | const generators: Generator[] = []; 502 | for (const each of generator) { 503 | generators.push(await this.composeWith(each, args as any, options as any)); 504 | } 505 | 506 | return generators as any; 507 | } 508 | 509 | if ( 510 | typeof args === 'object' && 511 | ('generatorArgs' in args || 512 | 'generatorOptions' in args || 513 | 'skipEnvRegister' in args || 514 | 'forceResolve' in args || 515 | 'forwardOptions' in args) 516 | ) { 517 | return this.composeWithOptions(generator, args); 518 | } 519 | 520 | let parsedArgs: string[] = []; 521 | let parsedOptions: Partial = {}; 522 | if (typeof args === 'boolean') { 523 | return this.composeWithOptions(generator, { schedule: !args }); 524 | } 525 | 526 | if (Array.isArray(args)) { 527 | if (typeof options === 'object') { 528 | return this.composeWithOptions(generator, { 529 | generatorArgs: args, 530 | generatorOptions: options, 531 | schedule: !immediately, 532 | }); 533 | } 534 | 535 | if (typeof options === 'boolean') { 536 | return this.composeWithOptions(generator, { generatorArgs: args, schedule: !options }); 537 | } 538 | 539 | return this.composeWithOptions(generator, { generatorArgs: args }); 540 | } 541 | 542 | if (typeof args === 'object') { 543 | parsedOptions = args as any; 544 | parsedArgs = (args as any).arguments ?? (args as any).args ?? []; 545 | if (typeof options === 'boolean') { 546 | immediately = options; 547 | } 548 | 549 | return this.composeWithOptions(generator, { 550 | generatorArgs: parsedArgs, 551 | generatorOptions: parsedOptions as any, 552 | schedule: !immediately, 553 | }); 554 | } 555 | 556 | return this.composeWithOptions(generator); 557 | } 558 | 559 | private async composeWithOptions( 560 | this: BaseGeneratorImpl, 561 | generator: string | { Generator: any; path: string }, 562 | options: ComposeOptions = {}, 563 | ): Promise { 564 | const { forceResolve, skipEnvRegister = false, forwardOptions, ...composeOptions } = options; 565 | const optionsToForward = forwardOptions 566 | ? this.options 567 | : { 568 | skipInstall: this.options.skipInstall, 569 | skipCache: this.options.skipCache, 570 | skipLocalCache: this.options.skipLocalCache, 571 | }; 572 | 573 | composeOptions.generatorOptions = { 574 | destinationRoot: this._destinationRoot, 575 | ...optionsToForward, 576 | ...composeOptions.generatorOptions, 577 | } as any; 578 | 579 | if (typeof generator === 'object') { 580 | let generatorFile; 581 | try { 582 | generatorFile = await this.resolveGeneratorPath(generator.path ?? generator.Generator.resolved); 583 | } catch { 584 | // Ignore error 585 | } 586 | 587 | const resolved = generatorFile ?? generator.path ?? generator.Generator.resolved; 588 | 589 | return this.composeLocallyWithOptions({ Generator: generator.Generator, resolved }, composeOptions); 590 | } 591 | 592 | if (skipEnvRegister || isAbsolute(generator) || generator.startsWith('.')) { 593 | const resolved = await this.resolveGeneratorPath(generator); 594 | return this.composeLocallyWithOptions({ resolved }, composeOptions); 595 | } 596 | 597 | const namespace = typeof generator === 'string' ? toNamespace(generator) : undefined; 598 | if (!namespace || forceResolve) { 599 | try { 600 | generator = await this.resolveGeneratorPath(generator); 601 | } catch { 602 | // Ignore error 603 | } 604 | } 605 | 606 | return this.env.composeWith(generator, composeOptions); 607 | } 608 | 609 | private async composeLocallyWithOptions( 610 | this: BaseGeneratorImpl, 611 | { Generator, resolved = Generator.resolved }: { Generator?: any; resolved: string }, 612 | options: EnvironmentComposeOptions = {}, 613 | ) { 614 | if (!resolved) { 615 | throw new Error('Generator path property is not a string'); 616 | } 617 | const generatorNamespace = this.env.namespace(resolved); 618 | const findGenerator = async () => { 619 | const generatorImport = await import(pathToFileURL(resolved).href); 620 | const getFactory = (module: any) => 621 | module.createGenerator ?? module.default?.createGenerator ?? module.default?.default?.createGenerator; 622 | const factory = getFactory(generatorImport); 623 | if (factory) { 624 | return factory(this.env); 625 | } 626 | 627 | return typeof generatorImport.default === 'function' ? generatorImport.default : generatorImport; 628 | }; 629 | 630 | try { 631 | Generator = Generator ?? (await findGenerator()); 632 | } catch { 633 | throw new Error('Missing Generator property'); 634 | } 635 | 636 | Generator.namespace = generatorNamespace; 637 | Generator.resolved = resolved; 638 | return this.env.composeWith(Generator, options); 639 | } 640 | 641 | private async resolveGeneratorPath(this: BaseGeneratorImpl, maybePath: string) { 642 | // Allows to run a local generator without namespace. 643 | // Resolve the generator absolute path to current generator; 644 | const generatorFile = isAbsolute(maybePath) ? maybePath : pathResolve(dirname(this.resolved), maybePath); 645 | let generatorResolvedFile: string | undefined; 646 | try { 647 | const status = await stat(generatorFile); 648 | if (status.isFile()) { 649 | generatorResolvedFile = generatorFile; 650 | } 651 | } catch { 652 | // Ignore error 653 | } 654 | 655 | if (!generatorResolvedFile) { 656 | // Resolve the generator file. 657 | // Use import.resolve when stable. 658 | generatorResolvedFile = createRequire(import.meta.url).resolve(generatorFile); 659 | } 660 | 661 | return generatorResolvedFile; 662 | } 663 | 664 | async pipeline( 665 | this: BaseGeneratorImpl, 666 | options?: GeneratorPipelineOptions, 667 | ...transforms: Array> 668 | ) { 669 | if (isFileTransform(options)) { 670 | transforms = [options, ...transforms]; 671 | options = {}; 672 | } 673 | 674 | let filter: ((file: MemFsEditorFile) => boolean) | undefined; 675 | const { disabled, name, pendingFiles = true, filter: passedFilter, ...memFsPipelineOptions } = options ?? {}; 676 | if (passedFilter && pendingFiles) { 677 | filter = (file: MemFsEditorFile) => isFilePending(file) && passedFilter(file); 678 | } else { 679 | filter = pendingFiles ? isFilePending : passedFilter; 680 | } 681 | 682 | const { env } = this; 683 | await env.adapter.progress( 684 | async ({ step }) => 685 | env.sharedFs.pipeline( 686 | { filter, ...memFsPipelineOptions }, 687 | ...transforms, 688 | new Transform({ 689 | objectMode: true, 690 | transform(file: MemFsEditorFile, _encoding, callback) { 691 | step('Completed', relative(env.logCwd, file.path)); 692 | callback(null, file); 693 | }, 694 | }), 695 | ), 696 | { disabled, name }, 697 | ); 698 | } 699 | 700 | /** 701 | * Add a transform stream to the commit stream. 702 | * 703 | * Most usually, these transform stream will be Gulp plugins. 704 | * 705 | * @param streams An array of Transform stream 706 | * or a single one. 707 | * @return This generator 708 | */ 709 | queueTransformStream( 710 | this: BaseGeneratorImpl, 711 | options?: GeneratorPipelineOptions & { priorityToQueue?: string }, 712 | ...transforms: Array> 713 | ) { 714 | if (isFileTransform(options)) { 715 | transforms = [options, ...transforms]; 716 | options = {}; 717 | } 718 | 719 | const { priorityToQueue, ...pipelineOptions } = options!; 720 | const getQueueForPriority = (priority: string): string => { 721 | const found = this._queues[priority]; 722 | if (!found) { 723 | throw new Error(`Could not find priority '${priority}'`); 724 | } 725 | 726 | return found.queueName ?? found.priorityName; 727 | }; 728 | 729 | const queueName = priorityToQueue ? getQueueForPriority(priorityToQueue) : 'transform'; 730 | 731 | this.queueTask({ 732 | method: async () => this.pipeline(pipelineOptions, ...transforms), 733 | taskName: 'transformStream', 734 | queueName, 735 | }); 736 | 737 | return this; 738 | } 739 | } 740 | -------------------------------------------------------------------------------- /src/actions/package-json.ts: -------------------------------------------------------------------------------- 1 | import latestVersion from 'latest-version'; 2 | import type { BaseGenerator } from '../generator.js'; 3 | 4 | export class PackageJsonMixin { 5 | /** 6 | * Resolve the dependencies to be added to the package.json. 7 | */ 8 | async _resolvePackageJsonDependencies( 9 | this: BaseGenerator, 10 | dependencies: string | string[] | Record, 11 | ): Promise> { 12 | if (typeof dependencies === 'string') { 13 | dependencies = [dependencies]; 14 | } else if (typeof dependencies !== 'object') { 15 | throw new TypeError('resolvePackageJsonDependencies requires an object'); 16 | } 17 | 18 | const depMap = Array.isArray(dependencies) 19 | ? Object.fromEntries( 20 | dependencies.map(dependency => { 21 | const lastIndex = dependency.lastIndexOf('@'); 22 | if (lastIndex > 0) { 23 | const depName = dependency.slice(0, lastIndex); 24 | const version = dependency.slice(lastIndex + 1); 25 | return [depName, version]; 26 | } 27 | 28 | return [dependency, undefined]; 29 | }), 30 | ) 31 | : dependencies; 32 | 33 | return Object.fromEntries( 34 | await Promise.all( 35 | // Make sure to convert empty string too 36 | 37 | Object.entries(depMap).map(async ([pkg, version]) => [pkg, version || (await latestVersion(pkg))]), 38 | ), 39 | ); 40 | } 41 | 42 | /** 43 | * Add dependencies to the destination the package.json. 44 | * 45 | * Environment watches for package.json changes at `this.env.cwd`, and triggers an package manager install if it has been committed to disk. 46 | * If package.json is at a different folder, like a changed generator root, propagate it to the Environment like `this.env.cwd = this.destinationPath()`. 47 | * 48 | * @param dependencies 49 | */ 50 | async addDependencies( 51 | this: BaseGenerator, 52 | dependencies: string | string[] | Record, 53 | ): Promise> { 54 | dependencies = await this._resolvePackageJsonDependencies(dependencies); 55 | this.packageJson.merge({ dependencies }); 56 | return dependencies; 57 | } 58 | 59 | /** 60 | * Add dependencies to the destination the package.json. 61 | * 62 | * Environment watches for package.json changes at `this.env.cwd`, and triggers an package manager install if it has been committed to disk. 63 | * If package.json is at a different folder, like a changed generator root, propagate it to the Environment like `this.env.cwd = this.destinationPath()`. 64 | * 65 | * @param dependencies 66 | */ 67 | async addDevDependencies( 68 | this: BaseGenerator, 69 | devDependencies: string | string[] | Record, 70 | ): Promise> { 71 | devDependencies = await this._resolvePackageJsonDependencies(devDependencies); 72 | this.packageJson.merge({ devDependencies }); 73 | return devDependencies; 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /src/actions/spawn-command.ts: -------------------------------------------------------------------------------- 1 | import { 2 | type Options as ExecaOptions, 3 | type ResultPromise, 4 | type SyncOptions, 5 | type SyncResult, 6 | execa, 7 | execaCommand, 8 | execaCommandSync, 9 | execaSync, 10 | } from 'execa'; 11 | import type { BaseGenerator } from '../generator.js'; 12 | 13 | export class SpawnCommandMixin { 14 | /** 15 | * Normalize a command across OS and spawn it (asynchronously). 16 | * 17 | * @param command program to execute 18 | * @param opt execa options options 19 | * @see https://github.com/sindresorhus/execa#execacommandcommand-options 20 | */ 21 | spawnCommand( 22 | this: BaseGenerator, 23 | command: string, 24 | opt?: OptionsType, 25 | ): ResultPromise { 26 | opt = { cwd: this.destinationRoot(), ...opt } as OptionsType; 27 | return execaCommand(command, opt) as any; 28 | } 29 | 30 | /** 31 | * Normalize a command across OS and spawn it (asynchronously). 32 | * 33 | * @param command program to execute 34 | * @param args list of arguments to pass to the program 35 | * @param opt execa options options 36 | * @see https://github.com/sindresorhus/execa#execafile-arguments-options 37 | */ 38 | spawn( 39 | this: BaseGenerator, 40 | command: string, 41 | args?: readonly string[], 42 | opt?: OptionsType, 43 | ): ResultPromise { 44 | opt = { cwd: this.destinationRoot(), ...opt } as OptionsType; 45 | return execa(command, args, opt) as any; 46 | } 47 | 48 | /** 49 | * Normalize a command across OS and spawn it (synchronously). 50 | * 51 | * @param command program to execute 52 | * @param opt execa options options 53 | * @see https://github.com/sindresorhus/execa#execacommandsynccommand-options 54 | */ 55 | spawnCommandSync( 56 | this: BaseGenerator, 57 | command: string, 58 | opt?: OptionsType, 59 | ): SyncResult { 60 | opt = { cwd: this.destinationRoot(), ...opt } as OptionsType; 61 | return execaCommandSync(command, opt); 62 | } 63 | 64 | /** 65 | * Normalize a command across OS and spawn it (synchronously). 66 | * 67 | * @param command program to execute 68 | * @param args list of arguments to pass to the program 69 | * @param opt execa options options 70 | * @see https://github.com/sindresorhus/execa#execafile-arguments-options 71 | */ 72 | spawnSync( 73 | this: BaseGenerator, 74 | command: string, 75 | args?: readonly string[], 76 | opt?: OptionsType, 77 | ): SyncResult { 78 | opt = { cwd: this.destinationRoot(), ...opt } as OptionsType; 79 | return execaSync(command, args, opt); 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /src/actions/user.ts: -------------------------------------------------------------------------------- 1 | import { type SimpleGit } from 'simple-git'; 2 | 3 | class GitUtil { 4 | #parent: { get simpleGit(): SimpleGit }; 5 | 6 | constructor(parent: { get simpleGit(): SimpleGit }) { 7 | this.#parent = parent; 8 | } 9 | 10 | /** 11 | * Retrieves user's name from Git in the global scope or the project scope 12 | * (it'll take what Git will use in the current context) 13 | * @return {Promise} configured git name or undefined 14 | */ 15 | async name(): Promise { 16 | const { value } = await this.#parent.simpleGit.getConfig('user.name'); 17 | return value ?? undefined; 18 | } 19 | 20 | /** 21 | * Retrieves user's email from Git in the global scope or the project scope 22 | * (it'll take what Git will use in the current context) 23 | * @return {Promise} configured git email or undefined 24 | */ 25 | async email(): Promise { 26 | const { value } = await this.#parent.simpleGit.getConfig('user.email'); 27 | return value ?? undefined; 28 | } 29 | } 30 | 31 | export abstract class GitMixin { 32 | _git?: GitUtil; 33 | 34 | get git(): GitUtil { 35 | if (!this._git) { 36 | this._git = new GitUtil(this as any); 37 | } 38 | 39 | return this._git; 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/constants.ts: -------------------------------------------------------------------------------- 1 | export const DESTINATION_ROOT_CHANGE_EVENT = 'destinationRootChange'; 2 | 3 | export const requiredEnvironmentVersion = '4.0.0-rc.0'; 4 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import process from 'node:process'; 2 | import { type SimpleGit, simpleGit } from 'simple-git'; 3 | import { BaseGenerator } from './generator.js'; 4 | import type { BaseFeatures, BaseOptions } from './types.js'; 5 | import { DESTINATION_ROOT_CHANGE_EVENT } from './constants.js'; 6 | 7 | export type * from './types.js'; 8 | export type * from './questions.js'; 9 | export type * from './util/storage.js'; 10 | export { default as Storage } from './util/storage.js'; 11 | 12 | export default class Generator< 13 | C extends Record = Record, 14 | O extends BaseOptions = BaseOptions, 15 | F extends BaseFeatures = BaseFeatures, 16 | > extends BaseGenerator { 17 | _simpleGit?: SimpleGit; 18 | 19 | constructor(...args: any[]) { 20 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment 21 | // @ts-expect-error 22 | super(...args); 23 | 24 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment 25 | // @ts-expect-error 26 | this._queues = {}; 27 | 28 | // Add original queues. 29 | for (const queue of BaseGenerator.queues) { 30 | this._queues[queue] = { priorityName: queue, queueName: queue }; 31 | } 32 | 33 | // Add custom queues 34 | if (Array.isArray(this._customPriorities)) { 35 | this.registerPriorities(this._customPriorities); 36 | } 37 | } 38 | 39 | get simpleGit(): SimpleGit { 40 | if (!this._simpleGit) { 41 | this._simpleGit = simpleGit({ baseDir: this.destinationPath() }).env({ 42 | ...process.env, 43 | 44 | LANG: 'en', 45 | }); 46 | this.on(DESTINATION_ROOT_CHANGE_EVENT, () => { 47 | this._simpleGit = undefined; 48 | }); 49 | } 50 | 51 | return this._simpleGit; 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /src/questions.d.ts: -------------------------------------------------------------------------------- 1 | import type { PromptAnswers, PromptQuestion as PromptQuestionApi } from '@yeoman/types'; 2 | import type Storage from './util/storage.js'; 3 | 4 | export type { PromptAnswers } from '@yeoman/types'; 5 | 6 | /** 7 | * Represents a question. 8 | */ 9 | export type PromptQuestion = PromptQuestionApi & { 10 | name: string; 11 | 12 | /** 13 | * The storage to store the answer. 14 | */ 15 | storage?: Storage; 16 | 17 | /** 18 | * A value indicating whether to store the user's previous answer for others projects. 19 | */ 20 | store?: boolean; 21 | }; 22 | 23 | /** 24 | * Provides options for registering a prompt. 25 | */ 26 | export type QuestionRegistrationOptions = PromptQuestion & { 27 | /** 28 | * A value indicating whether an option should be exported for this question. 29 | */ 30 | exportOption?: boolean | Record; 31 | }; 32 | 33 | /** 34 | * Provides a set of questions. 35 | */ 36 | export type PromptQuestions = PromptQuestion | Array>; // | Observable>; 37 | -------------------------------------------------------------------------------- /src/types-utils.d.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * https://github.com/microsoft/TypeScript/issues/14107#issuecomment-1146738780 3 | */ 4 | type OverloadProps = Pick; 5 | 6 | type OverloadUnionRecursive = TOverload extends ( 7 | ...args: infer TArgs 8 | ) => infer TReturn 9 | ? // Prevent infinite recursion by stopping recursion when TPartialOverload 10 | // has accumulated all of the TOverload signatures. 11 | TPartialOverload extends TOverload 12 | ? never 13 | : 14 | | OverloadUnionRecursive< 15 | TPartialOverload & TOverload, 16 | TPartialOverload & ((...args: TArgs) => TReturn) & OverloadProps 17 | > 18 | | ((...args: TArgs) => TReturn) 19 | : never; 20 | 21 | type OverloadUnion any> = Exclude< 22 | OverloadUnionRecursive< 23 | // The "() => never" signature must be hoisted to the "front" of the 24 | // intersection, for two reasons: a) because recursion stops when it is 25 | // encountered, and b) it seems to prevent the collapse of subsequent 26 | // "compatible" signatures (eg. "() => void" into "(a?: 1) => void"), 27 | // which gives a direct conversion to a union. 28 | (() => never) & TOverload 29 | >, 30 | TOverload extends () => never ? never : () => never 31 | >; 32 | 33 | // Inferring a union of parameter tuples or return types is now possible. 34 | export type OverloadParameters any> = Parameters>; 35 | export type OverloadReturnType any> = ReturnType>; 36 | -------------------------------------------------------------------------------- /src/types.d.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | BaseGenerator, 3 | ComposeOptions as EnvironmentComposeOptions, 4 | GeneratorFeatures as FeaturesApi, 5 | GeneratorOptions as OptionsApi, 6 | ProgressOptions, 7 | } from '@yeoman/types'; 8 | import type { PipelineOptions } from 'mem-fs'; 9 | import type { MemFsEditorFile } from 'mem-fs-editor'; 10 | import type { JsonValue } from 'type-fest'; 11 | import type Storage from './util/storage.js'; 12 | import type Generator from './index.js'; 13 | 14 | export type StorageValue = JsonValue; 15 | export type GeneratorPipelineOptions = PipelineOptions & ProgressOptions & { pendingFiles?: boolean }; 16 | 17 | /** 18 | * Queue options. 19 | */ 20 | type QueueOptions = { 21 | /** Name of the queue. */ 22 | queueName?: string; 23 | 24 | /** Execute only once by namespace and taskName. */ 25 | once?: boolean; 26 | 27 | /** Run the queue if not running yet. */ 28 | run?: boolean; 29 | 30 | /** Edit a priority */ 31 | edit?: boolean; 32 | 33 | /** Queued manually only */ 34 | skip?: boolean; 35 | 36 | /** Arguments to pass to tasks */ 37 | args?: any[] | ((generator: Generator) => any[]); 38 | }; 39 | 40 | /** 41 | * Task options. 42 | */ 43 | export type TaskOptions = QueueOptions & { 44 | /** Reject callback. */ 45 | reject?: (error: unknown) => void; 46 | 47 | taskPrefix?: string; 48 | 49 | auto?: boolean; 50 | 51 | taskOrigin?: any; 52 | 53 | cancellable?: boolean; 54 | }; 55 | 56 | /** 57 | * Priority object. 58 | */ 59 | export type Priority = QueueOptions & { 60 | /** Name of the priority. */ 61 | priorityName: string; 62 | /** The queue which this priority should be added before. */ 63 | before?: string; 64 | }; 65 | 66 | /** 67 | * Complete Task object. 68 | */ 69 | export type Task = TaskOptions & { 70 | /** Function to be queued. */ 71 | 72 | method: (this: TaskContext, ...args: any[]) => unknown | Promise; 73 | 74 | /** Name of the task. */ 75 | taskName: string; 76 | }; 77 | 78 | export type BaseFeatures = FeaturesApi & { 79 | /** The Generator instance unique identifier. The Environment will ignore duplicated identifiers. */ 80 | uniqueBy?: string; 81 | 82 | /** UniqueBy calculation method */ 83 | unique?: true | 'argument' | 'namespace'; 84 | 85 | /** Only queue methods that matches a priority. */ 86 | tasksMatchingPriority?: boolean; 87 | 88 | /** Tasks methods starts with prefix. Allows api methods (non tasks) without prefix. */ 89 | taskPrefix?: string; 90 | 91 | /** Provides a custom install task. Environment built-in task will not be executed */ 92 | customInstallTask?: boolean | ((...args: any[]) => void | Promise); 93 | 94 | /** Provides a custom commit task. */ 95 | customCommitTask?: boolean | ((...args: any[]) => void | Promise); 96 | 97 | /** Disable args/options parsing. Whenever options/arguments are provided parsed like using commander based parsing. */ 98 | skipParseOptions?: boolean; 99 | 100 | /** Custom priorities for more fine tuned workflows. */ 101 | customPriorities?: Priority[]; 102 | 103 | /** Inherit tasks from parent prototypes, implies tasksMatchingPriority */ 104 | inheritTasks?: boolean; 105 | }; 106 | 107 | export type BaseOptions = OptionsApi & { 108 | destinationRoot?: string; 109 | 110 | skipInstall?: boolean; 111 | 112 | skipCheckEnv?: boolean; 113 | 114 | ignoreVersionCheck?: boolean; 115 | 116 | askAnswered?: boolean; 117 | 118 | localConfigOnly?: boolean; 119 | 120 | skipCache?: boolean; 121 | 122 | skipLocalCache?: boolean; 123 | 124 | description?: string; 125 | 126 | /** @deprecated moved to features */ 127 | skipParseOptions?: boolean; 128 | 129 | /** @deprecated moved to features */ 130 | customPriorities?: Priority[]; 131 | }; 132 | 133 | export type ArgumentSpec = { 134 | name: string; 135 | 136 | /** Description for the argument. */ 137 | description?: string; 138 | 139 | /** A value indicating whether the argument is required. */ 140 | required?: boolean; 141 | 142 | /** A value indicating whether the argument is optional. */ 143 | optional?: boolean; 144 | 145 | /** The type of the argument. */ 146 | type: typeof String | typeof Number | typeof Array | typeof Object; 147 | 148 | /** The default value of the argument. */ 149 | default?: any; 150 | }; 151 | 152 | export type CliOptionSpec = { 153 | name: string; 154 | 155 | /** The type of the option. */ 156 | type: typeof Boolean | typeof String | typeof Number | ((opt: string) => any); 157 | 158 | required?: boolean; 159 | 160 | /** The option name alias (example `-h` and --help`). */ 161 | alias?: string; 162 | 163 | /** The default value. */ 164 | default?: any; 165 | 166 | /** The description for the option. */ 167 | description?: string; 168 | 169 | /** A value indicating whether the option should be hidden from the help output. */ 170 | hide?: boolean; 171 | 172 | /** The storage to persist the option */ 173 | storage?: string | Storage; 174 | }; 175 | 176 | export type ComposeOptions = EnvironmentComposeOptions & { 177 | skipEnvRegister?: boolean; 178 | forceResolve?: boolean; 179 | forwardOptions?: boolean; 180 | }; 181 | -------------------------------------------------------------------------------- /src/util/deprecate.js: -------------------------------------------------------------------------------- 1 | import { template } from 'lodash-es'; 2 | import chalk from 'chalk'; 3 | 4 | const deprecate = (message, fn) => { 5 | return function (...args) { 6 | deprecate.log(message); 7 | return Reflect.apply(fn, this, args); 8 | }; 9 | }; 10 | 11 | deprecate.log = message => { 12 | console.log(chalk.yellow('(!) ') + message); 13 | }; 14 | 15 | deprecate.object = (message, object) => { 16 | const messageTpl = template(message); 17 | const mirror = []; 18 | 19 | for (const name of Object.keys(object)) { 20 | const func = object[name]; 21 | 22 | if (typeof func !== 'function') { 23 | mirror[name] = func; 24 | continue; 25 | } 26 | 27 | mirror[name] = deprecate(messageTpl({ name }), func); 28 | } 29 | 30 | return mirror; 31 | }; 32 | 33 | deprecate.property = (message, object, property) => { 34 | const original = object[property]; 35 | Object.defineProperty(object, property, { 36 | get() { 37 | deprecate.log(message); 38 | return original; 39 | }, 40 | }); 41 | }; 42 | 43 | export default deprecate; 44 | -------------------------------------------------------------------------------- /src/util/prompt-suggestion.ts: -------------------------------------------------------------------------------- 1 | import assert from 'node:assert'; 2 | import type { PromptAnswers, PromptQuestion } from '../questions.js'; 3 | import type Storage from './storage.js'; 4 | 5 | const getChoices = (question: PromptQuestion) => { 6 | if (question.type === 'list') { 7 | return question.choices; 8 | } 9 | 10 | if (question.type === 'checkbox') { 11 | return question.choices; 12 | } 13 | 14 | if (question.type === 'expand') { 15 | return question.choices; 16 | } 17 | 18 | if (question.type === 'rawlist') { 19 | return question.choices; 20 | } 21 | 22 | return; 23 | }; 24 | 25 | /** 26 | * Returns the default value for a checkbox 27 | * 28 | * @param question Inquirer prompt item 29 | * @param defaultValue The stored default value 30 | * @return Default value to set 31 | * @private 32 | */ 33 | const getCheckboxDefault = (question: any, defaultValue: any) => { 34 | // For simplicity we uncheck all boxes and use .default to set the active ones 35 | for (const choice of question.choices) { 36 | if (typeof choice === 'object') { 37 | choice.checked = false; 38 | } 39 | } 40 | 41 | return defaultValue; 42 | }; 43 | 44 | /** 45 | * Returns the default value for a list 46 | * 47 | * @param question Inquirer prompt item 48 | * @param defaultValue The stored default value 49 | * @return Default value to set 50 | * @private 51 | */ 52 | const getListDefault = (question: any, defaultValue: any) => { 53 | const choiceValues = question.choices.map((choice: any) => (typeof choice === 'object' ? choice.value : choice)); 54 | return choiceValues.indexOf(defaultValue); 55 | }; 56 | 57 | /** 58 | * Return true if the answer should be store in 59 | * the global store, otherwise false 60 | * 61 | * @param question Inquirer prompt item 62 | * @param answer The inquirer answer 63 | * @param storeAll Should store default values 64 | * @return Answer to be stored 65 | * @private 66 | */ 67 | const storeListAnswer = (question: any, answer: PromptAnswers, storeAll: boolean) => { 68 | const choiceValues = question.choices.map((choice: any) => { 69 | if (Object.prototype.hasOwnProperty.call(choice, 'value')) { 70 | return choice.value; 71 | } 72 | 73 | return choice; 74 | }); 75 | 76 | const choiceIndex = choiceValues.indexOf(answer); 77 | 78 | // Check if answer is not equal to default value 79 | if (storeAll || question.default !== choiceIndex) { 80 | return true; 81 | } 82 | 83 | return false; 84 | }; 85 | 86 | /** 87 | * Return true if the answer should be store in 88 | * the global store, otherwise false 89 | * 90 | * @param question Inquirer prompt item 91 | * @param answer The inquirer answer 92 | * @param storeAll Should store default values 93 | * @return Answer to be stored 94 | * @private 95 | */ 96 | const storeAnswer = (question: any, answer: PromptAnswers, storeAll: boolean) => { 97 | // Check if answer is not equal to default value or is undefined 98 | if (answer !== undefined && (storeAll || question.default !== answer)) { 99 | return true; 100 | } 101 | 102 | return false; 103 | }; 104 | 105 | /** 106 | * Prefill the defaults with values from the global store 107 | * 108 | * @param store `.yo-rc-global` global config 109 | * @param questions Original prompt questions 110 | * @return Prompt questions array with prefilled values. 111 | */ 112 | export const prefillQuestions = ( 113 | store: Storage, 114 | questions: Array>, 115 | ) => { 116 | assert(store, 'A store parameter is required'); 117 | assert(questions, 'A questions parameter is required'); 118 | 119 | const promptValues = store.get('promptValues') ?? {}; 120 | 121 | questions = [questions].flat(); 122 | 123 | // Write user defaults back to prompt 124 | return questions.map(question => { 125 | if (question.store !== true) { 126 | return question; 127 | } 128 | 129 | const storedValue = promptValues[question.name as string]; 130 | 131 | if (storedValue === undefined || typeof getChoices(question) === 'function') { 132 | // Do not override prompt default when question.choices is a function, 133 | // since can't guarantee that the `storedValue` will even be in the returned choices 134 | return question; 135 | } 136 | 137 | // Override prompt default with the user's default 138 | switch (question.type) { 139 | case 'rawlist': 140 | case 'expand': { 141 | question.default = getListDefault(question, storedValue); 142 | break; 143 | } 144 | 145 | case 'checkbox': { 146 | question.default = getCheckboxDefault(question, storedValue); 147 | break; 148 | } 149 | 150 | default: { 151 | question.default = storedValue; 152 | break; 153 | } 154 | } 155 | 156 | return question; 157 | }); 158 | }; 159 | 160 | /** 161 | * Store the answers in the global store 162 | * 163 | * @param store `.yo-rc-global` global config 164 | * @param questions Original prompt questions 165 | * @param answers The inquirer answers 166 | * @param storeAll Should store default values 167 | */ 168 | export const storeAnswers = (store: Storage, questions: any, answers: PromptAnswers, storeAll: boolean) => { 169 | assert(store, 'A store parameter is required'); 170 | assert(answers, 'A answers parameter is required'); 171 | assert(questions, 'A questions parameter is required'); 172 | assert.ok(typeof answers === 'object', 'answers must be a object'); 173 | 174 | storeAll = storeAll || false; 175 | const promptValues = store.get('promptValues') ?? {}; 176 | 177 | questions = [questions].flat(); 178 | 179 | for (const question of questions) { 180 | if (question.store !== true) { 181 | return; 182 | } 183 | 184 | let saveAnswer; 185 | const key = question.name; 186 | const answer = answers[key]; 187 | 188 | switch (question.type) { 189 | case 'rawlist': 190 | case 'expand': { 191 | saveAnswer = storeListAnswer(question, answer, storeAll); 192 | break; 193 | } 194 | 195 | default: { 196 | saveAnswer = storeAnswer(question, answer, storeAll); 197 | break; 198 | } 199 | } 200 | 201 | if (saveAnswer) { 202 | promptValues[key] = answer; 203 | } 204 | } 205 | 206 | if (Object.keys(promptValues).length > 0) { 207 | store.set('promptValues', promptValues); 208 | } 209 | }; 210 | -------------------------------------------------------------------------------- /src/util/storage.ts: -------------------------------------------------------------------------------- 1 | import assert from 'node:assert'; 2 | import { cloneDeep, get, merge, set, defaults as setDefaults } from 'lodash-es'; 3 | import sortKeys from 'sort-keys'; 4 | import type { Get } from 'type-fest'; 5 | import type { MemFsEditor } from 'mem-fs-editor'; 6 | import type { StorageValue } from '../types.js'; 7 | 8 | /** 9 | * Proxy handler for Storage 10 | */ 11 | const proxyHandler: ProxyHandler = { 12 | get(storage: Storage, property: string, _receiver: any): StorageValue { 13 | return storage.get(property); 14 | }, 15 | set(storage: Storage, property: string, value: any, _receiver: any): boolean { 16 | if (typeof property === 'string') { 17 | storage.set(property, value); 18 | return true; 19 | } 20 | 21 | return false; 22 | }, 23 | ownKeys(storage: Storage) { 24 | return Reflect.ownKeys(storage._store); 25 | }, 26 | has(storage: Storage, prop: string) { 27 | return storage.get(prop) !== undefined; 28 | }, 29 | getOwnPropertyDescriptor(storage: Storage, key: string): PropertyDescriptor { 30 | return { 31 | get: () => this.get!(storage, key, null), 32 | enumerable: true, 33 | configurable: true, 34 | }; 35 | }, 36 | }; 37 | 38 | export type StorageOptions = { 39 | name?: string; 40 | /** 41 | * Set true to treat name as a lodash path. 42 | */ 43 | lodashPath?: boolean; 44 | /** 45 | * Set true to disable json object cache. 46 | */ 47 | disableCache?: boolean; 48 | /** 49 | * Set true to cleanup cache for every fs change. 50 | */ 51 | disableCacheByFile?: boolean; 52 | /** 53 | * Set true to write sorted json. 54 | */ 55 | sorted?: boolean; 56 | }; 57 | 58 | /** 59 | * Storage instances handle a json file where Generator authors can store data. 60 | * 61 | * The `Generator` class instantiate the storage named `config` by default. 62 | * 63 | * @constructor 64 | * @param name The name of the new storage (this is a namespace) 65 | * @param fs A mem-fs editor instance 66 | * @param configPath The filepath used as a storage. 67 | * 68 | * @example 69 | * class extend Generator { 70 | * writing: function() { 71 | * this.config.set('coffeescript', false); 72 | * } 73 | * } 74 | */ 75 | class Storage = Record> { 76 | path: string; 77 | name?: string; 78 | fs: MemFsEditor; 79 | indent: number; 80 | lodashPath: boolean; 81 | disableCache: boolean; 82 | disableCacheByFile: boolean; 83 | sorted: boolean; 84 | existed: boolean; 85 | _cachedStore?: StorageRecord; 86 | 87 | constructor(name: string | undefined, fs: MemFsEditor, configPath: string, options?: StorageOptions); 88 | constructor(fs: MemFsEditor, configPath: string, options?: StorageOptions); 89 | constructor( 90 | name: string | MemFsEditor | undefined, 91 | fs: MemFsEditor | string, 92 | configPath?: string | StorageOptions, 93 | options: StorageOptions = {}, 94 | ) { 95 | let editor: MemFsEditor | undefined; 96 | let actualName: string | undefined; 97 | let actualConfigPath: string | undefined; 98 | let actualOptions: StorageOptions = options; 99 | 100 | if (typeof name === 'string') { 101 | actualName = name; 102 | } else if (typeof name === 'object') { 103 | editor = name; 104 | } 105 | 106 | if (typeof fs === 'string') { 107 | actualConfigPath = fs; 108 | } else { 109 | editor = fs; 110 | } 111 | 112 | if (typeof configPath === 'string') { 113 | actualConfigPath = configPath; 114 | } else if (typeof configPath === 'object') { 115 | actualOptions = configPath; 116 | } 117 | 118 | if (!editor) { 119 | throw new Error(`Check parameters`); 120 | } 121 | 122 | assert(actualConfigPath, 'A config filepath is required to create a storage'); 123 | 124 | this.path = actualConfigPath; 125 | this.name = actualName ?? actualOptions.name; 126 | this.fs = editor; 127 | this.indent = 2; 128 | this.lodashPath = actualOptions.lodashPath ?? false; 129 | this.disableCache = actualOptions.disableCache ?? false; 130 | this.disableCacheByFile = actualOptions.disableCacheByFile ?? false; 131 | this.sorted = actualOptions.sorted ?? false; 132 | 133 | this.existed = Object.keys(this._store).length > 0; 134 | 135 | this.fs.store.on('change', filename => { 136 | // At mem-fs 1.1.3 filename is not passed to the event. 137 | if (this.disableCacheByFile || (filename && filename !== this.path)) { 138 | return; 139 | } 140 | 141 | delete this._cachedStore; 142 | }); 143 | } 144 | 145 | /** 146 | * @protected 147 | * @return the store content 148 | */ 149 | readContent(): StorageRecord { 150 | const content = this.fs.readJSON(this.path, {}); 151 | if (!content || typeof content !== 'object' || Array.isArray(content)) { 152 | throw new Error(`${this.path} is not a valid Storage`); 153 | } 154 | 155 | return content; 156 | } 157 | 158 | /** 159 | * @protected 160 | * @return the store content 161 | */ 162 | writeContent(fullStore: StorageValue): string { 163 | return this.fs.writeJSON(this.path, fullStore, undefined, this.indent); 164 | } 165 | 166 | /** 167 | * Return the current store as JSON object 168 | * @return the store content 169 | * @private 170 | */ 171 | get _store(): StorageRecord { 172 | const store = this._cachedStore ?? this.readContent(); 173 | if (!this.disableCache) { 174 | this._cachedStore = store; 175 | } 176 | 177 | if (!this.name) { 178 | return store; 179 | } 180 | 181 | return ((this.lodashPath ? get(store, this.name) : store[this.name]) ?? {}) as StorageRecord; 182 | } 183 | 184 | /** 185 | * Persist a configuration to disk 186 | * @param val - current configuration values 187 | * @private 188 | */ 189 | _persist(value: StorageRecord) { 190 | if (this.sorted) { 191 | value = sortKeys(value, { deep: true }); 192 | } 193 | 194 | let fullStore: StorageRecord; 195 | if (this.name) { 196 | fullStore = this.readContent(); 197 | if (this.lodashPath) { 198 | set(fullStore, this.name, value); 199 | } else { 200 | (fullStore as any)[this.name] = value; 201 | } 202 | } else { 203 | fullStore = value; 204 | } 205 | 206 | this.writeContent(fullStore); 207 | } 208 | 209 | /** 210 | * Save a new object of values 211 | */ 212 | save(): void { 213 | this._persist(this._store); 214 | } 215 | 216 | /** 217 | * Get a stored value 218 | * @param key The key under which the value is stored. 219 | * @return The stored value. Any JSON valid type could be returned 220 | */ 221 | get(key: Key): StorageRecord[Key] { 222 | return this._store[key]; 223 | } 224 | 225 | /** 226 | * Get a stored value from a lodash path 227 | * @param path The path under which the value is stored. 228 | * @return The stored value. Any JSON valid type could be returned 229 | */ 230 | getPath(path: KeyPath): Get { 231 | return get(this._store, path); 232 | } 233 | 234 | /** 235 | * Get all the stored values 236 | * @return key-value object 237 | */ 238 | getAll(): StorageRecord { 239 | return cloneDeep(this._store); 240 | } 241 | 242 | /** 243 | * Assign a key to a value and schedule a save. 244 | * @param key The key under which the value is stored 245 | * @param val Any valid JSON type value (String, Number, Array, Object). 246 | * @return val Whatever was passed in as val. 247 | */ 248 | set(value: Partial): StorageRecord; 249 | set( 250 | key: Key, 251 | value?: Value, 252 | ): Value | undefined; 253 | set(key: string | number | Partial, value?: StorageValue): StorageRecord | StorageValue | undefined { 254 | const store = this._store; 255 | let ret: StorageValue | StorageValue | undefined; 256 | 257 | if (typeof key === 'object') { 258 | ret = Object.assign(store, key); 259 | } else if (typeof key === 'string' || typeof key === 'number') { 260 | (store as any)[key] = value; 261 | ret = value; 262 | } else { 263 | throw new TypeError(`key not supported ${typeof key}`); 264 | } 265 | 266 | this._persist(store); 267 | return ret; 268 | } 269 | 270 | /** 271 | * Assign a lodash path to a value and schedule a save. 272 | * @param path The key under which the value is stored 273 | * @param val Any valid JSON type value (String, Number, Array, Object). 274 | * @return val Whatever was passed in as val. 275 | */ 276 | setPath(path: KeyPath, value: Get): Get { 277 | assert(typeof value !== 'function', "Storage value can't be a function"); 278 | 279 | const store = this._store; 280 | set(store, path, value); 281 | this._persist(store); 282 | return value; 283 | } 284 | 285 | /** 286 | * Delete a key from the store and schedule a save. 287 | * @param key The key under which the value is stored. 288 | */ 289 | delete(key: keyof StorageRecord): void { 290 | const store = this._store; 291 | 292 | delete store[key]; 293 | this._persist(store); 294 | } 295 | 296 | /** 297 | * Setup the store with defaults value and schedule a save. 298 | * If keys already exist, the initial value is kept. 299 | * @param defaults Key-value object to store. 300 | * @return val Returns the merged options. 301 | */ 302 | defaults(defaults: Partial): StorageRecord { 303 | assert(typeof defaults === 'object', 'Storage `defaults` method only accept objects'); 304 | const store = setDefaults({}, this._store, defaults); 305 | this._persist(store); 306 | return this.getAll(); 307 | } 308 | 309 | /** 310 | * @param defaults Key-value object to store. 311 | * @return val Returns the merged object. 312 | */ 313 | merge(source: Partial): StorageRecord { 314 | assert(typeof source === 'object', 'Storage `merge` method only accept objects'); 315 | const value = merge({}, this._store, source); 316 | this._persist(value); 317 | return this.getAll(); 318 | } 319 | 320 | /** 321 | * Create a child storage. 322 | * @param path - relative path of the key to create a new storage. 323 | * Some paths need to be escaped. Eg: ["dotted.path"] 324 | * @return Returns a new Storage. 325 | */ 326 | createStorage>(path: string): Storage; 327 | createStorage(path: KeyPath): Storage>; 328 | createStorage(path: string): Storage { 329 | const childName = this.name ? `${this.name}.${path}` : path; 330 | return new Storage(childName, this.fs, this.path, { lodashPath: true }); 331 | } 332 | 333 | /** 334 | * Creates a proxy object. 335 | * @return proxy. 336 | */ 337 | createProxy(): StorageRecord { 338 | return new Proxy(this, proxyHandler) as unknown as StorageRecord; 339 | } 340 | } 341 | 342 | export default Storage; 343 | -------------------------------------------------------------------------------- /test/deprecate.test.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-unused-expressions */ 2 | import assert from 'node:assert'; 3 | import { afterEach, beforeEach, describe, it } from 'vitest'; 4 | import chalk from 'chalk'; 5 | import sinon, { type SinonSpy } from 'sinon'; 6 | import deprecate from '../src/util/deprecate.js'; 7 | 8 | type SimpleObject = { 9 | foo: number; 10 | functionInObj: (someValue: number) => number; 11 | }; 12 | 13 | describe('deprecate()', () => { 14 | let fakeConsoleLog: SinonSpy; 15 | beforeEach(() => { 16 | fakeConsoleLog = sinon.fake(); 17 | sinon.replace(console, 'log', fakeConsoleLog); 18 | }); 19 | 20 | afterEach(() => { 21 | sinon.restore(); 22 | }); 23 | 24 | describe('deprecate a function', () => { 25 | let deprecatedLogSpy: SinonSpy; 26 | beforeEach(() => { 27 | deprecatedLogSpy = sinon.fake(); 28 | sinon.replace(deprecate, 'log', deprecatedLogSpy); 29 | }); 30 | 31 | afterEach(() => { 32 | sinon.restore(); 33 | }); 34 | 35 | it('the original function is still called', () => { 36 | const originalFunction = sinon.spy(); 37 | const deprecatedFunction = deprecate('this is function deprecated', originalFunction); 38 | 39 | deprecatedFunction('baz', 3); 40 | assert.ok( 41 | originalFunction.calledWith('baz', 3), 42 | `original function called with (${originalFunction.lastCall.args[0]}, ${originalFunction.lastCall.args[1]})`, 43 | ); 44 | }); 45 | 46 | it('a call to deprecate.log(msg) is added', () => { 47 | const originalFunction = sinon.spy(); 48 | const deprecatedFunction = deprecate('this is function deprecated', originalFunction); 49 | 50 | originalFunction('bar', 2); 51 | assert.ok(originalFunction.calledWith('bar', 2), 'original function not called with ("bar", 2)'); 52 | assert.ok(deprecatedLogSpy.notCalled); 53 | 54 | deprecatedFunction('baz', 3); 55 | assert.ok(deprecatedLogSpy.calledWith('this is function deprecated')); 56 | }); 57 | }); 58 | 59 | describe('.log', () => { 60 | it('logs the message in yellow, starting with "(!) "', () => { 61 | deprecate.log('this is the message'); 62 | assert.ok(fakeConsoleLog.calledWith(`${chalk.yellow('(!) ')}this is the message`)); 63 | }); 64 | }); 65 | 66 | describe('.object()', () => { 67 | let deprecatedLogSpy: SinonSpy; 68 | beforeEach(() => { 69 | deprecatedLogSpy = sinon.fake(); 70 | sinon.replace(deprecate, 'log', deprecatedLogSpy); 71 | }); 72 | 73 | afterEach(() => { 74 | sinon.restore(); 75 | }); 76 | 77 | it('deprecates all functions/methods in the object', () => { 78 | type SomeOtherObject = SimpleObject & { 79 | anotherFunction: (someValue: string) => string; 80 | }; 81 | const originalObject: SomeOtherObject = { 82 | foo: 1, 83 | functionInObj(someValue: number): number { 84 | return someValue - 1; 85 | }, 86 | anotherFunction(someValue: string): string { 87 | return `${someValue} and ${someValue}`; 88 | }, 89 | }; 90 | 91 | // TODO When deprecate.js is changed to .ts, DeprecatedObject will be defined in deprecate.ts as something like type DeprecatedObject = O; 92 | const deprecatedObject = deprecate.object('<%= name %> is deprecated', originalObject); 93 | // @ts-expect-error The method functionInObj() does exist on deprecatedObject. This should be a DeprecatedObject. When deprecate.js is changed to .ts, this can be implemented and no error will occur here. 94 | deprecatedObject.functionInObj(42); 95 | assert.ok( 96 | deprecatedLogSpy.calledWith('functionInObj is deprecated'), 97 | `last call with args: ${deprecatedLogSpy.lastCall.args[0]}`, 98 | ); 99 | 100 | // @ts-expect-error The method anotherFunction() does exist on deprecatedObject. This should be a DeprecatedObject. When deprecate.js is changed to .ts, this can be implemented and no error will occur here. 101 | deprecatedObject.anotherFunction('something'); 102 | assert.ok( 103 | deprecatedLogSpy.calledWith('anotherFunction is deprecated'), 104 | `last call with args: ${deprecatedLogSpy.lastCall.args[0]}`, 105 | ); 106 | }); 107 | 108 | it('properties that are not functions are not changed', () => { 109 | const originalObject: SimpleObject = { 110 | foo: 1, 111 | functionInObj(someValue: number): number { 112 | return someValue - 1; 113 | }, 114 | }; 115 | 116 | const deprecatedObject = deprecate.object('The function "<%= name %>" is deprecated', originalObject); 117 | // @ts-expect-error The property foo does exist on deprecatedObject. This should be a DeprecatedObject. When deprecate.js is changed to .ts, this can be implemented and no error will occur here. 118 | const fooValue = deprecatedObject.foo; 119 | assert.equal(fooValue, 1); 120 | assert.ok(deprecatedLogSpy.notCalled); 121 | }); 122 | 123 | it('property getters and setters are not changed', () => { 124 | type ObjectWithGettersSetters = SimpleObject & { 125 | get bar(): number; 126 | set bar(someValue: number); 127 | }; 128 | 129 | const originalObject: ObjectWithGettersSetters = { 130 | foo: 10, 131 | functionInObj(someValue: number): number { 132 | return someValue - 1; 133 | }, 134 | get bar(): number { 135 | return this.foo * 2; 136 | }, 137 | set bar(someValue: number) { 138 | this.foo = someValue / 2; 139 | }, 140 | }; 141 | 142 | const deprecatedObject = deprecate.object('The function "<%= name %>" is deprecated', originalObject); 143 | // @ts-expect-error The getter bar does exist on the object. This should be a DeprecatedObject. When deprecate.js is changed to .ts, this can be implemented and no error will occur here. 144 | deprecatedObject.bar; 145 | // @ts-expect-error The setter bar does exist on the object. This should be a DeprecatedObject. When deprecate.js is changed to .ts, this can be implemented and no error will occur here. 146 | deprecatedObject.bar = 7; 147 | 148 | assert.ok(deprecatedLogSpy.notCalled); 149 | }); 150 | 151 | it('deprecation message can be a template', () => { 152 | const originalObject: SimpleObject = { 153 | foo: 1, 154 | functionInObj(someValue: number): number { 155 | return someValue - 1; 156 | }, 157 | }; 158 | 159 | const deprecatedObject = deprecate.object('The function "<%= name %>" is deprecated', originalObject); 160 | 161 | // @ts-expect-error The method functionInObj() does exist on deprecatedObject. This should be a DeprecatedObject. When deprecate.js is changed to .ts, this can be implemented and no error will occur here. 162 | deprecatedObject.functionInObj(42); 163 | 164 | assert.ok( 165 | deprecatedLogSpy.calledWith('The function "functionInObj" is deprecated'), 166 | `last call with args: ${deprecatedLogSpy.lastCall.args[0]}`, 167 | ); 168 | }); 169 | }); 170 | 171 | describe('.property()', () => { 172 | let deprecatedLogSpy: SinonSpy; 173 | beforeEach(() => { 174 | deprecatedLogSpy = sinon.fake(); 175 | sinon.replace(deprecate, 'log', deprecatedLogSpy); 176 | }); 177 | 178 | afterEach(() => { 179 | sinon.restore(); 180 | }); 181 | 182 | it('the deprecated message shows when a property is accessed', () => { 183 | const originalObject = { 184 | foo: 1, 185 | }; 186 | deprecate.property('foo property is deprecated', originalObject, 'foo'); 187 | 188 | // Value is not affected; it remains the same 189 | assert.equal(originalObject.foo, 1); 190 | 191 | assert.ok( 192 | deprecatedLogSpy.calledWith('foo property is deprecated'), 193 | `deprecatedLogSpy called with (${deprecatedLogSpy.lastCall.args[0]})`, 194 | ); 195 | }); 196 | 197 | it('property getters and setters are deprecated', () => { 198 | type ObjectWithGettersSetters = SimpleObject & { 199 | get bar(): number; 200 | set bar(someValue: number); 201 | }; 202 | 203 | const originalObject: ObjectWithGettersSetters = { 204 | foo: 10, 205 | functionInObj(someValue: number): number { 206 | return someValue - 1; 207 | }, 208 | get bar(): number { 209 | return this.foo * 2; 210 | }, 211 | set bar(someValue: number) { 212 | this.foo = someValue / 2; 213 | }, 214 | }; 215 | 216 | deprecate.property('bar is deprecated', originalObject, 'bar'); 217 | originalObject.bar; 218 | assert.ok( 219 | deprecatedLogSpy.calledWith('bar is deprecated'), 220 | `deprecatedLogSpy called with (${deprecatedLogSpy.lastCall.args[0]})`, 221 | ); 222 | 223 | originalObject.bar = 7; 224 | assert.ok( 225 | deprecatedLogSpy.calledWith('bar is deprecated'), 226 | `deprecatedLogSpy called with (${deprecatedLogSpy.lastCall.args[0]})`, 227 | ); 228 | }); 229 | }); 230 | }); 231 | -------------------------------------------------------------------------------- /test/environment.test.ts: -------------------------------------------------------------------------------- 1 | import assert from 'node:assert'; 2 | import { TestAdapter } from '@yeoman/adapter/testing'; 3 | import { afterAll, beforeAll, describe, it } from 'vitest'; 4 | import type { SinonStub } from 'sinon'; 5 | import { stub as sinonStub } from 'sinon'; 6 | import Environment from 'yeoman-environment'; 7 | import helpers from 'yeoman-test'; 8 | import Base from './utils.js'; 9 | 10 | describe('Generator with environment version', () => { 11 | let env: Environment; 12 | let Dummy: typeof Base; 13 | let dummy: Base; 14 | let getVersionStub: SinonStub; 15 | 16 | beforeAll(async () => { 17 | await helpers.prepareTemporaryDir().run(); 18 | }); 19 | 20 | describe('mocked 3.0.0', () => { 21 | beforeAll(() => { 22 | env = new Environment({ skipInstall: true, adapter: new TestAdapter() }); 23 | env.getVersion = env.getVersion || (() => {}); 24 | getVersionStub = sinonStub(env, 'getVersion'); 25 | 26 | Dummy = class extends Base {}; 27 | dummy = new Dummy(['bar', 'baz', 'bom'], { 28 | foo: false, 29 | something: 'else', 30 | namespace: 'dummy', 31 | env: env, 32 | 'skip-install': true, 33 | skipCheckEnv: true, 34 | }); 35 | }, 100_000); 36 | 37 | afterAll(() => { 38 | getVersionStub.restore(); 39 | }); 40 | 41 | describe('#checkEnvironmentVersion', () => { 42 | describe('without args', () => { 43 | it('returns true', () => { 44 | getVersionStub.returns('3.0.0'); 45 | assert.equal(dummy.checkEnvironmentVersion(), true); 46 | }); 47 | }); 48 | 49 | describe('with required environment', () => { 50 | beforeAll(() => { 51 | getVersionStub.returns('3.0.1'); 52 | }); 53 | 54 | it('returns true', () => { 55 | assert.equal(dummy.checkEnvironmentVersion('3.0.1'), true); 56 | }); 57 | 58 | describe('with ignoreVersionCheck', () => { 59 | beforeAll(() => { 60 | dummy.options.ignoreVersionCheck = true; 61 | }); 62 | 63 | afterAll(() => { 64 | dummy.options.ignoreVersionCheck = false; 65 | }); 66 | 67 | it('returns true', () => { 68 | getVersionStub.returns('3.0.1'); 69 | assert.equal(dummy.checkEnvironmentVersion('3.0.1'), true); 70 | }); 71 | }); 72 | }); 73 | 74 | describe('with greater than required environment', () => { 75 | it('returns true', () => { 76 | getVersionStub.returns('3.0.2'); 77 | assert.equal(dummy.checkEnvironmentVersion('3.0.1'), true); 78 | }); 79 | }); 80 | 81 | describe('with less than required environment', () => { 82 | beforeAll(() => { 83 | getVersionStub.returns('3.0.0'); 84 | }); 85 | 86 | it('should throw', () => { 87 | assert.throws( 88 | () => dummy.checkEnvironmentVersion('3.0.1'), 89 | /requires yeoman-environment at least 3.0.1, current version is 3.0.0/, 90 | ); 91 | }); 92 | 93 | describe('with warning', () => { 94 | it('should return false', () => { 95 | assert.equal(dummy.checkEnvironmentVersion('3.0.1', true), false); 96 | }); 97 | }); 98 | 99 | describe('with ignoreVersionCheck', () => { 100 | beforeAll(() => { 101 | dummy.options.ignoreVersionCheck = true; 102 | }); 103 | 104 | afterAll(() => { 105 | dummy.options.ignoreVersionCheck = false; 106 | }); 107 | 108 | it('returns false', () => { 109 | assert.equal(dummy.checkEnvironmentVersion('3.0.1'), false); 110 | }); 111 | }); 112 | }); 113 | 114 | describe('with required inquirer', () => { 115 | it('returns true', () => { 116 | getVersionStub.withArgs('inquirer').returns('7.1.0'); 117 | assert.equal(dummy.checkEnvironmentVersion('inquirer', '7.1.0'), true); 118 | }); 119 | }); 120 | 121 | describe('with greater than required inquirer', () => { 122 | it('returns true', () => { 123 | getVersionStub.withArgs('inquirer').returns('7.1.1'); 124 | assert.equal(dummy.checkEnvironmentVersion('inquirer', '7.1.0'), true); 125 | }); 126 | }); 127 | 128 | describe('with less than required inquirer', () => { 129 | beforeAll(() => { 130 | getVersionStub.withArgs('inquirer').returns('7.1.0'); 131 | }); 132 | 133 | it('throws exception', () => { 134 | assert.throws( 135 | () => dummy.checkEnvironmentVersion('inquirer', '7.1.1'), 136 | /requires inquirer at least 7.1.1, current version is 7.1.0/, 137 | ); 138 | }); 139 | 140 | describe('with warning', () => { 141 | it('returns false', () => { 142 | assert.equal(dummy.checkEnvironmentVersion('inquirer', '7.1.1', true), false); 143 | }); 144 | }); 145 | 146 | describe('with ignoreVersionCheck', () => { 147 | beforeAll(() => { 148 | dummy.options.ignoreVersionCheck = true; 149 | }); 150 | 151 | afterAll(() => { 152 | dummy.options.ignoreVersionCheck = false; 153 | }); 154 | 155 | it('returns false', () => { 156 | assert.equal(dummy.checkEnvironmentVersion('inquirer', '7.1.1'), false); 157 | }); 158 | }); 159 | }); 160 | }); 161 | 162 | describe('#prompt with storage', () => { 163 | it('with compatible environment', () => { 164 | getVersionStub.withArgs().returns('3.0.0'); 165 | getVersionStub.withArgs('inquirer').returns('7.1.0'); 166 | return dummy.prompt([], dummy.config); 167 | }); 168 | }); 169 | }); 170 | 171 | describe('mocked 2.8.1', () => { 172 | beforeAll(() => { 173 | env = new Environment({ skipInstall: true, adapter: new TestAdapter() }); 174 | env.getVersion = undefined; 175 | 176 | Dummy = class extends Base {}; 177 | dummy = new Dummy(['bar', 'baz', 'bom'], { 178 | foo: false, 179 | something: 'else', 180 | namespace: 'dummy', 181 | env: env, 182 | skipCheckEnv: true, 183 | 'skip-install': true, 184 | }); 185 | }, 100_000); 186 | 187 | describe('#checkEnvironmentVersion', () => { 188 | describe('without args', () => { 189 | it('throws exception', () => { 190 | assert.throws( 191 | () => dummy.checkEnvironmentVersion(), 192 | /requires yeoman-environment at least 2.9.0, current version is less than 2.9.0/, 193 | ); 194 | }); 195 | }); 196 | 197 | describe('with ignoreVersionCheck', () => { 198 | beforeAll(() => { 199 | dummy.options.ignoreVersionCheck = true; 200 | }); 201 | 202 | afterAll(() => { 203 | dummy.options.ignoreVersionCheck = false; 204 | }); 205 | 206 | describe('without args', () => { 207 | it('returns false', () => { 208 | assert.equal(dummy.checkEnvironmentVersion(), false); 209 | }); 210 | }); 211 | 212 | describe('without less then 3.0.0', () => { 213 | it('returns undefined', () => { 214 | assert.equal(dummy.checkEnvironmentVersion('2.9.0'), false); 215 | }); 216 | }); 217 | }); 218 | }); 219 | }); 220 | }); 221 | -------------------------------------------------------------------------------- /test/fixtures/config.json: -------------------------------------------------------------------------------- 1 | { 2 | "test": { 3 | "name": "test", 4 | "testFramework": "mocha" 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /test/fixtures/conflict.js: -------------------------------------------------------------------------------- 1 | var a = 1; 2 | -------------------------------------------------------------------------------- /test/fixtures/dummy-project/.yo-rc.json: -------------------------------------------------------------------------------- 1 | {} 2 | -------------------------------------------------------------------------------- /test/fixtures/dummy-project/subdir/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yeoman/generator/377782ed82aee599761b0ed670a051e863b90801/test/fixtures/dummy-project/subdir/.gitkeep -------------------------------------------------------------------------------- /test/fixtures/file-conflict.txt: -------------------------------------------------------------------------------- 1 | initial content 2 | -------------------------------------------------------------------------------- /test/fixtures/file-contains-utf8.yml: -------------------------------------------------------------------------------- 1 | city: 深圳 2 | -------------------------------------------------------------------------------- /test/fixtures/foo-copy.js: -------------------------------------------------------------------------------- 1 | var foo = 'foo'; 2 | -------------------------------------------------------------------------------- /test/fixtures/foo-template.js: -------------------------------------------------------------------------------- 1 | var <%= foo %> = '<%= foo %>'; 2 | <%%= extra %> 3 | -------------------------------------------------------------------------------- /test/fixtures/foo.js: -------------------------------------------------------------------------------- 1 | var foo = 'foo'; 2 | -------------------------------------------------------------------------------- /test/fixtures/generator-defaults/app/index.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | // Example of a generator with options. 4 | // 5 | // It takes a list of arguments (usually CLI args) and a Hash of options 6 | // (CLI options), the context of the function is a `new Generator.Base` 7 | // object, which means that you can use the API as if you were extending 8 | // `Base`. 9 | 10 | import Base from '../../../../dist/index.js'; 11 | import options from './options.js'; 12 | import prompts from './prompts.js'; 13 | 14 | export default class App extends Base { 15 | constructor(args, opts) { 16 | super(args, opts); 17 | 18 | this.option(options); 19 | 20 | this.registerConfigPrompts(prompts); 21 | } 22 | } 23 | 24 | App.namespace = 'options:generator'; 25 | -------------------------------------------------------------------------------- /test/fixtures/generator-defaults/app/options.js: -------------------------------------------------------------------------------- 1 | export default [{ name: 'extra', type: String, storage: 'config' }]; 2 | -------------------------------------------------------------------------------- /test/fixtures/generator-defaults/app/prompts.js: -------------------------------------------------------------------------------- 1 | export default [ 2 | { 3 | name: 'foo', 4 | type: 'input', 5 | message: 'Test foo value', 6 | default: 'bar', 7 | exportOption: true, 8 | }, 9 | ]; 10 | -------------------------------------------------------------------------------- /test/fixtures/generator-defaults/app/templates/foo-template.js: -------------------------------------------------------------------------------- 1 | var <%= foo %> = '<%= foo %>'; 2 | <%= extra %> 3 | -------------------------------------------------------------------------------- /test/fixtures/generator-defaults/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "generator-defaults", 3 | "version": "0.0.0", 4 | "author": "", 5 | "type": "module", 6 | "main": "main.js", 7 | "dependencies": {}, 8 | "devDependencies": {}, 9 | "optionalDependencies": {}, 10 | "engines": { 11 | "node": "*" 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /test/fixtures/generator-mocha/main.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | // Example of a simple generator. 4 | // 5 | // A raw function that is executed when this generator is resolved. 6 | // 7 | // It takes a list of arguments (usually CLI args) and a Hash of options 8 | // (CLI options), the context of the function is a `new Generator.Base` 9 | // object, which means that you can use the API as if you were extending 10 | // `Base`. 11 | // 12 | // It works with simple generator, if you need to do a bit more complex 13 | // stuff, extends from Generator.Base and defines your generator steps 14 | // in several methods. 15 | import Base from '../../../dist/index.js'; 16 | 17 | class Generator extends Base { 18 | notEmpty() {} 19 | } 20 | 21 | Generator.description = 22 | 'Ana add a custom description by adding a `description` property to your function.'; 23 | Generator.usage = 'Usage can be used to customize the help output'; 24 | 25 | // Namespace is resolved depending on the location of this generator, 26 | // unless you specifically define it. 27 | Generator.namespace = 'mocha:generator'; 28 | 29 | export default Generator; 30 | -------------------------------------------------------------------------------- /test/fixtures/generator-mocha/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "generator-mocha", 3 | "version": "0.0.0", 4 | "author": "", 5 | "type": "module", 6 | "main": "main.js", 7 | "dependencies": {}, 8 | "devDependencies": {}, 9 | "optionalDependencies": {}, 10 | "engines": { 11 | "node": "*" 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /test/fixtures/options-generator/main.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | // Example of a generator with options. 4 | // 5 | // It takes a list of arguments (usually CLI args) and a Hash of options 6 | // (CLI options), the context of the function is a `new Generator.Base` 7 | // object, which means that you can use the API as if you were extending 8 | // `Base`. 9 | 10 | import Base from '../../../dist/index.js'; 11 | 12 | export default class Generator extends Base { 13 | constructor(args, opts) { 14 | super(args, opts); 15 | 16 | this.option('testOption', { 17 | type: Boolean, 18 | desc: 'Testing falsey values for option', 19 | defaults: true, 20 | }); 21 | } 22 | 23 | testOption() { 24 | return 'foo'; 25 | } 26 | } 27 | 28 | Generator.namespace = 'options:generator'; 29 | -------------------------------------------------------------------------------- /test/fixtures/options-generator/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "options-generator", 3 | "version": "0.0.0", 4 | "author": "", 5 | "type": "module", 6 | "main": "main.js", 7 | "dependencies": {}, 8 | "devDependencies": {}, 9 | "optionalDependencies": {}, 10 | "engines": { 11 | "node": "*" 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /test/fixtures/testFile.tar.gz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yeoman/generator/377782ed82aee599761b0ed670a051e863b90801/test/fixtures/testFile.tar.gz -------------------------------------------------------------------------------- /test/fixtures/yeoman-logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yeoman/generator/377782ed82aee599761b0ed670a051e863b90801/test/fixtures/yeoman-logo.png -------------------------------------------------------------------------------- /test/fs.test.ts: -------------------------------------------------------------------------------- 1 | import assert from 'node:assert'; 2 | import path from 'node:path'; 3 | import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; 4 | import { TestAdapter } from '@yeoman/adapter/testing'; 5 | import { type SinonStub, stub as sinonStub } from 'sinon'; 6 | import type { Data as TemplateData } from 'ejs'; 7 | import Environment from 'yeoman-environment'; 8 | import { BaseGenerator } from '../src/generator.js'; 9 | import Base from './utils.js'; 10 | 11 | const randomString = () => Math.random().toString(36).slice(7); 12 | const createEnv = () => new Environment({ skipInstall: true, adapter: new TestAdapter() }); 13 | 14 | // Make copyTpl() call argument indices more readable 15 | const ARG_FROM = 0; 16 | const ARG_TO = 1; 17 | const ARG_DATA = 2; // A.k.a. context 18 | const ARG_TPLSETTINGS = 3; // Template settings 19 | const ARG_COPYSETTINGS = 4; 20 | 21 | type FSOpResult = { 22 | name: string; 23 | first?: string; 24 | second?: string; 25 | fromBasePath?: string; 26 | dest: string; 27 | returnsUndefined?: boolean; 28 | }; 29 | 30 | let testResults: FSOpResult[] = []; 31 | testResults = [ 32 | ...testResults, 33 | { name: 'readTemplate', first: 'templatePath', dest: 'read' }, 34 | { 35 | name: 'copyTemplate', 36 | second: 'destinationPath', 37 | fromBasePath: 'templatePath', 38 | dest: 'copy', 39 | }, 40 | { 41 | name: 'copyTemplateAsync', 42 | first: 'templatePath', 43 | second: 'destinationPath', 44 | dest: 'copyAsync', 45 | }, 46 | { name: 'readDestination', first: 'destinationPath', dest: 'read' }, 47 | { name: 'writeDestination', first: 'destinationPath', dest: 'write' }, 48 | { 49 | name: 'writeDestinationJSON', 50 | first: 'destinationPath', 51 | dest: 'writeJSON', 52 | }, 53 | { name: 'deleteDestination', first: 'destinationPath', dest: 'delete' }, 54 | { 55 | name: 'copyDestination', 56 | second: 'destinationPath', 57 | fromBasePath: 'destinationPath', 58 | dest: 'copy', 59 | }, 60 | { 61 | name: 'moveDestination', 62 | second: 'destinationPath', 63 | fromBasePath: 'destinationPath', 64 | dest: 'move', 65 | }, 66 | { name: 'existsDestination', first: 'destinationPath', dest: 'exists' }, 67 | { 68 | name: 'renderTemplate', 69 | first: 'templatePath', 70 | second: 'destinationPath', 71 | dest: 'copyTpl', 72 | returnsUndefined: true, 73 | }, 74 | { 75 | name: 'renderTemplateAsync', 76 | first: 'templatePath', 77 | second: 'destinationPath', 78 | dest: 'copyTplAsync', 79 | }, 80 | ]; 81 | 82 | type BaseGenPaths = Record; 83 | 84 | describe('generators.Base (actions/fs)', () => { 85 | const baseReturns: BaseGenPaths = { 86 | templatePath: `templatePath${randomString()}`, 87 | destinationPath: `destinationPath${randomString()}`, 88 | }; 89 | const configGetAll = { foo: 'bar' }; 90 | let returns: Record; 91 | let gen: Base; 92 | let base: BaseGenerator; 93 | 94 | beforeAll(() => { 95 | gen = new Base({ env: createEnv(), resolved: 'unknown', help: true }); 96 | }, 10_000); 97 | 98 | beforeEach(() => { 99 | returns = {}; 100 | base = new BaseGenerator({ namespace: 'foo', help: true, resolved: 'unknown' }); 101 | 102 | // Why not use a sinonStub for base.config as is done in #renderTemplate and #renderTemplateAsync below? 103 | // base get config is not being tested in any way below. 104 | // @ts-expect-error Config is a string (not a symbol) and we know it exists on base https://github.com/DefinitelyTyped/DefinitelyTyped/issues/33173 105 | vi.spyOn(base, 'config', 'get').mockReturnValue({ 106 | getAll() { 107 | return configGetAll; 108 | }, 109 | }); 110 | 111 | Object.assign(base, { 112 | templatePath: sinonStub().returns(baseReturns.templatePath), 113 | destinationPath: sinonStub().returns(baseReturns.destinationPath), 114 | renderTemplate: Base.prototype.renderTemplate, 115 | renderTemplateAsync: Base.prototype.renderTemplateAsync, 116 | renderTemplates: Base.prototype.renderTemplates, 117 | renderTemplatesAsync: Base.prototype.renderTemplatesAsync, 118 | checkEnvironmentVersion() {}, 119 | fs: {}, 120 | }); 121 | for (const op of [ 122 | 'read', 123 | 'copy', 124 | 'copyAsync', 125 | 'write', 126 | 'writeJSON', 127 | 'delete', 128 | 'move', 129 | 'exists', 130 | 'copyTpl', 131 | 'copyTplAsync', 132 | ]) { 133 | const returnValue = randomString(); 134 | base.fs[op] = sinonStub().returns(returnValue); 135 | returns[op] = returnValue; 136 | } 137 | }); 138 | 139 | for (const operation of testResults) { 140 | const passedArg1 = randomString(); 141 | const passedArg2 = randomString(); 142 | const passedArg3: any = {}; 143 | const passedArg4 = { foo: 'bar' }; 144 | 145 | describe(`#${operation.name}`, () => { 146 | let returnValue: any; 147 | let expectedReturn: string | undefined; 148 | let firstArgumentHandler: SinonStub | undefined; 149 | let secondArgumentHandler: SinonStub; 150 | 151 | beforeEach(async () => { 152 | returnValue = await base[operation.name](passedArg1, passedArg2, passedArg3, passedArg4); 153 | 154 | expectedReturn = operation.returnsUndefined ? undefined : returns[operation.dest]; 155 | firstArgumentHandler = operation.first ? base[operation.first] : undefined; 156 | if (operation.second !== undefined && operation.second !== null) { 157 | secondArgumentHandler = base[operation.second]; 158 | } 159 | }); 160 | 161 | it('exists on the generator', () => { 162 | assert(operation.name in Base.prototype); 163 | }); 164 | 165 | it('returns the correct value', () => { 166 | assert.equal(returnValue, expectedReturn); 167 | }); 168 | 169 | it('handles the first parameter', () => { 170 | if (firstArgumentHandler) { 171 | assert.equal(firstArgumentHandler.getCall(0).args[0], passedArg1); 172 | } 173 | }); 174 | 175 | it.skip('handles the second parameter', () => { 176 | if (operation.second && operation.first === operation.second) { 177 | assert(secondArgumentHandler.calledTwice); 178 | expect(secondArgumentHandler.getCall(1).args[0]).toMatch(passedArg2); 179 | } else if (operation.second) { 180 | assert(secondArgumentHandler.calledOnce); 181 | expect(secondArgumentHandler.getCall(0).args[0]).toMatch(passedArg2); 182 | if (firstArgumentHandler) { 183 | assert(firstArgumentHandler.calledOnce); 184 | } 185 | } 186 | }); 187 | 188 | it('calls fs with correct arguments', () => { 189 | const destCall = base.fs[operation.dest]; 190 | assert(destCall.calledOnce); 191 | const call = destCall.getCall(0); 192 | // First argument should be the trated first arguments 193 | expect(call.args[0]).toMatch(operation.first ? baseReturns[operation.first] : passedArg1); 194 | 195 | // Second argument should be the trated first arguments 196 | if (operation.second) { 197 | expect(call.args[1]).toMatch(baseReturns[operation.second]); 198 | } else { 199 | expect(call.args[1]).toMatch(passedArg2); 200 | } 201 | 202 | if (operation.dest === 'copy' || operation.dest === 'move') { 203 | expect(call.args[2]).toMatchObject(passedArg3); 204 | } else { 205 | expect(call.args[2]).toMatchObject(passedArg3); 206 | } 207 | expect(call.args[3].foo).toMatch(passedArg4.foo); 208 | if (operation.fromBasePath) { 209 | expect(call.args[2].fromBasePath).toMatch(baseReturns[operation.fromBasePath]); 210 | } 211 | }); 212 | }); 213 | } 214 | 215 | describe('#renderTemplate', () => { 216 | const getAllReturn = {}; 217 | const getPathReturn = { foo: 'bar' }; 218 | 219 | beforeEach(() => { 220 | sinonStub(gen, 'sourceRoot').returns(''); 221 | sinonStub(gen, 'destinationRoot').returns(''); 222 | sinonStub(gen.config, 'getAll').returns(getAllReturn); 223 | sinonStub(gen.config, 'getPath').returns(getPathReturn); 224 | 225 | for (const op of ['copyTpl']) { 226 | const returnValue = randomString(); 227 | sinonStub(gen.fs, op).returns(returnValue); 228 | returns[op] = returnValue; 229 | } 230 | }); 231 | 232 | afterEach(() => { 233 | gen.sourceRoot.restore(); 234 | gen.destinationRoot.restore(); 235 | gen.config.getAll.restore(); 236 | gen.config.getPath.restore(); 237 | for (const op of ['copyTpl']) gen.fs[op].restore(); 238 | }); 239 | 240 | it('gets default data from config', () => { 241 | gen.renderTemplate('a', 'b'); 242 | const { copyTpl } = gen.fs; 243 | 244 | assert(copyTpl.calledOnce); 245 | const firsCall = copyTpl.getCall(0); 246 | assert.equal(firsCall.args[ARG_DATA], getAllReturn); 247 | }); 248 | 249 | it('gets data with path from config', () => { 250 | gen.renderTemplate('a', 'b', 'test'); 251 | const { copyTpl } = gen.fs; 252 | 253 | assert(copyTpl.calledOnce); 254 | const firsCall = copyTpl.getCall(0); 255 | assert.equal(firsCall.args[ARG_DATA], getPathReturn); 256 | }); 257 | 258 | it('concatenates source and destination', () => { 259 | const source = ['a', 'b']; 260 | const destination = ['b', 'a']; 261 | const data = {}; 262 | 263 | gen.renderTemplate(source, destination, data); 264 | const { copyTpl } = gen.fs; 265 | 266 | assert(copyTpl.calledOnce); 267 | const firsCall = copyTpl.getCall(0); 268 | assert.equal(firsCall.args[ARG_FROM], path.join(...source)); 269 | assert.equal(firsCall.args[ARG_TO], path.join(...destination)); 270 | assert.equal(firsCall.args[ARG_DATA], data); 271 | }); 272 | }); 273 | 274 | describe('#renderTemplateAsync', () => { 275 | const getAllReturn = {}; 276 | const getPathReturn = { foo: 'bar' }; 277 | 278 | beforeEach(() => { 279 | sinonStub(gen, 'sourceRoot').returns(''); 280 | sinonStub(gen, 'destinationRoot').returns(''); 281 | sinonStub(gen.config, 'getAll').returns(getAllReturn); 282 | sinonStub(gen.config, 'getPath').returns(getPathReturn); 283 | 284 | for (const op of ['copyTplAsync']) { 285 | const returnValue = randomString(); 286 | sinonStub(gen.fs, op).returns(returnValue); 287 | returns[op] = returnValue; 288 | } 289 | }); 290 | 291 | afterEach(() => { 292 | gen.sourceRoot.restore(); 293 | gen.destinationRoot.restore(); 294 | gen.config.getAll.restore(); 295 | gen.config.getPath.restore(); 296 | for (const op of ['copyTplAsync']) gen.fs[op].restore(); 297 | }); 298 | 299 | it('gets default data from config', () => { 300 | gen.renderTemplateAsync('a', 'b'); 301 | const { copyTplAsync } = gen.fs; 302 | 303 | assert(copyTplAsync.calledOnce); 304 | const firsCall = copyTplAsync.getCall(0); 305 | assert.equal(firsCall.args[ARG_DATA], getAllReturn); 306 | }); 307 | 308 | it('gets data with path from config', async () => { 309 | await gen.renderTemplateAsync('a', 'b', 'test'); 310 | const { copyTplAsync } = gen.fs; 311 | 312 | assert(copyTplAsync.calledOnce); 313 | const firsCall = copyTplAsync.getCall(0); 314 | assert.equal(firsCall.args[ARG_DATA], getPathReturn); 315 | }); 316 | 317 | it('concatenates source and destination', () => { 318 | const source = ['a', 'b']; 319 | const destination = ['b', 'a']; 320 | const data = {}; 321 | 322 | gen.renderTemplateAsync(source, destination, data); 323 | const { copyTplAsync } = gen.fs; 324 | 325 | assert(copyTplAsync.calledOnce); 326 | const firsCall = copyTplAsync.getCall(0); 327 | assert.equal(firsCall.args[ARG_FROM], path.join(...source)); 328 | assert.equal(firsCall.args[ARG_TO], path.join(...destination)); 329 | assert.equal(firsCall.args[ARG_DATA], data); 330 | }); 331 | }); 332 | 333 | describe('#renderTemplates', () => { 334 | beforeEach(() => { 335 | sinonStub(gen, 'sourceRoot').returns(''); 336 | sinonStub(gen, 'destinationRoot').returns(''); 337 | 338 | for (const op of ['copyTpl']) { 339 | const returnValue = randomString(); 340 | sinonStub(gen.fs, op).returns(returnValue); 341 | returns[op] = returnValue; 342 | } 343 | }); 344 | 345 | afterEach(() => { 346 | gen.sourceRoot.restore(); 347 | gen.destinationRoot.restore(); 348 | for (const op of ['copyTpl']) gen.fs[op].restore(); 349 | }); 350 | 351 | it('handles 1 template', () => { 352 | const passedArg1 = 'foo'; 353 | const data = {}; 354 | gen.renderTemplates([{ source: passedArg1 }], data); 355 | 356 | const { copyTpl } = gen.fs; 357 | assert.equal(copyTpl.callCount, 1); 358 | 359 | const firsCall = copyTpl.getCall(0); 360 | assert.equal(firsCall.args[ARG_FROM], passedArg1); 361 | assert.equal(firsCall.args[ARG_TO], passedArg1); 362 | assert.equal(firsCall.args[ARG_DATA], data); 363 | }); 364 | 365 | it('handles more than 1 template', () => { 366 | const passedArg1 = 'foo'; 367 | const secondCallArg1 = 'bar'; 368 | const secondCallArg2 = 'bar2'; 369 | const data = {}; 370 | const templateOptions = { foo: '123' }; 371 | const copyOptions = {}; 372 | 373 | gen.renderTemplates( 374 | [ 375 | { source: passedArg1 }, 376 | { 377 | source: secondCallArg1, 378 | destination: secondCallArg2, 379 | templateOptions, 380 | copyOptions, 381 | }, 382 | ], 383 | data, 384 | ); 385 | 386 | const { copyTpl } = gen.fs; 387 | assert.equal(copyTpl.callCount, 2); 388 | 389 | const firsCall = copyTpl.getCall(0); 390 | assert.equal(firsCall.args[ARG_FROM], passedArg1); 391 | assert.equal(firsCall.args[ARG_TO], passedArg1); 392 | assert.equal(firsCall.args[ARG_DATA], data); 393 | 394 | const secondCall = copyTpl.getCall(1); 395 | assert.equal(secondCall.args[ARG_FROM], secondCallArg1); 396 | assert.equal(secondCall.args[ARG_TO], secondCallArg2); 397 | assert.equal(secondCall.args[ARG_DATA], data); 398 | assert.equal(secondCall.args[ARG_TPLSETTINGS].foo, templateOptions.foo); 399 | expect(secondCall.args[ARG_COPYSETTINGS]).toMatchObject({ ...copyOptions, fromBasePath: expect.any(String) }); 400 | }); 401 | 402 | it('skips templates based on when callback', () => { 403 | const passedArg1 = 'foo'; 404 | const secondCallArg1 = 'bar'; 405 | const secondCallArg2 = 'bar2'; 406 | const data = {}; 407 | const templateOptions = {}; 408 | const copyOptions = {}; 409 | 410 | gen.renderTemplates( 411 | [ 412 | { source: passedArg1 }, 413 | { 414 | source: secondCallArg1, 415 | when: () => false, 416 | destination: secondCallArg2, 417 | templateOptions, 418 | copyOptions, 419 | }, 420 | ], 421 | data, 422 | ); 423 | 424 | const { copyTpl } = gen.fs; 425 | assert.equal(copyTpl.callCount, 1); 426 | 427 | const firsCall = copyTpl.getCall(0); 428 | assert.equal(firsCall.args[ARG_FROM], passedArg1); 429 | assert.equal(firsCall.args[ARG_TO], passedArg1); 430 | assert.equal(firsCall.args[ARG_DATA], data); 431 | }); 432 | 433 | it('passes the data to when callback', () => { 434 | const passedArg1 = 'foo'; 435 | const templateData: TemplateData = {}; 436 | let receivedData: TemplateData = { name: 'original value' }; // Set this to something so TypeScript doesn't complain that it is used before set 437 | 438 | gen.renderTemplates( 439 | [ 440 | { 441 | source: passedArg1, 442 | when(data: TemplateData) { 443 | receivedData = data; 444 | }, 445 | }, 446 | ], 447 | templateData, 448 | ); 449 | 450 | const { copyTpl } = gen.fs; 451 | assert.equal(copyTpl.callCount, 0); 452 | 453 | assert.equal(receivedData, templateData); 454 | }); 455 | }); 456 | 457 | describe('#renderTemplatesAsync', () => { 458 | beforeEach(() => { 459 | sinonStub(gen, 'sourceRoot').returns(''); 460 | sinonStub(gen, 'destinationRoot').returns(''); 461 | 462 | for (const op of ['copyTplAsync']) { 463 | const returnValue = randomString(); 464 | sinonStub(gen.fs, op).returns(returnValue); 465 | returns[op] = returnValue; 466 | } 467 | }); 468 | 469 | afterEach(() => { 470 | gen.sourceRoot.restore(); 471 | gen.destinationRoot.restore(); 472 | for (const op of ['copyTplAsync']) gen.fs[op].restore(); 473 | }); 474 | 475 | it('handles 1 template', () => { 476 | const passedArg1 = 'foo'; 477 | const data = {}; 478 | gen.renderTemplatesAsync([{ source: passedArg1 }], data); 479 | 480 | const { copyTplAsync } = gen.fs; 481 | assert.equal(copyTplAsync.callCount, 1); 482 | 483 | const firsCall = copyTplAsync.getCall(0); 484 | assert.equal(firsCall.args[ARG_FROM], passedArg1); 485 | assert.equal(firsCall.args[ARG_TO], passedArg1); 486 | assert.equal(firsCall.args[ARG_DATA], data); 487 | }); 488 | 489 | it('handles more than 1 template', () => { 490 | const passedArg1 = 'foo'; 491 | const secondCallArg1 = 'bar'; 492 | const secondCallArg2 = 'bar2'; 493 | const data = {}; 494 | const templateOptions = { foo: '123' }; 495 | const copyOptions = {}; 496 | 497 | gen.renderTemplatesAsync( 498 | [ 499 | { source: passedArg1 }, 500 | { 501 | source: secondCallArg1, 502 | destination: secondCallArg2, 503 | templateOptions, 504 | copyOptions, 505 | }, 506 | ], 507 | data, 508 | ); 509 | 510 | const { copyTplAsync } = gen.fs; 511 | assert.equal(copyTplAsync.callCount, 2); 512 | 513 | const firsCall = copyTplAsync.getCall(0); 514 | assert.equal(firsCall.args[ARG_FROM], passedArg1); 515 | assert.equal(firsCall.args[ARG_TO], passedArg1); 516 | assert.equal(firsCall.args[ARG_DATA], data); 517 | 518 | const secondCall = copyTplAsync.getCall(1); 519 | assert.equal(secondCall.args[ARG_FROM], secondCallArg1); 520 | assert.equal(secondCall.args[ARG_TO], secondCallArg2); 521 | assert.equal(secondCall.args[ARG_DATA], data); 522 | assert.equal(secondCall.args[ARG_TPLSETTINGS].foo, templateOptions.foo); 523 | expect(secondCall.args[ARG_COPYSETTINGS]).toMatchObject({ ...copyOptions, fromBasePath: expect.any(String) }); 524 | }); 525 | 526 | it('skips templates based on when callback', async () => { 527 | const passedArg1 = 'foo'; 528 | const secondCallArg1 = 'bar'; 529 | const secondCallArg2 = 'bar2'; 530 | const data = {}; 531 | const templateOptions = {}; 532 | const copyOptions = {}; 533 | 534 | await gen.renderTemplatesAsync( 535 | [ 536 | { source: passedArg1 }, 537 | { 538 | source: secondCallArg1, 539 | when: () => false, 540 | destination: secondCallArg2, 541 | templateOptions, 542 | copyOptions, 543 | }, 544 | ], 545 | data, 546 | ); 547 | 548 | const { copyTplAsync } = gen.fs; 549 | assert.equal(copyTplAsync.callCount, 1); 550 | 551 | const firsCall = copyTplAsync.getCall(0); 552 | assert.equal(firsCall.args[ARG_FROM], passedArg1); 553 | assert.equal(firsCall.args[ARG_TO], passedArg1); 554 | assert.equal(firsCall.args[ARG_DATA], data); 555 | }); 556 | 557 | it('passes the data to when callback', () => { 558 | const passedArg1 = 'foo'; 559 | const templateData: TemplateData = {}; 560 | let receivedData: TemplateData = { name: 'original value' }; // Set this to something so TypeScript doesn't complain that it is used before set 561 | 562 | gen.renderTemplatesAsync( 563 | [ 564 | { 565 | source: passedArg1, 566 | when(data: TemplateData) { 567 | receivedData = data; 568 | }, 569 | }, 570 | ], 571 | templateData, 572 | ); 573 | 574 | const { copyTplAsync } = gen.fs; 575 | assert.equal(copyTplAsync.callCount, 0); 576 | 577 | assert.equal(receivedData, templateData); 578 | }); 579 | }); 580 | }); 581 | -------------------------------------------------------------------------------- /test/generators-compose-workflow.test.ts: -------------------------------------------------------------------------------- 1 | import { beforeEach, describe, it } from 'vitest'; 2 | import { TestAdapter } from '@yeoman/adapter/testing'; 3 | import type { SinonSpy } from 'sinon'; 4 | import { assert as sinonAssert, spy as sinonSpy } from 'sinon'; 5 | import Environment from 'yeoman-environment'; 6 | import assert from 'yeoman-assert'; 7 | import helpers from 'yeoman-test'; 8 | import Base from './utils.js'; 9 | 10 | const createEnv = () => new Environment({ skipInstall: true, adapter: new TestAdapter() }); 11 | 12 | describe('Multiples generators', () => { 13 | let env: Environment; 14 | 15 | let Dummy: typeof Base; 16 | let dummy: Base; 17 | let dummy2: Base; 18 | 19 | let spyExec: SinonSpy; 20 | let spyExec1: SinonSpy; 21 | let spyInit1: SinonSpy; 22 | let spyWrite1: SinonSpy; 23 | let spyEnd1: SinonSpy; 24 | let spyExec2: SinonSpy; 25 | let spyInit2: SinonSpy; 26 | let spyWrite2: SinonSpy; 27 | let spyEnd2: SinonSpy; 28 | let spyExec3: SinonSpy; 29 | 30 | beforeEach(async () => { 31 | await helpers.prepareTemporaryDir().run(); 32 | 33 | env = createEnv(); 34 | Dummy = class extends Base {}; 35 | spyExec = sinonSpy(); 36 | Dummy.prototype.exec = spyExec; 37 | }); 38 | 39 | describe('#composeWith() with multiples generators', () => { 40 | beforeEach(() => { 41 | dummy = new Dummy([], { 42 | resolved: 'unknown', 43 | namespace: 'dummy', 44 | env: env, 45 | 'skip-install': true, 46 | 'force-install': true, 47 | 'skip-cache': true, 48 | }); 49 | 50 | spyExec1 = sinonSpy(); 51 | spyInit1 = sinonSpy(); 52 | spyWrite1 = sinonSpy(); 53 | spyEnd1 = sinonSpy(); 54 | 55 | const GenCompose1 = class extends Base {}; 56 | GenCompose1.prototype.exec = spyExec1; 57 | GenCompose1.prototype.initializing = spyInit1; 58 | GenCompose1.prototype.writing = spyWrite1; 59 | GenCompose1.prototype.end = spyEnd1; 60 | 61 | spyExec2 = sinonSpy(); 62 | spyInit2 = sinonSpy(); 63 | spyWrite2 = sinonSpy(); 64 | spyEnd2 = sinonSpy(); 65 | 66 | const GenCompose2 = class extends Base {}; 67 | GenCompose2.prototype.exec = spyExec2; 68 | GenCompose2.prototype.initializing = spyInit2; 69 | GenCompose2.prototype.writing = spyWrite2; 70 | GenCompose2.prototype.end = spyEnd2; 71 | 72 | env.registerStub(GenCompose1, 'composed:gen'); 73 | env.registerStub(GenCompose2, 'composed:gen2'); 74 | }); 75 | 76 | it('runs multiple composed generators', async () => { 77 | await dummy.composeWith(['composed:gen', 'composed:gen2']); 78 | 79 | const runSpy = sinonSpy(dummy, 'run'); 80 | 81 | // I use a setTimeout here just to make sure composeWith() doesn't start the 82 | // generator before the base one is ran. 83 | await dummy.run(); 84 | sinonAssert.callOrder( 85 | runSpy, 86 | spyInit1, 87 | spyInit2, 88 | spyExec, 89 | spyExec1, 90 | spyExec2, 91 | spyWrite1, 92 | spyWrite2, 93 | spyEnd1, 94 | spyEnd2, 95 | ); 96 | assert(spyInit1.calledAfter(runSpy)); 97 | assert(spyInit2.calledAfter(spyInit1)); 98 | assert(spyExec1.calledAfter(spyInit2)); 99 | assert(spyExec2.calledAfter(spyExec1)); 100 | }); 101 | 102 | it('runs multiple composed generators (reverse)', async () => { 103 | await dummy.composeWith(['composed:gen2', 'composed:gen']); 104 | 105 | const runSpy = sinonSpy(dummy, 'run'); 106 | await dummy.run(); 107 | 108 | sinonAssert.callOrder( 109 | runSpy, 110 | spyInit2, 111 | spyInit1, 112 | spyExec, 113 | spyExec2, 114 | spyExec1, 115 | spyWrite2, 116 | spyWrite1, 117 | spyEnd2, 118 | spyEnd1, 119 | ); 120 | assert(spyInit2.calledAfter(runSpy)); 121 | assert(spyInit1.calledAfter(spyInit2)); 122 | assert(spyExec2.calledAfter(spyInit1)); 123 | assert(spyExec1.calledAfter(spyExec2)); 124 | }); 125 | 126 | it('runs 3 composed generators', async () => { 127 | spyExec3 = sinonSpy(); 128 | const spyInit3 = sinonSpy(); 129 | const GenCompose3 = class extends Base {}; 130 | GenCompose3.prototype.exec = spyExec3; 131 | GenCompose3.prototype.initializing = spyInit3; 132 | 133 | env.registerStub(GenCompose3, 'composed:gen3'); 134 | 135 | await dummy.composeWith(['composed:gen', 'composed:gen2', 'composed:gen3']); 136 | 137 | const runSpy = sinonSpy(dummy, 'run'); 138 | await dummy.run(); 139 | 140 | sinonAssert.callOrder( 141 | runSpy, 142 | spyInit1, 143 | spyInit2, 144 | spyInit3, 145 | spyExec, 146 | spyExec1, 147 | spyExec2, 148 | spyExec3, 149 | spyWrite1, 150 | spyWrite2, 151 | spyEnd1, 152 | spyEnd2, 153 | ); 154 | assert(spyInit1.calledAfter(runSpy)); 155 | assert(spyInit2.calledAfter(spyInit1)); 156 | assert(spyInit3.calledAfter(spyInit2)); 157 | assert(spyExec1.calledAfter(spyInit3)); 158 | assert(spyExec2.calledAfter(spyExec1)); 159 | assert(spyExec3.calledAfter(spyExec2)); 160 | }); 161 | 162 | it('runs multiple composed generators inside a running generator', () => 163 | new Promise(done => { 164 | const Dummy2 = class extends Dummy {}; 165 | 166 | const writingSpy1 = sinonSpy(); 167 | const writingSpy2 = sinonSpy(); 168 | const endSpy = sinonSpy(); 169 | Dummy2.prototype.end = endSpy; 170 | 171 | Dummy2.prototype.writing = { 172 | async compose() { 173 | // Initializing and default is queue and called next (before writingSpy2) 174 | // Writing is queue after already queued functions (after writingSpy2) 175 | await this.composeWith(['composed:gen', 'composed:gen2']); 176 | writingSpy1(); 177 | }, 178 | writingSpy2() { 179 | writingSpy2(); 180 | }, 181 | }; 182 | 183 | dummy2 = new Dummy2([], { 184 | resolved: 'unknown', 185 | namespace: 'dummy', 186 | env: env, 187 | 'skip-install': true, 188 | 'force-install': true, 189 | 'skip-cache': true, 190 | }); 191 | 192 | const runSpy = sinonSpy(dummy2, 'run'); 193 | 194 | // I use a setTimeout here just to make sure composeWith() doesn't start the 195 | // generator before the base one is ran. 196 | setTimeout(() => { 197 | dummy2.run().then(() => { 198 | sinonAssert.callOrder( 199 | runSpy, 200 | writingSpy1, 201 | spyInit1, 202 | spyInit2, 203 | spyExec1, 204 | spyExec2, 205 | writingSpy2, 206 | spyWrite1, 207 | spyWrite2, 208 | endSpy, 209 | spyEnd1, 210 | spyEnd2, 211 | ); 212 | assert(writingSpy1.calledAfter(runSpy)); 213 | assert(spyInit1.calledAfter(writingSpy1)); 214 | assert(spyInit2.calledAfter(spyInit1)); 215 | assert(spyExec1.calledAfter(spyInit2)); 216 | assert(spyExec2.calledAfter(spyExec1)); 217 | assert(writingSpy2.calledAfter(spyExec2)); 218 | assert(spyWrite1.calledAfter(writingSpy2)); 219 | assert(spyWrite2.calledAfter(spyWrite1)); 220 | assert(endSpy.calledAfter(spyWrite2)); 221 | assert(spyEnd1.calledAfter(endSpy)); 222 | assert(spyEnd2.calledAfter(spyEnd1)); 223 | done(); 224 | }); 225 | }, 100); 226 | })); 227 | 228 | it('runs multiple composed generators inside a running generator', () => 229 | new Promise(done => { 230 | const Dummy2 = class extends Dummy {}; 231 | 232 | const writingSpy1 = sinonSpy(); 233 | const writingSpy2 = sinonSpy(); 234 | const writingSpy3 = sinonSpy(); 235 | const endSpy = sinonSpy(); 236 | 237 | Dummy2.prototype.end = endSpy; 238 | Dummy2.prototype.writing = { 239 | async compose1() { 240 | // Initializing and default is queue and called next (before writingSpy2) 241 | // Writing is queue after already queued functions (after writingSpy2, compose2, writingSpy3) 242 | await this.composeWith('composed:gen'); 243 | writingSpy1(); 244 | }, 245 | writingSpy2() { 246 | writingSpy2(); 247 | }, 248 | async compose2() { 249 | await this.composeWith('composed:gen2'); 250 | }, 251 | writingSpy3() { 252 | writingSpy3(); 253 | }, 254 | }; 255 | 256 | dummy2 = new Dummy2([], { 257 | resolved: 'unknown', 258 | namespace: 'dummy', 259 | env: env, 260 | 'skip-install': true, 261 | 'force-install': true, 262 | 'skip-cache': true, 263 | }); 264 | 265 | const runSpy = sinonSpy(dummy2, 'run'); 266 | 267 | // I use a setTimeout here just to make sure composeWith() doesn't start the 268 | // generator before the base one is ran. 269 | setTimeout(() => { 270 | dummy2.run().then(() => { 271 | sinonAssert.callOrder( 272 | runSpy, 273 | writingSpy1, 274 | spyInit1, 275 | spyExec1, 276 | writingSpy2, 277 | spyInit2, 278 | spyExec2, 279 | writingSpy3, 280 | spyWrite1, 281 | spyWrite2, 282 | endSpy, 283 | spyEnd1, 284 | spyEnd2, 285 | ); 286 | assert(writingSpy1.calledAfter(runSpy)); 287 | assert(spyInit1.calledAfter(writingSpy1)); 288 | assert(spyExec1.calledAfter(spyInit1)); 289 | assert(writingSpy2.calledAfter(spyExec1)); 290 | assert(spyInit2.calledAfter(writingSpy2)); 291 | assert(spyExec2.calledAfter(spyExec1)); 292 | assert(writingSpy3.calledAfter(spyExec2)); 293 | assert(spyWrite1.calledAfter(writingSpy3)); 294 | assert(spyWrite2.calledAfter(spyWrite1)); 295 | assert(endSpy.calledAfter(spyWrite2)); 296 | assert(spyEnd1.calledAfter(endSpy)); 297 | assert(spyEnd2.calledAfter(spyEnd1)); 298 | done(); 299 | }); 300 | }, 100); 301 | })); 302 | }); 303 | 304 | it('#composeWith() inside _beforeQueue', async () => { 305 | const Generator = class extends Base { 306 | async _beforeQueue() { 307 | await this.composeWith('composed:gen2'); 308 | } 309 | }; 310 | const writingSpy1 = sinonSpy(); 311 | Generator.prototype.writing = { 312 | compose1() { 313 | writingSpy1(); 314 | }, 315 | }; 316 | 317 | const Generator2 = class extends Base { 318 | async _beforeQueue() { 319 | await this.composeWith('composed:gen3'); 320 | } 321 | }; 322 | const writingSpy2 = sinonSpy(); 323 | Generator2.prototype.writing = { 324 | compose2() { 325 | writingSpy2(); 326 | }, 327 | }; 328 | 329 | const Generator3 = class extends Base {}; 330 | const writingSpy3 = sinonSpy(); 331 | Generator3.prototype.writing = { 332 | compose3() { 333 | writingSpy3(); 334 | }, 335 | }; 336 | 337 | env.registerStub(Generator, 'composed:gen'); 338 | env.registerStub(Generator2, 'composed:gen2'); 339 | env.registerStub(Generator3, 'composed:gen3'); 340 | 341 | const dummy = new Generator([], { 342 | resolved: 'unknown', 343 | namespace: 'dummy', 344 | env: env, 345 | 'skip-install': true, 346 | 'force-install': true, 347 | 'skip-cache': true, 348 | }); 349 | 350 | await dummy.run(); 351 | assert(writingSpy2.calledAfter(writingSpy1)); 352 | assert(writingSpy3.calledAfter(writingSpy2)); 353 | }); 354 | }); 355 | -------------------------------------------------------------------------------- /test/generators.test.ts: -------------------------------------------------------------------------------- 1 | import EventEmitter from 'node:events'; 2 | import path from 'node:path'; 3 | import os from 'node:os'; 4 | import { beforeEach, describe, expect, it, vitest } from 'vitest'; 5 | import { TestAdapter } from '@yeoman/adapter/testing'; 6 | import Environment from 'yeoman-environment'; 7 | import assert from 'yeoman-assert'; 8 | import { valid as semverValid } from 'semver'; 9 | import Base from './utils.js'; 10 | 11 | const NAMESPACE = 'somenamespace'; 12 | const createEnv = () => new Environment({ skipInstall: true, adapter: new TestAdapter() }); 13 | 14 | describe('Generators module', () => { 15 | let generator: Generator; 16 | let env: Environment; 17 | 18 | beforeEach(() => { 19 | env = createEnv(); 20 | }); 21 | 22 | describe('Base', () => { 23 | beforeEach(() => { 24 | const Generator = class extends Base {}; 25 | Generator.prototype.exec = function () {}; 26 | generator = new Generator({ 27 | env: env, 28 | namespace: NAMESPACE, 29 | resolved: 'test', 30 | }); 31 | }); 32 | 33 | it('should expose yoGeneratorVersion', () => { 34 | assert(semverValid(generator.yoGeneratorVersion), `Not valid version ${generator.yoGeneratorVersion as string}`); 35 | }); 36 | 37 | it('is an EventEmitter', () => 38 | new Promise(done => { 39 | assert.ok(generator instanceof EventEmitter); 40 | assert.strictEqual(typeof generator.on, 'function'); 41 | assert.strictEqual(typeof generator.emit, 'function'); 42 | generator.on('yay-o-man', done); 43 | generator.emit('yay-o-man'); 44 | })); 45 | 46 | it('emits done event', () => 47 | new Promise(done => { 48 | env.on(`done$${NAMESPACE}#exec`, data => { 49 | assert(data.generator === generator); 50 | assert(`done$${NAMESPACE}#exec`.includes(data.namespace)); 51 | assert(data.namespace === NAMESPACE); 52 | assert(data.priorityName === 'default'); 53 | assert(data.queueName === 'default'); 54 | done(); 55 | }); 56 | generator.run().catch(() => {}); 57 | })); 58 | }); 59 | 60 | it('without localConfigOnly option', () => { 61 | generator = new Base({ 62 | env: env, 63 | resolved: 'test', 64 | }); 65 | assert.equal(path.join(os.homedir(), '.yo-rc-global.json'), generator._globalConfig.path); 66 | }); 67 | 68 | it('with localConfigOnly option', () => { 69 | generator = new Base({ 70 | env: env, 71 | resolved: 'test', 72 | localConfigOnly: true, 73 | }); 74 | assert.equal(path.join(env.cwd, '.yo-rc-global.json'), generator._globalConfig.path); 75 | }); 76 | 77 | describe('#run', () => { 78 | beforeEach(() => { 79 | const Generator = class extends Base {}; 80 | Generator.prototype.throwing = () => { 81 | throw new Error('not thrown'); 82 | }; 83 | 84 | generator = new Generator({ 85 | env: env, 86 | resolved: 'test', 87 | }); 88 | }); 89 | 90 | it('forwards error to environment', () => 91 | new Promise(done => { 92 | env.on('error', () => { 93 | done(); 94 | }); 95 | generator.run().catch(() => {}); 96 | })); 97 | }); 98 | 99 | describe('#createStorage', () => { 100 | beforeEach(() => { 101 | generator = new Base({ 102 | env: env, 103 | resolved: 'test', 104 | localConfigOnly: true, 105 | }); 106 | }); 107 | 108 | it('with path and name', () => { 109 | const global = path.join(env.cwd, '.yo-rc-global.json'); 110 | const customStorage = generator.createStorage(global, '*'); 111 | assert.equal(global, customStorage.path); 112 | assert.equal('*', customStorage.name); 113 | }); 114 | 115 | it('with path', () => { 116 | const global = path.join(env.cwd, '.yo-rc-global.json'); 117 | const customStorage = generator.createStorage(global); 118 | assert.equal(global, customStorage.path); 119 | assert.equal(undefined, customStorage.name); 120 | }); 121 | }); 122 | 123 | describe('#getContextData', () => { 124 | beforeEach(() => { 125 | generator = new Base({ 126 | env: env, 127 | resolved: 'test', 128 | localConfigOnly: true, 129 | }); 130 | }); 131 | 132 | it('non existing key should throw', () => { 133 | expect(() => generator.getContextData('foo')).toThrow('Context data foo not found and no factory provided'); 134 | }); 135 | 136 | it('non existing key should use factory if provided', () => { 137 | const data = 'bar'; 138 | const factory: () => string = vitest.fn().mockReturnValue(data); 139 | expect(generator.getContextData('foo', { factory })).toBe(data); 140 | expect(factory).toHaveBeenCalled(); 141 | }); 142 | 143 | it('retrieves the data', () => { 144 | const data = 'bar'; 145 | generator._contextMap.set('foo', data); 146 | expect(generator.getContextData('foo')).toBe(data); 147 | }); 148 | 149 | it('supports custon context', () => { 150 | const context = 'ctx'; 151 | const key = 'foo'; 152 | const data = 'bar'; 153 | const factory: () => string = vitest.fn().mockReturnValue(data); 154 | expect(generator.getContextData({ context, key }, { factory })).toBe(data); 155 | expect(factory).toHaveBeenCalled(); 156 | expect(env.getContextMap(context).get(key)).toBe(data); 157 | }); 158 | 159 | it('using replacement option sets new data and retrieves old data', () => { 160 | const key = 'foo'; 161 | const data = 'bar'; 162 | expect(generator.getContextData(key, { replacement: data })).toBe(undefined); 163 | expect(generator.getContextData(key, { replacement: 'new' })).toBe(data); 164 | }); 165 | 166 | it('supports replacement option with custon context', () => { 167 | const context = 'ctx'; 168 | const key = 'foo'; 169 | const data = 'bar'; 170 | expect(generator.getContextData({ context, key }, { replacement: data })).toBe(undefined); 171 | expect(generator.getContextData({ context, key }, { replacement: 'new' })).toBe(data); 172 | expect(env.getContextMap(context).get(key)).toBe('new'); 173 | }); 174 | }); 175 | 176 | it('running standalone', () => 177 | new Promise(done => { 178 | const Generator = class extends Base {}; 179 | try { 180 | new Generator(); 181 | } catch (error) { 182 | assert.equal(error.message, 'This generator requires an environment.'); 183 | done(); 184 | } 185 | })); 186 | 187 | it('running with an empty env', () => 188 | new Promise(done => { 189 | const Generator = class extends Base {}; 190 | try { 191 | new Generator({ env: {} }); 192 | } catch (error) { 193 | assert.equal( 194 | error.message, 195 | "Current environment doesn't provides some necessary feature this generator needs.", 196 | ); 197 | done(); 198 | } 199 | })); 200 | }); 201 | -------------------------------------------------------------------------------- /test/integration.test.ts: -------------------------------------------------------------------------------- 1 | import path, { dirname } from 'node:path'; 2 | import { fileURLToPath } from 'node:url'; 3 | import { beforeAll, describe, it } from 'vitest'; 4 | import helpers, { result } from 'yeoman-test'; 5 | 6 | const _filename = fileURLToPath(import.meta.url); 7 | const _dirname = dirname(_filename); 8 | 9 | describe('Integration', () => { 10 | beforeAll(async () => { 11 | await helpers 12 | .create(path.join(_dirname, 'fixtures/generator-defaults/app')) 13 | .withAnswers({ foo: 'fooValue' }) 14 | .withOptions({ extra: 'extraValue' }) 15 | .run(); 16 | }, 5000); 17 | it('writes prompt value to foo-template.js', () => { 18 | result.assertFileContent('foo-template.js', "fooValue = 'fooValue"); 19 | }); 20 | it('writes option value foo-template.js', () => { 21 | result.assertFileContent('foo-template.js', 'extraValue'); 22 | }); 23 | }); 24 | -------------------------------------------------------------------------------- /test/package-json.test.ts: -------------------------------------------------------------------------------- 1 | import assert from 'node:assert'; 2 | import { beforeEach, describe, expect, it, vi } from 'vitest'; 3 | import { lte as semverLte } from 'semver'; 4 | import helpers from 'yeoman-test'; 5 | import type { BaseEnvironment } from '@yeoman/types'; 6 | import Generator from '../src/index.js'; 7 | 8 | describe('Base#package-json', () => { 9 | let generator: Generator; 10 | let env: BaseEnvironment; 11 | 12 | beforeEach(async () => { 13 | const context = helpers.create(Generator); 14 | await context.build(); 15 | // eslint-disable-next-line prefer-destructuring 16 | generator = context.generator; 17 | // eslint-disable-next-line prefer-destructuring 18 | env = context.env; 19 | generator.exec = vi.fn(); 20 | }); 21 | 22 | describe('_resolvePackageJsonDependencies()', () => { 23 | it('should accept empty version and resolve', async ctx => { 24 | if (semverLte(env.getVersion(), '3.1.0')) { 25 | ctx.skip(); 26 | } 27 | const dependencies = await generator._resolvePackageJsonDependencies('yeoman-generator'); 28 | expect(dependencies['yeoman-generator']).toBeDefined(); 29 | }); 30 | 31 | it('should accept semver version', async () => { 32 | assert.deepStrictEqual(await generator._resolvePackageJsonDependencies('yeoman-generator@^2'), { 33 | 'yeoman-generator': '^2', 34 | }); 35 | }); 36 | 37 | it('should accept object and return it', async () => { 38 | const a = { 'yeoman-generator': '^4' }; 39 | assert.deepStrictEqual(await generator._resolvePackageJsonDependencies(a), a); 40 | }); 41 | 42 | it('should resolve object with empty version and resolve', async ctx => { 43 | if (semverLte(env.getVersion(), '3.1.0')) { 44 | ctx.skip(); 45 | } 46 | 47 | const a = { 'yeoman-generator': '' }; 48 | const dependencies = await generator._resolvePackageJsonDependencies(a); 49 | expect(dependencies['yeoman-generator']); 50 | }); 51 | 52 | it('should accept arrays', async () => { 53 | assert.deepStrictEqual( 54 | await generator._resolvePackageJsonDependencies(['yeoman-generator@^2', 'yeoman-environment@^2']), 55 | { 56 | 'yeoman-generator': '^2', 57 | 'yeoman-environment': '^2', 58 | }, 59 | ); 60 | }); 61 | }); 62 | 63 | describe('addDependencies()', () => { 64 | it('should generate dependencies inside package.json', async () => { 65 | await generator.addDependencies('yeoman-generator@^2'); 66 | assert.deepStrictEqual(generator.packageJson.getAll(), { 67 | dependencies: { 'yeoman-generator': '^2' }, 68 | }); 69 | }); 70 | 71 | it('should accept object and merge inside package.json', async () => { 72 | await generator.addDependencies({ 'yeoman-generator': '^2' }); 73 | assert.deepStrictEqual(generator.packageJson.getAll(), { 74 | dependencies: { 'yeoman-generator': '^2' }, 75 | }); 76 | }); 77 | }); 78 | 79 | describe('addDependencies()', () => { 80 | it('should generate dependencies inside package.json', async () => { 81 | await generator.addDevDependencies('yeoman-generator@^2'); 82 | assert.deepStrictEqual(generator.packageJson.getAll(), { 83 | devDependencies: { 'yeoman-generator': '^2' }, 84 | }); 85 | }); 86 | 87 | it('should accept object and merge devDependencies inside package.json', async () => { 88 | await generator.addDevDependencies({ 'yeoman-generator': '^2' }); 89 | assert.deepStrictEqual(generator.packageJson.getAll(), { 90 | devDependencies: { 'yeoman-generator': '^2' }, 91 | }); 92 | }); 93 | }); 94 | }, 10_000); 95 | -------------------------------------------------------------------------------- /test/prompt-suggestion.test.ts: -------------------------------------------------------------------------------- 1 | import path from 'node:path'; 2 | import assert from 'node:assert'; 3 | import os from 'node:os'; 4 | import { rmSync } from 'node:fs'; 5 | import { afterEach, beforeEach, describe, it } from 'vitest'; 6 | import { TestAdapter } from '@yeoman/adapter/testing'; 7 | import inquirer from 'inquirer'; 8 | import Environment from 'yeoman-environment'; 9 | import { create as createMemFsEditor } from 'mem-fs-editor'; 10 | import Storage from '../src/util/storage.js'; 11 | import { prefillQuestions, storeAnswers } from '../src/util/prompt-suggestion.js'; 12 | 13 | const createEnv = () => new Environment({ skipInstall: true, adapter: new TestAdapter() }); 14 | /* eslint max-nested-callbacks: ["warn", 6] */ 15 | 16 | describe('PromptSuggestion', () => { 17 | let storePath: string; 18 | let store: Storage; 19 | 20 | beforeEach(() => { 21 | const memFs = createEnv().sharedFs; 22 | const fs = createMemFsEditor(memFs); 23 | storePath = path.join(os.tmpdir(), 'suggestion-config.json'); 24 | store = new Storage('suggestion', fs, storePath); 25 | store.set('promptValues', { respuesta: 'foo' }); 26 | }); 27 | 28 | afterEach(() => { 29 | rmSync(storePath, { force: true }); 30 | }); 31 | 32 | describe('.prefillQuestions()', () => { 33 | it('require a store parameter', () => { 34 | assert.throws(prefillQuestions.bind(null)); 35 | }); 36 | 37 | it('require a questions parameter', () => { 38 | assert.throws(prefillQuestions.bind(store)); 39 | }); 40 | 41 | it('take a questions parameter', () => { 42 | prefillQuestions(store, []); 43 | }); 44 | 45 | it('take a question object', () => { 46 | const question = { 47 | name: 'respuesta', 48 | default: 'bar', 49 | store: true, 50 | }; 51 | const [result] = prefillQuestions(store, question); 52 | assert.equal(result.default, 'foo'); 53 | }); 54 | 55 | it('take a question array', () => { 56 | const question = [ 57 | { 58 | name: 'respuesta', 59 | default: 'bar', 60 | store: true, 61 | }, 62 | ]; 63 | const [result] = prefillQuestions(store, question); 64 | assert.equal(result.default, 'foo'); 65 | }); 66 | 67 | it("don't override default when store is set to false", () => { 68 | const question = { 69 | name: 'respuesta', 70 | default: 'bar', 71 | store: false, 72 | }; 73 | const [result] = prefillQuestions(store, question); 74 | assert.equal(result.default, 'bar'); 75 | }); 76 | 77 | it('override default when store is set to true', () => { 78 | const question = { 79 | name: 'respuesta', 80 | default: 'bar', 81 | store: true, 82 | }; 83 | const [result] = prefillQuestions(store, question); 84 | assert.equal(result.default, 'foo'); 85 | }); 86 | 87 | it('keep inquirer objects', () => { 88 | const question = { 89 | type: 'checkbox', 90 | name: 'respuesta', 91 | default: ['bar'], 92 | store: true, 93 | choices: [new inquirer.Separator('spacer')], 94 | }; 95 | const [result] = prefillQuestions(store, question); 96 | assert.ok(result.choices[0] instanceof inquirer.Separator); 97 | }); 98 | 99 | describe('take a checkbox', () => { 100 | beforeEach(() => { 101 | store.set('promptValues', { 102 | respuesta: ['foo'], 103 | }); 104 | }); 105 | 106 | it('override default from an array with objects', () => { 107 | const question = { 108 | type: 'checkbox', 109 | name: 'respuesta', 110 | default: ['bar'], 111 | store: true, 112 | choices: [ 113 | { 114 | value: 'foo', 115 | name: 'foo', 116 | }, 117 | new inquirer.Separator('spacer'), 118 | { 119 | value: 'bar', 120 | name: 'bar', 121 | }, 122 | { 123 | value: 'baz', 124 | name: 'baz', 125 | }, 126 | ], 127 | }; 128 | const [result] = prefillQuestions(store, question); 129 | 130 | for (const choice of result.choices) { 131 | assert.equal(choice.checked, false); 132 | } 133 | 134 | assert.deepEqual(result.default, ['foo']); 135 | }); 136 | 137 | it('override default from an array with strings', () => { 138 | const question = { 139 | type: 'checkbox', 140 | name: 'respuesta', 141 | default: ['bar'], 142 | store: true, 143 | choices: ['foo', new inquirer.Separator('spacer'), 'bar', 'baz'], 144 | }; 145 | const [result] = prefillQuestions(store, question); 146 | assert.deepEqual(result.default, ['foo']); 147 | }); 148 | 149 | describe('with multiple defaults', () => { 150 | beforeEach(() => { 151 | store.set('promptValues', { 152 | respuesta: ['foo', 'bar'], 153 | }); 154 | }); 155 | 156 | it('from an array with objects', () => { 157 | const question = { 158 | type: 'checkbox', 159 | name: 'respuesta', 160 | default: ['bar'], 161 | store: true, 162 | choices: [ 163 | { 164 | value: 'foo', 165 | name: 'foo', 166 | }, 167 | new inquirer.Separator('spacer'), 168 | { 169 | value: 'bar', 170 | name: 'bar', 171 | }, 172 | { 173 | value: 'baz', 174 | name: 'baz', 175 | }, 176 | ], 177 | }; 178 | const [result] = prefillQuestions(store, question); 179 | 180 | for (const choice of result.choices) { 181 | assert.equal(choice.checked, false); 182 | } 183 | 184 | assert.deepEqual(result.default, ['foo', 'bar']); 185 | }); 186 | 187 | it('from an array with strings', () => { 188 | const question = { 189 | type: 'checkbox', 190 | name: 'respuesta', 191 | default: ['bar'], 192 | store: true, 193 | choices: ['foo', new inquirer.Separator('spacer'), 'bar', 'baz'], 194 | }; 195 | const [result] = prefillQuestions(store, question); 196 | assert.deepEqual(result.default, ['foo', 'bar']); 197 | }); 198 | }); 199 | }); 200 | 201 | describe('take a checkbox with choices from a function', () => { 202 | beforeEach(() => { 203 | store.set('promptValues', { 204 | respuesta: ['foo'], 205 | }); 206 | }); 207 | 208 | it('does not override default from an array with objects', () => { 209 | const question = { 210 | type: 'checkbox', 211 | name: 'respuesta', 212 | default: ['bar'], 213 | store: true, 214 | choices: () => [ 215 | { 216 | value: 'foo', 217 | name: 'foo', 218 | }, 219 | new inquirer.Separator('spacer'), 220 | { 221 | value: 'bar', 222 | name: 'bar', 223 | }, 224 | { 225 | value: 'baz', 226 | name: 'baz', 227 | }, 228 | ], 229 | }; 230 | const [result] = prefillQuestions(store, question); 231 | 232 | assert.deepEqual(result.default, ['bar']); 233 | }); 234 | 235 | it('does not override default from an array with strings', () => { 236 | const question = { 237 | type: 'checkbox', 238 | name: 'respuesta', 239 | default: ['bar'], 240 | store: true, 241 | choices: () => ['foo', new inquirer.Separator('spacer'), 'bar', 'baz'], 242 | }; 243 | const [result] = prefillQuestions(store, question); 244 | assert.deepEqual(result.default, ['bar']); 245 | }); 246 | 247 | describe('does not override even with multiple defaults', () => { 248 | beforeEach(() => { 249 | store.set('promptValues', { 250 | respuesta: ['foo', 'bar'], 251 | }); 252 | }); 253 | 254 | it('from an array with objects', () => { 255 | const question = { 256 | type: 'checkbox', 257 | name: 'respuesta', 258 | default: ['bar'], 259 | store: true, 260 | choices: () => [ 261 | { 262 | value: 'foo', 263 | name: 'foo', 264 | }, 265 | new inquirer.Separator('spacer'), 266 | { 267 | value: 'bar', 268 | name: 'bar', 269 | }, 270 | { 271 | value: 'baz', 272 | name: 'baz', 273 | }, 274 | ], 275 | }; 276 | const [result] = prefillQuestions(store, question); 277 | 278 | assert.deepEqual(result.default, ['bar']); 279 | }); 280 | 281 | it('from an array with strings', () => { 282 | const question = { 283 | type: 'checkbox', 284 | name: 'respuesta', 285 | default: ['bar'], 286 | store: true, 287 | choices: () => ['foo', new inquirer.Separator('spacer'), 'bar', 'baz'], 288 | }; 289 | const [result] = prefillQuestions(store, question); 290 | assert.deepEqual(result.default, ['bar']); 291 | }); 292 | }); 293 | }); 294 | 295 | describe('take a rawlist / expand', () => { 296 | beforeEach(() => { 297 | store.set('promptValues', { 298 | respuesta: 'bar', 299 | }); 300 | }); 301 | 302 | it('override default arrayWithObjects', () => { 303 | const question = { 304 | type: 'rawlist', 305 | name: 'respuesta', 306 | default: 0, 307 | store: true, 308 | choices: [ 309 | { 310 | value: 'foo', 311 | name: 'foo', 312 | }, 313 | new inquirer.Separator('spacer'), 314 | { 315 | value: 'bar', 316 | name: 'bar', 317 | }, 318 | { 319 | value: 'baz', 320 | name: 'baz', 321 | }, 322 | ], 323 | }; 324 | const [result] = prefillQuestions(store, question); 325 | assert.equal(result.default, 2); 326 | }); 327 | 328 | it('override default arrayWithObjects', () => { 329 | const question = { 330 | type: 'rawlist', 331 | name: 'respuesta', 332 | default: 0, 333 | store: true, 334 | choices: ['foo', new inquirer.Separator('spacer'), 'bar', 'baz'], 335 | }; 336 | const [result] = prefillQuestions(store, question); 337 | assert.equal(result.default, 2); 338 | }); 339 | }); 340 | }); 341 | 342 | describe('.storeAnswers()', () => { 343 | beforeEach(() => { 344 | store.set('promptValues', { respuesta: 'foo' }); 345 | }); 346 | 347 | it('require a store parameter', () => { 348 | assert.throws(storeAnswers.bind(null)); 349 | }); 350 | 351 | it('require a question parameter', () => { 352 | assert.throws(storeAnswers.bind(store)); 353 | }); 354 | 355 | it('require a answer parameter', () => { 356 | assert.throws(storeAnswers.bind(store, [])); 357 | }); 358 | 359 | it('take a answer parameter', () => { 360 | storeAnswers(store, [], {}); 361 | }); 362 | 363 | it('take a storeAll parameter', () => { 364 | storeAnswers(store, [], {}, true); 365 | }); 366 | 367 | it('store answer in global store', () => { 368 | const question = { 369 | name: 'respuesta', 370 | default: 'bar', 371 | store: true, 372 | }; 373 | 374 | const mockAnswers = { 375 | respuesta: 'baz', 376 | }; 377 | 378 | prefillQuestions(store, question); 379 | storeAnswers(store, question, mockAnswers); 380 | assert.equal(store.get('promptValues').respuesta, 'baz'); 381 | }); 382 | 383 | it("don't store default answer in global store", () => { 384 | const question = { 385 | name: 'respuesta', 386 | default: 'bar', 387 | store: true, 388 | }; 389 | 390 | const mockAnswers = { 391 | respuesta: 'bar', 392 | }; 393 | 394 | store.delete('promptValues'); 395 | prefillQuestions(store, question); 396 | storeAnswers(store, question, mockAnswers, false); 397 | assert.equal(store.get('promptValues'), undefined); 398 | }); 399 | 400 | it('force store default answer in global store', () => { 401 | const question = { 402 | name: 'respuesta', 403 | default: 'bar', 404 | store: true, 405 | }; 406 | 407 | const mockAnswers = { 408 | respuesta: 'bar', 409 | }; 410 | 411 | store.delete('promptValues'); 412 | prefillQuestions(store, question); 413 | storeAnswers(store, question, mockAnswers, true); 414 | assert.equal(store.get('promptValues').respuesta, 'bar'); 415 | }); 416 | 417 | it("don't store answer in global store", () => { 418 | const question = { 419 | name: 'respuesta', 420 | default: 'bar', 421 | store: false, 422 | }; 423 | 424 | const mockAnswers = { 425 | respuesta: 'baz', 426 | }; 427 | 428 | prefillQuestions(store, question); 429 | storeAnswers(store, question, mockAnswers); 430 | assert.equal(store.get('promptValues').respuesta, 'foo'); 431 | }); 432 | 433 | it('store answer from rawlist type', () => { 434 | const question = { 435 | type: 'rawlist', 436 | name: 'respuesta', 437 | default: 0, 438 | store: true, 439 | choices: ['foo', new inquirer.Separator('spacer'), 'bar', 'baz'], 440 | }; 441 | 442 | const mockAnswers = { 443 | respuesta: 'baz', 444 | }; 445 | 446 | prefillQuestions(store, question); 447 | storeAnswers(store, question, mockAnswers); 448 | assert.equal(store.get('promptValues').respuesta, 'baz'); 449 | }); 450 | 451 | describe('empty store', () => { 452 | beforeEach(() => { 453 | store.delete('promptValues'); 454 | }); 455 | it("don't store default answer from rawlist type", () => { 456 | const question = { 457 | type: 'rawlist', 458 | name: 'respuesta', 459 | default: 0, 460 | store: true, 461 | choices: ['foo', new inquirer.Separator('spacer'), 'bar', 'baz'], 462 | }; 463 | 464 | const mockAnswers = { 465 | respuesta: 'foo', 466 | }; 467 | 468 | prefillQuestions(store, question); 469 | storeAnswers(store, question, mockAnswers, false); 470 | assert.equal(store.get('promptValues'), undefined); 471 | }); 472 | 473 | it('force store default answer from rawlist type', () => { 474 | const question = { 475 | type: 'rawlist', 476 | name: 'respuesta', 477 | default: 0, 478 | store: true, 479 | choices: ['foo', new inquirer.Separator('spacer'), 'bar', 'baz'], 480 | }; 481 | 482 | const mockAnswers = { 483 | respuesta: 'foo', 484 | }; 485 | 486 | prefillQuestions(store, question); 487 | storeAnswers(store, question, mockAnswers, true); 488 | assert.equal(store.get('promptValues').respuesta, 'foo'); 489 | }); 490 | }); 491 | 492 | it('store falsy answer (but not undefined) in global store', () => { 493 | const question = { 494 | name: 'respuesta', 495 | default: true, 496 | store: true, 497 | }; 498 | 499 | const mockAnswers = { 500 | respuesta: false, 501 | }; 502 | 503 | prefillQuestions(store, question); 504 | storeAnswers(store, question, mockAnswers); 505 | assert.equal(store.get('promptValues').respuesta, false); 506 | }); 507 | }); 508 | }); 509 | -------------------------------------------------------------------------------- /test/spawn-command.test.ts: -------------------------------------------------------------------------------- 1 | import assert from 'node:assert'; 2 | import Generator from '../src/index.js'; 3 | import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; 4 | import { spy } from 'sinon'; 5 | import { execa, execaCommand, execaCommandSync, execaSync } from 'execa'; 6 | 7 | vi.mock('execa', () => ({ 8 | execa: vi.fn(), 9 | execaSync: vi.fn(), 10 | execaCommand: vi.fn(), 11 | execaCommandSync: vi.fn(), 12 | })); 13 | 14 | describe('generators.Base (actions/spawn-command)', () => { 15 | let testGenerator: Generator; 16 | 17 | beforeEach(async () => { 18 | testGenerator = new Generator({ help: true, namespace: 'foo', resolved: 'unknown' }); 19 | testGenerator.destinationRoot = vi.fn().mockReturnValue('some/destination/path'); 20 | }); 21 | 22 | afterEach(() => { 23 | vi.restoreAllMocks(); 24 | }); 25 | 26 | describe('#spawnCommand()', () => { 27 | describe('only the command is required', () => { 28 | describe('no args and no options are given', () => { 29 | it('calls execaCommandSync with the command, {stdio: "inherit", cwd: this.destinationRoot()}', () => { 30 | testGenerator.spawnCommand('foo'); 31 | expect(execaCommand).toHaveBeenCalledWith('foo', { 32 | cwd: testGenerator.destinationRoot(), 33 | }); 34 | }); 35 | }); 36 | }); 37 | 38 | it('opts given are passed to spawnCommand', () => { 39 | const spawnSpy = spy(testGenerator, 'spawnCommand'); 40 | testGenerator.spawnCommand('foo', { verbose: true }); 41 | assert.ok(spawnSpy.calledWith('foo', { verbose: true })); 42 | }); 43 | }); 44 | 45 | describe('#spawn() calls execa()', () => { 46 | describe('only the command is required', () => { 47 | describe('no args and no options are given', () => { 48 | it('calls execaSync with the command, args, {stdio: "inherit", cwd: this.destinationRoot()}', () => { 49 | testGenerator.spawn('foo'); 50 | expect(execa).toHaveBeenCalledWith('foo', undefined, { 51 | cwd: testGenerator.destinationRoot(), 52 | }); 53 | }); 54 | }); 55 | }); 56 | 57 | it('passes any args and opts along to execa()', () => { 58 | // @ts-expect-error We know that spawn exists on the generator. It is added with applyMixins(). 59 | testGenerator.spawn('foo', ['arg1', 2, 'the third arg'], { verbose: true }); 60 | expect(execa).toHaveBeenCalledWith('foo', ['arg1', 2, 'the third arg'], { 61 | cwd: testGenerator.destinationRoot(), 62 | verbose: true, 63 | }); 64 | }); 65 | 66 | it('can override default stdio option', () => { 67 | testGenerator.spawn('foo', undefined, { stdio: 'pipe' }); 68 | expect(execa).toHaveBeenCalledWith('foo', undefined, { 69 | cwd: testGenerator.destinationRoot(), 70 | stdio: 'pipe', 71 | }); 72 | }); 73 | }); 74 | 75 | describe('#spawnCommandSync()', () => { 76 | describe('only the command is required', () => { 77 | describe('no args and no options are given', () => { 78 | it('calls execaCommandSync with the command, {stdio: "inherit", cwd: this.destinationRoot()}', () => { 79 | testGenerator.spawnCommandSync('foo'); 80 | expect(execaCommandSync).toHaveBeenCalledWith('foo', { 81 | cwd: testGenerator.destinationRoot(), 82 | }); 83 | }); 84 | }); 85 | }); 86 | 87 | it('opts given are passed to spawnCommandSync', () => { 88 | const spawnSyncSpy = spy(testGenerator, 'spawnCommandSync'); 89 | testGenerator.spawnCommandSync('foo', { verbose: true }); 90 | assert.ok(spawnSyncSpy.calledWith('foo', { verbose: true })); 91 | }); 92 | }); 93 | 94 | describe('#spawnSync() calls execaSync', () => { 95 | describe('only the command is required', () => { 96 | describe('no args and no options are given', () => { 97 | it('calls execaSync with the command, args, {stdio: "inherit", cwd: this.destinationRoot()}', () => { 98 | testGenerator.spawnSync('foo'); 99 | expect(execaSync).toHaveBeenCalledWith('foo', undefined, { 100 | cwd: testGenerator.destinationRoot(), 101 | }); 102 | }); 103 | }); 104 | }); 105 | 106 | it('passes any args and opts along to execaSync()', () => { 107 | testGenerator.spawnSync('foo', ['arg1', 2, 'the third arg'], { verbose: true }); 108 | expect(execaSync).toHaveBeenCalledWith('foo', ['arg1', 2, 'the third arg'], { 109 | cwd: testGenerator.destinationRoot(), 110 | verbose: true, 111 | }); 112 | }); 113 | 114 | it('can override default stdio option', () => { 115 | testGenerator.spawnSync('foo', undefined, { stdio: 'pipe' }); 116 | expect(execaSync).toHaveBeenCalledWith('foo', undefined, { 117 | cwd: testGenerator.destinationRoot(), 118 | stdio: 'pipe', 119 | }); 120 | }); 121 | }); 122 | }); 123 | -------------------------------------------------------------------------------- /test/storage.test.ts: -------------------------------------------------------------------------------- 1 | import assert from 'node:assert'; 2 | import fs from 'node:fs'; 3 | import os from 'node:os'; 4 | import path, { dirname } from 'node:path'; 5 | import { fileURLToPath } from 'node:url'; 6 | import process from 'node:process'; 7 | import { type MemFsEditor, create as createMemFsEditor } from 'mem-fs-editor'; 8 | import helpers from 'yeoman-test'; 9 | import { type Store, create as createMemFs } from 'mem-fs'; 10 | import Storage from '../src/util/storage.js'; 11 | import { afterEach, beforeEach, describe, it } from 'vitest'; 12 | 13 | const _filename = fileURLToPath(import.meta.url); 14 | const _dirname = dirname(_filename); 15 | 16 | const tmpdir: string = path.join(os.tmpdir(), 'yeoman-storage'); 17 | 18 | function rm(filepath: string) { 19 | if (fs.existsSync(filepath)) { 20 | fs.unlinkSync(filepath); 21 | } 22 | } 23 | 24 | describe('Storage', () => { 25 | let beforeDir: string; 26 | let storePath: string; 27 | let memFsInstance: Store; 28 | let editor: MemFsEditor; 29 | 30 | beforeEach(async () => { 31 | await helpers.prepareTemporaryDir(); 32 | }); 33 | 34 | beforeEach(() => { 35 | beforeDir = process.cwd(); 36 | storePath = path.join(tmpdir, 'new-config.json'); 37 | memFsInstance = createMemFs(); 38 | editor = createMemFsEditor(memFsInstance); 39 | }); 40 | 41 | afterEach(() => { 42 | if (fs.existsSync(storePath)) { 43 | const json = editor.read(storePath); 44 | assert.ok(json.endsWith('\n')); 45 | assert.ok(!json.endsWith('\n\n')); 46 | rm(storePath); 47 | process.chdir(beforeDir); 48 | } 49 | }); 50 | 51 | describe('.constructor()', () => { 52 | it('require a parameter', () => { 53 | assert.throws(() => { 54 | new Storage(); 55 | }); 56 | }); 57 | 58 | it('require at least 2 parameter', () => { 59 | assert.throws(() => { 60 | new Storage({}); 61 | }); 62 | }); 63 | 64 | it('take a path parameter', () => { 65 | const store = new Storage('test', editor, path.join(_dirname, './fixtures/config.json')); 66 | assert.equal(store.get('testFramework'), 'mocha'); 67 | assert.ok(store.existed); 68 | }); 69 | 70 | it('take a fs and path parameter without name', () => { 71 | const store = new Storage(editor, path.join(_dirname, './fixtures/config.json')); 72 | assert.equal(store.get('test')!.testFramework, 'mocha'); 73 | assert.ok(store.existed); 74 | }); 75 | }); 76 | 77 | it('a config path is required', () => { 78 | assert.throws(() => { 79 | new Storage('yo', editor); 80 | }); 81 | }); 82 | 83 | describe('#get()', () => { 84 | let store: Storage; 85 | 86 | beforeEach(() => { 87 | store = new Storage('test', editor, storePath); 88 | store.set('foo', 'bar'); 89 | }); 90 | 91 | it('namespace each store sharing the same store file', () => { 92 | const localStore = new Storage('foobar', editor, storePath); 93 | localStore.set('foo', 'something else'); 94 | assert.equal(store.get('foo'), 'bar'); 95 | }); 96 | 97 | beforeEach(() => { 98 | store.set('testFramework', 'mocha'); 99 | store.set('name', 'test'); 100 | }); 101 | 102 | it('get values', () => { 103 | assert.equal(store.get('testFramework'), 'mocha'); 104 | assert.equal(store.get('name'), 'test'); 105 | }); 106 | }); 107 | 108 | describe('#set()', () => { 109 | let store: Storage; 110 | 111 | beforeEach(() => { 112 | store = new Storage('test', editor, storePath); 113 | store.set('foo', 'bar'); 114 | }); 115 | 116 | it('set values', () => { 117 | store.set('name', 'Yeoman!'); 118 | assert.equal(store.get('name'), 'Yeoman!'); 119 | }); 120 | 121 | it('set multiple values at once', () => { 122 | store.set({ foo: 'bar', john: 'doe' }); 123 | assert.equal(store.get('foo'), 'bar'); 124 | assert.equal(store.get('john'), 'doe'); 125 | }); 126 | 127 | it('throws when invalid JSON values are passed', function () { 128 | assert.throws(store.set.bind(this, 'foo', () => {})); 129 | }); 130 | 131 | it('save on each changes', () => { 132 | store.set('foo', 'bar'); 133 | assert.equal(editor.readJSON(storePath).test.foo, 'bar'); 134 | store.set('foo', 'oo'); 135 | assert.equal(editor.readJSON(storePath).test.foo, 'oo'); 136 | }); 137 | 138 | describe('@return', () => { 139 | beforeEach(() => { 140 | storePath = path.join(tmpdir, 'setreturn.json'); 141 | store = new Storage('test', editor, storePath); 142 | }); 143 | 144 | afterEach(() => { 145 | rm(storePath); 146 | }); 147 | 148 | it('the saved value (with key)', () => { 149 | assert.equal(store.set('name', 'Yeoman!'), 'Yeoman!'); 150 | }); 151 | 152 | it('the saved value (without key)', () => { 153 | assert.deepEqual(store.set({ foo: 'bar', john: 'doe' }), { 154 | foo: 'bar', 155 | john: 'doe', 156 | }); 157 | }); 158 | 159 | it('the saved value (update values)', () => { 160 | store.set({ foo: 'bar', john: 'doe' }); 161 | assert.deepEqual(store.set({ foo: 'moo' }), { 162 | foo: 'moo', 163 | john: 'doe', 164 | }); 165 | }); 166 | }); 167 | 168 | describe('when multiples instances share the same file', () => { 169 | let store: Storage; 170 | let store2: Storage; 171 | 172 | beforeEach(() => { 173 | store = new Storage('test', editor, storePath); 174 | store.set('foo', 'bar'); 175 | store2 = new Storage('test2', editor, storePath); 176 | }); 177 | 178 | it('only update modified namespace', () => { 179 | store2.set('bar', 'foo'); 180 | store.set('foo', 'bar'); 181 | 182 | const json = editor.readJSON(storePath); 183 | assert.equal(json.test.foo, 'bar'); 184 | assert.equal(json.test2.bar, 'foo'); 185 | }); 186 | }); 187 | 188 | describe('when multiples instances share the same namespace', () => { 189 | let store: Storage; 190 | let store2: Storage; 191 | 192 | beforeEach(() => { 193 | store = new Storage('test', editor, storePath); 194 | store.set('foo', 'bar'); 195 | store2 = new Storage('test', editor, storePath); 196 | }); 197 | 198 | it('only update modified namespace', () => { 199 | store2.set('bar', 'foo'); 200 | store.set('foo', 'bar'); 201 | 202 | assert.equal(store2.get('foo'), 'bar'); 203 | assert.equal(store.get('bar'), 'foo'); 204 | 205 | const json = editor.readJSON(storePath); 206 | assert.equal(json.test.foo, 'bar'); 207 | assert.equal(json.test.bar, 'foo'); 208 | }); 209 | }); 210 | }); 211 | 212 | describe('#getAll()', () => { 213 | let store: Storage; 214 | 215 | beforeEach(() => { 216 | store = new Storage('test', editor, storePath); 217 | store.set('foo', 'bar'); 218 | }); 219 | 220 | beforeEach(() => { 221 | store.set({ foo: 'bar', john: 'doe' }); 222 | }); 223 | 224 | it('get all values', () => { 225 | assert.deepEqual(store.getAll().foo, 'bar'); 226 | }); 227 | 228 | it('does not return a reference to the inner store', () => { 229 | store.getAll().foo = 'uhoh'; 230 | assert.equal(store.getAll().foo, 'bar'); 231 | }); 232 | }); 233 | 234 | describe('#delete()', () => { 235 | let store: Storage; 236 | 237 | beforeEach(() => { 238 | store = new Storage('test', editor, storePath); 239 | store.set('foo', 'bar'); 240 | store.set('name', 'test'); 241 | }); 242 | 243 | it('delete value', () => { 244 | store.delete('name'); 245 | assert.equal(store.get('name'), undefined); 246 | }); 247 | }); 248 | 249 | describe('#defaults()', () => { 250 | let store: Storage; 251 | 252 | beforeEach(() => { 253 | store = new Storage('test', editor, storePath); 254 | store.set('foo', 'bar'); 255 | store.set('val1', 1); 256 | }); 257 | 258 | it('set defaults values if not predefined', () => { 259 | store.defaults({ val1: 3, val2: 4 }); 260 | 261 | assert.equal(store.get('val1'), 1); 262 | assert.equal(store.get('val2'), 4); 263 | }); 264 | 265 | it('require an Object as argument', () => { 266 | assert.throws(store.defaults.bind(store, 'foo')); 267 | }); 268 | 269 | describe('@return', () => { 270 | beforeEach(() => { 271 | storePath = path.join(tmpdir, 'defaultreturn.json'); 272 | store = new Storage('test', editor, storePath); 273 | store.set('val1', 1); 274 | store.set('foo', 'bar'); 275 | }); 276 | 277 | afterEach(() => { 278 | rm(storePath); 279 | }); 280 | 281 | it('the saved value when passed an empty object', () => { 282 | assert.deepEqual(store.defaults({}), { foo: 'bar', val1: 1 }); 283 | }); 284 | 285 | it('the saved value when passed the same key', () => { 286 | assert.deepEqual(store.defaults({ foo: 'baz' }), { 287 | foo: 'bar', 288 | val1: 1, 289 | }); 290 | }); 291 | 292 | it('the saved value when passed new key', () => { 293 | assert.deepEqual(store.defaults({ food: 'pizza' }), { 294 | foo: 'bar', 295 | val1: 1, 296 | food: 'pizza', 297 | }); 298 | }); 299 | }); 300 | }); 301 | 302 | describe('#merge()', () => { 303 | let store: Storage; 304 | 305 | beforeEach(() => { 306 | store = new Storage('test', editor, storePath); 307 | store.set('foo', 'bar'); 308 | store.set('val1', 1); 309 | }); 310 | 311 | it('should merge values if not predefined', () => { 312 | store.merge({ val1: 3, val2: 4 }); 313 | 314 | assert.strictEqual(store.get('val1'), 3); 315 | assert.strictEqual(store.get('val2'), 4); 316 | }); 317 | 318 | it('should require an Object as argument', () => { 319 | assert.throws(store.defaults.bind(store, 'foo')); 320 | }); 321 | 322 | describe('@return', () => { 323 | beforeEach(() => { 324 | storePath = path.join(tmpdir, 'defaultreturn.json'); 325 | store = new Storage('test', editor, storePath); 326 | store.set('val1', 1); 327 | store.set('foo', 'bar'); 328 | }); 329 | 330 | afterEach(() => { 331 | rm(storePath); 332 | }); 333 | 334 | it('should return the original object', () => { 335 | assert.deepStrictEqual(store.merge({}), { foo: 'bar', val1: 1 }); 336 | }); 337 | 338 | it('should return an object with replaced values', () => { 339 | assert.deepStrictEqual(store.merge({ foo: 'baz' }), { 340 | foo: 'baz', 341 | val1: 1, 342 | }); 343 | }); 344 | 345 | it('should return an object with new values', () => { 346 | assert.deepStrictEqual(store.merge({ food: 'pizza' }), { 347 | foo: 'bar', 348 | val1: 1, 349 | food: 'pizza', 350 | }); 351 | }); 352 | }); 353 | }); 354 | 355 | describe('with namespace', () => { 356 | let store: Storage; 357 | 358 | beforeEach(() => { 359 | store = new Storage('test', editor, storePath); 360 | store.set('foo', 'bar'); 361 | }); 362 | 363 | it('stores sharing the same store file with and without namespace', () => { 364 | const localstore = new Storage(editor, storePath); 365 | localstore.set('test', { bar: 'foo' }); 366 | assert.equal(store.get('bar'), 'foo'); 367 | }); 368 | }); 369 | 370 | describe('#getPath() & #setPath()', () => { 371 | let store: Storage; 372 | 373 | beforeEach(() => { 374 | store = new Storage('test', editor, storePath); 375 | store.set('foo', 'bar'); 376 | }); 377 | 378 | it('#getPath() & #setPath()', () => { 379 | store.set('name', { name: 'test' }); 380 | assert.ok(store.getPath('name')); 381 | assert.equal(store.getPath('name.name'), 'test'); 382 | assert.equal(store.setPath('name.name', 'changed'), 'changed'); 383 | assert.equal(store.getPath('name.name'), 'changed'); 384 | assert.equal(store.get('name').name, 'changed'); 385 | }); 386 | }); 387 | 388 | describe('#getStorage()', () => { 389 | let store: Storage; 390 | 391 | beforeEach(() => { 392 | store = new Storage('test', editor, storePath); 393 | store.set('foo', 'bar'); 394 | }); 395 | describe('with a path safe string', () => { 396 | let pathStore: Storage; 397 | beforeEach(() => { 398 | pathStore = store.createStorage('path'); 399 | }); 400 | 401 | it('should get and set value', () => { 402 | assert.equal(pathStore.setPath('name', 'initial'), 'initial'); 403 | assert.equal(store.get('path').name, 'initial'); 404 | assert.equal(store.getPath('path').name, 'initial'); 405 | store.set('path', { name: 'test' }); 406 | assert.equal(pathStore.get('name'), 'test'); 407 | pathStore.set('name', 'changed'); 408 | assert.equal(store.get('path').name, 'changed'); 409 | }); 410 | }); 411 | describe('with a path unsafe string', () => { 412 | const keyName = 'path.key'; 413 | let pathStore: Storage; 414 | 415 | beforeEach(() => { 416 | pathStore = store.createStorage(`["${keyName}"]`); 417 | }); 418 | 419 | it('should get and set value', () => { 420 | assert.equal(pathStore.setPath('name', 'initial'), 'initial'); 421 | assert.equal(store.get(keyName).name, 'initial'); 422 | assert.equal(store.getPath(`["${keyName}"]`).name, 'initial'); 423 | store.set(keyName, { name: 'test' }); 424 | assert.equal(pathStore.get('name'), 'test'); 425 | pathStore.set('name', 'changed'); 426 | assert.equal(store.get(keyName).name, 'changed'); 427 | }); 428 | }); 429 | }); 430 | 431 | describe('.constructor() with lodashPath', () => { 432 | let store: Storage; 433 | let pathStore: Storage; 434 | 435 | beforeEach(() => { 436 | store = new Storage('test', editor, storePath); 437 | store.set('foo', 'bar'); 438 | pathStore = new Storage('test.path', editor, storePath, { lodashPath: true }); 439 | }); 440 | 441 | it('get and set value', () => { 442 | assert.equal(pathStore.setPath('name', 'initial'), 'initial'); 443 | assert.equal(store.get('path').name, 'initial'); 444 | store.set('path', { name: 'test' }); 445 | assert.equal(pathStore.get('name'), 'test'); 446 | pathStore.set('name', 'changed'); 447 | assert.equal(store.get('path').name, 'changed'); 448 | }); 449 | }); 450 | 451 | describe('#createProxy()', () => { 452 | let store: Storage; 453 | let proxy; 454 | beforeEach(() => { 455 | store = new Storage('test', editor, storePath); 456 | store.set('foo', 'bar'); 457 | proxy = store.createProxy(); 458 | }); 459 | 460 | it('sets values', () => { 461 | proxy.name = 'Yeoman!'; 462 | assert.equal(store.get('name'), 'Yeoman!'); 463 | }); 464 | 465 | it('sets multiple values at once', () => { 466 | Object.assign(proxy, { foo: 'bar', john: 'doe' }); 467 | assert.equal(store.get('foo'), 'bar'); 468 | assert.equal(store.get('john'), 'doe'); 469 | }); 470 | 471 | it('gets values', () => { 472 | store.set('name', 'Yeoman!'); 473 | assert.equal(proxy.name, 'Yeoman!'); 474 | }); 475 | 476 | it('works with spread operator', () => { 477 | store.set({ foo: 'bar', john: 'doe' }); 478 | 479 | const spread = { ...proxy }; 480 | assert.equal(spread.foo, 'bar'); 481 | assert.equal(spread.john, 'doe'); 482 | }); 483 | 484 | it('works with in operator', () => { 485 | store.set({ foo: 'bar', john: 'doe' }); 486 | assert('foo' in proxy); 487 | assert(!('foo2' in proxy)); 488 | }); 489 | 490 | it('works with deepEquals', () => { 491 | store.set({ foo: 'bar', john: 'doe' }); 492 | assert.deepStrictEqual({ ...proxy }, { foo: 'bar', john: 'doe' }); 493 | }); 494 | }); 495 | 496 | describe('caching', () => { 497 | let store: Storage; 498 | 499 | beforeEach(() => { 500 | store = new Storage('test', editor, storePath); 501 | store.set('foo', 'bar'); 502 | 503 | // Make sure the cache is loaded. 504 | // on instantiation a change event is emitted when the file loads to mem-fs 505 | store.get('foo'); 506 | }); 507 | 508 | it('should load', () => { 509 | assert(store._cachedStore); 510 | }); 511 | 512 | it('should not load when disabled', () => { 513 | const store = new Storage('test', editor, storePath, { 514 | disableCache: true, 515 | }); 516 | assert(store._cachedStore === undefined); 517 | store.get('foo'); 518 | assert(store._cachedStore === undefined); 519 | }); 520 | 521 | it('cleanups when the file changes', () => { 522 | editor.writeJSON(store.path, {}); 523 | assert(store._cachedStore === undefined); 524 | }); 525 | 526 | it("doesn't cleanup when another file changes", () => { 527 | editor.write('a.txt', 'anything'); 528 | assert(store._cachedStore); 529 | }); 530 | 531 | it('cleanups when per file cache is disabled and another file changes', () => { 532 | editor.writeJSON(store.path, { disableCacheByFile: true }); 533 | editor.write('a.txt', 'anything'); 534 | assert(store._cachedStore === undefined); 535 | }); 536 | 537 | // Compatibility for mem-fs <= 1.1.3 538 | it('cleanups when change event argument is undefined', () => { 539 | memFsInstance.emit('change'); 540 | assert(store._cachedStore === undefined); 541 | }); 542 | }); 543 | 544 | describe('non sorted store', () => { 545 | let store: Storage; 546 | 547 | beforeEach(() => { 548 | store = new Storage('test', editor, storePath); 549 | store.set('foo', 'bar'); 550 | store.set('bar', 'foo'); 551 | store.set('array', [3, 2, 1]); 552 | }); 553 | it('should write non sorted file', () => { 554 | assert.strictEqual( 555 | editor.read(storePath), 556 | `{ 557 | "test": { 558 | "foo": "bar", 559 | "bar": "foo", 560 | "array": [ 561 | 3, 562 | 2, 563 | 1 564 | ] 565 | } 566 | } 567 | `, 568 | ); 569 | }); 570 | }); 571 | 572 | describe('sorted store', () => { 573 | let store: Storage; 574 | 575 | beforeEach(() => { 576 | store = new Storage('test', editor, storePath, { 577 | sorted: true, 578 | }); 579 | store.set('foo', 'bar'); 580 | store.set('bar', 'foo'); 581 | store.set('array', [3, 2, 1]); 582 | store.set('object', { b: 'shouldBeLast', a: 'shouldBeFirst' }); 583 | }); 584 | it('should write sorted file', () => { 585 | assert.strictEqual( 586 | editor.read(storePath), 587 | `{ 588 | "test": { 589 | "array": [ 590 | 3, 591 | 2, 592 | 1 593 | ], 594 | "bar": "foo", 595 | "foo": "bar", 596 | "object": { 597 | "a": "shouldBeFirst", 598 | "b": "shouldBeLast" 599 | } 600 | } 601 | } 602 | `, 603 | ); 604 | }); 605 | }); 606 | }); 607 | -------------------------------------------------------------------------------- /test/user.test.ts: -------------------------------------------------------------------------------- 1 | import assert from 'node:assert'; 2 | import { beforeEach, describe, it } from 'vitest'; 3 | import { simpleGit } from 'simple-git'; 4 | import helpers from 'yeoman-test'; 5 | import Generator from '../src/index.js'; 6 | 7 | /* eslint max-nested-callbacks: ["warn", 5] */ 8 | 9 | describe('Base#user', () => { 10 | let user: Generator; 11 | 12 | beforeEach(async () => { 13 | const context = helpers.create(Generator); 14 | await context.build(); 15 | user = context.generator; 16 | const git = simpleGit(); 17 | await git.init().addConfig('user.name', 'Yeoman').addConfig('user.email', 'yo@yeoman.io'); 18 | }); 19 | 20 | describe('.git', () => { 21 | describe('.name()', () => { 22 | it('is the name used by git', async () => { 23 | assert.equal(await user.git.name(), 'Yeoman'); 24 | }); 25 | }); 26 | 27 | describe('.email()', () => { 28 | it('is the email used by git', async () => { 29 | assert.equal(await user.git.email(), 'yo@yeoman.io'); 30 | }); 31 | }); 32 | }); 33 | 34 | describe.skip('.github', () => { 35 | describe('.username()', () => { 36 | /* 37 | Fetch mocking is not working as expected 38 | beforeEach(() => { 39 | nock('https://api.github.com') 40 | .filteringPath(/q=[^&]*\/g, 'q=XXX') 41 | .get('/search/users?q=XXX') 42 | .times(1) 43 | .reply(200, { 44 | items: [{ login: 'mockname' }], 45 | }); 46 | }); 47 | 48 | afterEach(() => { 49 | nock.restore(); 50 | }); 51 | */ 52 | 53 | it('is the username used by GitHub', async () => { 54 | assert.equal(await user.github.username(), 'mockname'); 55 | }); 56 | }); 57 | }); 58 | }, 10_000); 59 | -------------------------------------------------------------------------------- /test/utils.ts: -------------------------------------------------------------------------------- 1 | import { fileURLToPath } from 'node:url'; 2 | import Base from '../src/index.js'; 3 | 4 | const _filename = fileURLToPath(import.meta.url); 5 | 6 | export default class BaseTest extends Base { 7 | constructor( 8 | options: Omit & Partial>, 9 | features?: Base['features'], 10 | ); 11 | constructor( 12 | args: string[], 13 | options?: Omit & 14 | Partial>, 15 | features?: Base['features'], 16 | ); 17 | constructor(...args: any[]) { 18 | const optIndex = Array.isArray(args[0]) ? 1 : 0; 19 | args[optIndex] = args[optIndex] ?? {}; 20 | args[optIndex].resolved = args[optIndex].resolved ?? _filename; 21 | args[optIndex].namespace = args[optIndex].namespace ?? 'yeoman:testnamespace'; 22 | super(...args); 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "include": ["src/**/*"], 3 | "compilerOptions": { 4 | /* Language and Environment */ 5 | "target": "ES2022" /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */, 6 | "lib": [ 7 | "ES2022" 8 | ] /* Specify a set of bundled library declaration files that describe the target runtime environment. */, 9 | "types": ["node", "inquirer"], 10 | 11 | /* Modules */ 12 | "module": "node16" /* Specify what module code is generated. */, 13 | "rootDir": "./src" /* Specify the root folder within your source files. */, 14 | "resolveJsonModule": true, 15 | 16 | /* JavaScript Support */ 17 | "allowJs": true /* Allow JavaScript files to be a part of your program. Use the 'checkJS' option to get errors from these files. */, 18 | 19 | /* Emit */ 20 | "outDir": "./dist" /* Specify an output folder for all emitted files. */, 21 | "declaration": true, 22 | 23 | /* Interop Constraints */ 24 | "allowSyntheticDefaultImports": true /* Allow 'import x from y' when a module doesn't have a default export. */, 25 | "esModuleInterop": true /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables 'allowSyntheticDefaultImports' for type compatibility. */, 26 | "forceConsistentCasingInFileNames": true /* Ensure that casing is correct in imports. */, 27 | 28 | /* Type Checking */ 29 | "strict": true /* Enable all strict type-checking options. */, 30 | "noImplicitAny": true, 31 | 32 | /* Completeness */ 33 | "skipLibCheck": false /* Skip type checking all .d.ts files. */ 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /tsconfig.spec.json: -------------------------------------------------------------------------------- 1 | { 2 | "include": ["**/*"], 3 | "exclude": [], 4 | "extends": "./tsconfig.json", 5 | "compilerOptions": { 6 | "types": [] 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /vitest.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vitest/config'; 2 | 3 | export default defineConfig({ 4 | test: { 5 | pool: 'vmForks', 6 | dangerouslyIgnoreUnhandledErrors: true, 7 | }, 8 | }); 9 | --------------------------------------------------------------------------------