├── .github ├── CODEOWNERS ├── ISSUE_TEMPLATE │ ├── bug_report.yml │ ├── config.yml │ └── feature_request.yml ├── PULL_REQUEST_TEMPLATE.md ├── dependabot.yml ├── ionic-issue-bot.yml └── workflows │ ├── build.yml │ ├── format.yml │ ├── main.yml │ ├── release.yml │ └── test-component-starter.yml ├── .gitignore ├── .nvmrc ├── .prettierignore ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── RELEASE.md ├── package-lock.json ├── package.json ├── rollup.config.mjs ├── src ├── create-app.ts ├── download.test.ts ├── download.ts ├── git.test.ts ├── git.ts ├── index.ts ├── interactive.ts ├── starters.test.ts ├── starters.ts ├── unzip.ts ├── utils.test.ts ├── utils.ts ├── version.test.ts └── version.ts ├── tsconfig.json └── vitest.config.ts /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | * @stenciljs/technical-steering-committee 2 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.yml: -------------------------------------------------------------------------------- 1 | name: 🐛 Bug Report 2 | description: Create a report to help us improve the Create Stencil CLI 3 | title: 'bug: ' 4 | body: 5 | - type: checkboxes 6 | attributes: 7 | label: Prerequisites 8 | description: Please ensure you have completed all of the following. 9 | options: 10 | - label: I have read the [Contributing Guidelines](https://github.com/stenciljs/core/blob/main/CONTRIBUTING.md). 11 | required: true 12 | - label: I agree to follow the [Code of Conduct](https://github.com/stenciljs/.github/blob/main/CODE_OF_CONDUCT.md). 13 | required: true 14 | - label: I have searched for [existing issues](https://github.com/stenciljs/create-stencil/issues) that already report this problem, without success. 15 | required: true 16 | - type: input 17 | attributes: 18 | label: Create Stencil Version 19 | description: The version number of Create Stencil where the issue is occurring. 20 | validations: 21 | required: true 22 | - type: textarea 23 | attributes: 24 | label: Current Behavior 25 | description: A clear description of what the bug is and how it manifests. 26 | validations: 27 | required: true 28 | - type: textarea 29 | attributes: 30 | label: Expected Behavior 31 | description: A clear description of what you expected to happen. 32 | validations: 33 | required: true 34 | - type: textarea 35 | attributes: 36 | label: Steps to Reproduce 37 | description: Please explain the steps required to reproduce this issue. 38 | validations: 39 | required: true 40 | - type: textarea 41 | attributes: 42 | label: Additional Information 43 | description: List any other information that is relevant to your issue. Stack traces, related issues, suggestions on how to fix, Stack Overflow links, forum links, etc. 44 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | contact_links: 2 | - name: 📚 Documentation 3 | url: https://github.com/stenciljs/site/issues/new/choose 4 | about: This issue tracker is not for documentation issues. Please file documentation issues on the Stencil site repo. 5 | - name: 💻 Stencil 6 | url: https://github.com/stenciljs/core/issues/new/choose 7 | about: This issue tracker is not for Stencil compiler issues. Please file compiler issues on the Stencil repo. 8 | - name: 🤔 Support Question 9 | url: https://forum.ionicframework.com/ 10 | about: This issue tracker is not for support questions. Please post your question on the Ionic Forums. 11 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.yml: -------------------------------------------------------------------------------- 1 | name: 💡 Feature Request 2 | description: Suggest an idea for the Create Stencil CLI 3 | title: 'feat: ' 4 | body: 5 | - type: checkboxes 6 | attributes: 7 | label: Prerequisites 8 | description: Please ensure you have completed all of the following. 9 | options: 10 | - label: I have read the [Contributing Guidelines](https://github.com/stenciljs/core/blob/main/CONTRIBUTING.md). 11 | required: true 12 | - label: I agree to follow the [Code of Conduct](https://github.com/stenciljs/.github/blob/main/CODE_OF_CONDUCT.md). 13 | required: true 14 | - label: I have searched for [existing issues](https://github.com/stenciljs/create-stencil/issues) that already include this feature request, without success. 15 | required: true 16 | - type: textarea 17 | attributes: 18 | label: Describe the Feature Request 19 | description: A clear and concise description of what the feature does. 20 | validations: 21 | required: true 22 | - type: textarea 23 | attributes: 24 | label: Describe the Use Case 25 | description: A clear and concise use case for this feature. 26 | validations: 27 | required: true 28 | - type: textarea 29 | attributes: 30 | label: Describe Preferred Solution 31 | description: A clear and concise description of how you want this feature to be added to Stencil. 32 | - type: textarea 33 | attributes: 34 | label: Describe Alternatives 35 | description: A clear and concise description of any alternative solutions or features you have considered. 36 | - type: textarea 37 | attributes: 38 | label: Additional Information 39 | description: List any other information that is relevant to your request. Stack traces, related issues, suggestions on how to implement, Stack Overflow links, forum links, etc. 40 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | ## Pull request checklist 4 | 5 | Please check if your PR fulfills the following requirements: 6 | - [ ] Docs have been reviewed and added / updated if needed (for bug fixes / features) 7 | - [ ] Build (`npm run build`) was run locally and any changes were pushed 8 | - [ ] Tests (`npm test`) were run locally and passed 9 | - [ ] Prettier (`npm run prettier`) was run locally and passed 10 | 11 | ## Pull request type 12 | 13 | 14 | 15 | 16 | 17 | Please check the type of change your PR introduces: 18 | - [ ] Bugfix 19 | - [ ] Feature 20 | - [ ] Refactoring (no functional changes, no api changes) 21 | - [ ] Build related changes 22 | - [ ] Documentation content changes 23 | - [ ] Other (please describe): 24 | 25 | 26 | ## What is the current behavior? 27 | 28 | 29 | GitHub Issue Number: N/A 30 | 31 | 32 | ## What is the new behavior? 33 | 34 | 35 | - 36 | - 37 | - 38 | 39 | ## Does this introduce a breaking change? 40 | 41 | - [ ] Yes 42 | - [ ] No 43 | 44 | 45 | 46 | ## Testing 47 | 48 | 49 | 50 | ## Other information 51 | 52 | 53 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: npm 4 | directory: "/" 5 | schedule: 6 | interval: weekly 7 | open-pull-requests-limit: 10 8 | groups: 9 | patch-deps-updates-main: 10 | update-types: 11 | - "patch" 12 | minor-deps-updates-main: 13 | update-types: 14 | - "minor" 15 | major-deps-updates-main: 16 | update-types: 17 | - "major" 18 | 19 | - package-ecosystem: github-actions 20 | directory: "/" 21 | schedule: 22 | interval: weekly 23 | time: "11:00" 24 | open-pull-requests-limit: 10 25 | groups: 26 | patch-deps-updates: 27 | update-types: 28 | - "patch" 29 | minor-deps-updates: 30 | update-types: 31 | - "minor" 32 | major-deps-updates: 33 | update-types: 34 | - "major" -------------------------------------------------------------------------------- /.github/ionic-issue-bot.yml: -------------------------------------------------------------------------------- 1 | triage: 2 | label: triage 3 | dryRun: false 4 | 5 | closeAndLock: 6 | labels: 7 | - label: "ionitron: support" 8 | message: > 9 | Thanks for the issue! This issue appears to be a support request. We use this issue tracker exclusively for 10 | bug reports and feature requests. Please use our [discord channel](https://chat.stenciljs.com) 11 | for questions about Stencil. 12 | 13 | 14 | Thank you for using Stencil! 15 | - label: "ionitron: missing template" 16 | message: > 17 | Thanks for the issue! It appears that you have not filled out the provided issue template. We use this issue 18 | template in order to gather more information and further assist you. Please create a new issue and ensure the 19 | template is fully filled out. 20 | 21 | 22 | Thank you for using Stencil! 23 | close: true 24 | lock: true 25 | dryRun: false 26 | 27 | comment: 28 | labels: 29 | - label: "ionitron: needs reproduction" 30 | message: > 31 | Thanks for the issue! This issue has been labeled as `needs reproduction`. This label is added to issues that 32 | need a code reproduction. 33 | 34 | 35 | Please provide step-by-step instructions to reproduce your error. Be sure to include your operating system, 36 | node version, the shell you are using, and any other information that you feel may be useful in reproducing the 37 | error. 38 | 39 | 40 | If you have already provided a instructions and are seeing this message, it is likely that the instructions were 41 | not enough for our team to reproduce the issue. 42 | 43 | 44 | For a guide on how to create a good reproduction, see our 45 | [Contributing Guide](https://github.com/stenciljs/core/blob/main/CONTRIBUTING.md). 46 | dryRun: false 47 | 48 | noReply: 49 | maxIssuesPerRun: 100 50 | includePullRequests: false 51 | label: Awaiting Reply 52 | close: false 53 | lock: false 54 | dryRun: false 55 | 56 | noReproduction: 57 | days: 14 58 | maxIssuesPerRun: 100 59 | label: "ionitron: needs reproduction" 60 | responseLabel: triage 61 | exemptProjects: true 62 | exemptMilestones: true 63 | message: > 64 | Thanks for the issue! This issue is being closed due to the lack of a code reproduction. If this is still 65 | an issue with the latest version of Stencil, please create a new issue and ensure the template is fully filled out. 66 | 67 | 68 | Thank you for using Stencil! 69 | close: true 70 | lock: true 71 | dryRun: false 72 | 73 | stale: 74 | days: 30 75 | maxIssuesPerRun: 100 76 | exemptLabels: 77 | - "Bug: Validated" 78 | - "Feature: Want this? Upvote it!" 79 | - good first issue 80 | - help wanted 81 | - Reply Received 82 | - Request For Comments 83 | - "Resolution: Needs Investigation" 84 | - "Resolution: Refine" 85 | - triage 86 | exemptAssigned: true 87 | exemptProjects: true 88 | exemptMilestones: true 89 | label: "ionitron: stale issue" 90 | message: > 91 | Thanks for the issue! This issue is being closed due to inactivity. If this is still 92 | an issue with the latest version of Stencil, please create a new issue and ensure the 93 | template is fully filled out. 94 | 95 | 96 | Thank you for using Stencil! 97 | close: true 98 | lock: true 99 | dryRun: false 100 | 101 | wrongRepo: 102 | repos: 103 | - label: "ionitron: cli" 104 | repo: ionic-cli 105 | message: > 106 | Thanks for the issue! We use this issue tracker exclusively for bug reports and feature requests 107 | associated with the Stencil CLI. It appears that this issue is associated with the Ionic CLI. 108 | I am moving this issue to the Ionic CLI repository. Please track this issue over there. 109 | 110 | 111 | Thank you for using Stencil! 112 | - label: "ionitron: ionic" 113 | repo: ionic 114 | message: > 115 | Thanks for the issue! We use this issue tracker exclusively for bug reports and feature requests 116 | associated with Stencil CLI. It appears that this issue is associated with the Ionic Framework. 117 | I am moving this issue to the Ionic Framework repository. Please track this issue over there. 118 | 119 | 120 | Thank you for using Stencil! 121 | close: true 122 | lock: true 123 | dryRun: false 124 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: Build Create Stencil CLI 2 | 3 | on: 4 | workflow_call: 5 | # Make this a reusable workflow, no value needed 6 | # https://docs.github.com/en/actions/using-workflows/reusing-workflows 7 | 8 | jobs: 9 | build_cli: 10 | name: Build CLI 11 | runs-on: 'ubuntu-latest' 12 | steps: 13 | - name: Checkout Code 14 | uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 15 | with: 16 | # the pull_request_target event will consider the HEAD of `main` to be the SHA to use. 17 | # attempt to use the SHA associated with a pull request and fallback to HEAD of `main` 18 | ref: ${{ github.event_name == 'pull_request_target' && format('refs/pull/{0}/merge', github.event.number) || '' }} 19 | persist-credentials: false 20 | 21 | - name: Get Core Dependencies 22 | uses: stenciljs/.github/actions/get-core-dependencies@main 23 | 24 | - name: CLI Build 25 | run: npm run build 26 | shell: bash 27 | 28 | - name: Unit Tests 29 | run: npm test 30 | shell: bash 31 | 32 | - name: Upload Build Artifacts 33 | uses: stenciljs/.github/actions/upload-archive@main 34 | with: 35 | paths: index.js 36 | -------------------------------------------------------------------------------- /.github/workflows/format.yml: -------------------------------------------------------------------------------- 1 | name: Format Create Stencil CLI (Check) 2 | 3 | on: 4 | workflow_call: 5 | # Make this a reusable workflow, no value needed 6 | # https://docs.github.com/en/actions/using-workflows/reusing-workflows 7 | 8 | jobs: 9 | format: 10 | name: Check Formatting 11 | runs-on: 'ubuntu-latest' 12 | steps: 13 | - name: Checkout Code 14 | uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 15 | 16 | - name: Get Core Dependencies 17 | uses: stenciljs/.github/actions/get-core-dependencies@main 18 | 19 | - name: Prettier Check 20 | run: npm run prettier.dry-run 21 | shell: bash 22 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: 6 | - 'main' 7 | pull_request: 8 | branches: 9 | - '**' 10 | 11 | concurrency: 12 | group: ${{ github.workflow }}-${{ github.ref }} 13 | cancel-in-progress: true 14 | 15 | jobs: 16 | build_cli: 17 | name: Build Create Stencil CLI 18 | uses: ./.github/workflows/build.yml 19 | 20 | format: 21 | name: Format Check 22 | uses: ./.github/workflows/format.yml 23 | 24 | smoke_test: 25 | name: Smoke Testing 26 | needs: [build_cli] 27 | uses: ./.github/workflows/test-component-starter.yml 28 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release "create-stencil" 2 | 3 | on: 4 | workflow_dispatch: 5 | inputs: 6 | releaseType: 7 | description: "Release type - major, minor or patch" 8 | required: true 9 | type: choice 10 | default: "patch" 11 | options: 12 | - patch 13 | - minor 14 | - major 15 | devRelease: 16 | description: Set to "yes" to release a dev build 17 | required: true 18 | type: choice 19 | default: "no" 20 | options: 21 | - "yes" 22 | - "no" 23 | 24 | permissions: 25 | contents: write 26 | 27 | jobs: 28 | build_cli: 29 | name: Build 30 | uses: ./.github/workflows/build.yml 31 | 32 | get_dev_version: 33 | if: inputs.devRelease == 'yes' 34 | name: Get Dev Build Version 35 | runs-on: ubuntu-latest 36 | outputs: 37 | dev-version: ${{ steps.generate-dev-version.outputs.DEV_VERSION }} 38 | steps: 39 | - name: Checkout Code 40 | uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 41 | 42 | - name: Generate Dev Version 43 | id: generate-dev-version 44 | run: | 45 | PKG_JSON_VERSION=$(cat package.json | jq -r '.version') 46 | GIT_HASH=$(git rev-parse --short HEAD) 47 | 48 | # A unique string to publish the CLI under 49 | # e.g. "2.1.0-dev.1677185104.7c87e34" 50 | DEV_VERSION=$PKG_JSON_VERSION-dev.$(date +"%s").$GIT_HASH 51 | 52 | echo "Using version $DEV_VERSION" 53 | 54 | # store a key/value pair in GITHUB_OUTPUT 55 | # e.g. "DEV_VERSION=2.1.0-dev.1677185104.7c87e34" 56 | echo "DEV_VERSION=$DEV_VERSION" >> $GITHUB_OUTPUT 57 | shell: bash 58 | 59 | release_create_stencil_cli_dev: 60 | if: inputs.devRelease == 'yes' 61 | name: Publish Dev Build 62 | needs: [build_cli, get_dev_version] 63 | runs-on: ubuntu-latest 64 | permissions: 65 | id-token: write 66 | steps: 67 | - name: Checkout Code 68 | uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 69 | - uses: stenciljs/.github/actions/publish-npm@main 70 | with: 71 | tag: dev 72 | version: ${{ needs.get_dev_version.outputs.dev-version }} 73 | token: ${{ secrets.NPM_TOKEN }} 74 | 75 | release_create_stencil_cli: 76 | if: inputs.devRelease == 'no' 77 | name: Publish Create Stencil CLI 78 | needs: [build_cli] 79 | runs-on: ubuntu-latest 80 | permissions: 81 | id-token: write 82 | steps: 83 | - name: Checkout Code 84 | uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 85 | 86 | - uses: stenciljs/.github/actions/publish-npm@main 87 | with: 88 | tag: latest 89 | version: ${{ inputs.releaseType }} 90 | token: ${{ secrets.NPM_TOKEN }} 91 | github-token: ${{ secrets.GH_ADMIN_PAT }} 92 | 93 | -------------------------------------------------------------------------------- /.github/workflows/test-component-starter.yml: -------------------------------------------------------------------------------- 1 | name: Component Starter Smoke Test 2 | 3 | on: 4 | workflow_call: 5 | # Make this a reusable workflow, no value needed 6 | # https://docs.github.com/en/actions/using-workflows/reusing-workflows 7 | 8 | jobs: 9 | component_test: 10 | name: (${{ matrix.os }}.${{ matrix.node }}) 11 | strategy: 12 | fail-fast: false 13 | matrix: 14 | node: ['18', '20', '22'] 15 | os: ['ubuntu-latest', 'windows-latest', 'macos-latest'] 16 | runs-on: ${{ matrix.os }} 17 | steps: 18 | - name: Checkout Code 19 | uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 20 | 21 | - name: Get Core Dependencies 22 | uses: stenciljs/.github/actions/get-core-dependencies@main 23 | 24 | - name: Use Node ${{ matrix.node }} 25 | uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0 26 | with: 27 | node-version: ${{ matrix.node }} 28 | cache: 'npm' 29 | 30 | - name: Download Build Archive 31 | uses: stenciljs/.github/actions/download-archive@main 32 | with: 33 | name: stencil-cli 34 | path: . 35 | filename: stencil-cli-build.zip 36 | 37 | - name: Initialize the Project 38 | run: node index.js component tmp-component-starter 39 | shell: bash 40 | 41 | - name: Install Component Starter Dependencies 42 | run: npm install 43 | working-directory: ./tmp-component-starter 44 | shell: bash 45 | 46 | - name: Build Starter Project 47 | run: npm run build 48 | working-directory: ./tmp-component-starter 49 | shell: bash 50 | 51 | - name: Test Starter Project 52 | run: npm run test -- --no-build # the project was just built, don't build it again 53 | working-directory: ./tmp-component-starter 54 | shell: bash 55 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | coverage 3 | dist 4 | index.js 5 | .DS_store 6 | .idea/ 7 | *.tgz -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | v22.14.0 -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | # the output of the built project 2 | dist/* 3 | index.js 4 | 5 | # testing files 6 | coverage/* 7 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | Thanks for your interest in contributing to create-stencil! :tada: 4 | 5 | ## Contributing Etiquette 6 | 7 | Please see our [Contributor Code of Conduct](https://github.com/stenciljs/.github/blob/main/CODE_OF_CONDUCT.md) for information on our rules of conduct. 8 | 9 | ## Creating an Issue 10 | 11 | * If you have a question about using the CLI or Stencil in general, please ask in the [Stencil Discord server](https://chat.stenciljs.com). 12 | 13 | * It is required that you clearly describe the steps necessary to reproduce the issue you are running into. Although we would love to help our users as much as possible, diagnosing issues without clear reproduction steps is extremely time-consuming and simply not sustainable. 14 | 15 | * The issue list of this repository is exclusively for bug reports and feature requests. Non-conforming issues will be closed immediately. 16 | 17 | * Issues with no clear steps to reproduce will not be triaged. If an issue is labeled with "Awaiting Reply" and receives no further replies from the author of the issue for more than 5 days, it will be closed. 18 | 19 | * If you think you have found a bug, or have a new feature idea, please start by making sure it hasn't already been [reported](https://github.com/stenciljs/core/issues?utf8=%E2%9C%93&q=is%3Aissue). You can search through existing issues to see if there is a similar one reported. Include closed issues as it may have been closed with a solution. 20 | 21 | * Next, [create a new issue](https://github.com/stenciljs/create-stencil/issues/new?assignees=&labels=&projects=&template=bug_report.yml&title=bug%3A+) that thoroughly explains the problem. Please fill out the populated issue form before submitting the issue. 22 | 23 | 24 | ## Creating a Pull Request 25 | 26 | * We appreciate you taking the time to contribute! Before submitting a pull request, we ask that you please [create an issue](#creating-an-issue) that explains the bug or feature request and let us know that you plan on creating a pull request for it. If an issue already exists, please comment on that issue letting us know you would like to submit a pull request for it. This helps us to keep track of the pull request and make sure there isn't duplicated effort. 27 | 28 | ### Setup 29 | 30 | 1. Fork the repo. 31 | 2. Clone your fork. 32 | 3. Make a branch for your change. 33 | 4. This project uses [volta](https://volta.sh) to manage its npm and Node versions. 34 | [Install it](https://docs.volta.sh/guide/getting-started) before proceeding. 35 | 1. There's no need to install a specific version of npm or Node right now, it shall be done automatically for you in 36 | the next step 37 | 5. Run `npm install` 38 | 39 | ### Making Changes and Running Locally 40 | 41 | Next, make changes to the contents of the `src/` file(s) to support your changes. 42 | Changes can be manually tested by compiling & running the project using: 43 | ```bash 44 | npm run dev 45 | ``` 46 | 47 | ### Commit Message Format 48 | 49 | Please see the [Commit Message Format section](https://github.com/stenciljs/core/blob/main/CONTRIBUTING.md#commit-message-format) of the Stencil README. 50 | 51 | ## License 52 | 53 | By contributing your code to the [stenciljs/core](https://github.com/stenciljs/core/blob/main/CONTRIBUTING.md) GitHub Repository, you agree to license your contribution under the MIT license. 54 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Ionic 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # The create-stencil CLI 2 | 3 | create-stencil is a CLI for creating new Stencil projects based on predefined templates, or "starters". 4 | It is the official CLI maintained by the Stencil team, and is recommended for all new projects. 5 | 6 | ## Prerequisites 7 | 8 | The create-stencil CLI requires `npm` version 6 or higher to be installed. 9 | For instructions for installing or upgrading npm, please see the [npm Documentation](https://docs.npmjs.com/downloading-and-installing-node-js-and-npm). 10 | 11 | ## Starters 12 | 13 | The create-stencil CLI offers the following starters for bootstrapping your project: 14 | 15 | - `component` - allows one to spin up a component library containing one or more Stencil components. Best suited for 16 | teams/individuals looking to reuse components across one or more applications. ([Source Code](https://github.com/stenciljs/component-starter)) 17 | - `app` - allows one to spin up an application, complete with routing. This is a **community-driven** project, 18 | and is not formally owned by the Stencil team. ([Source Code](https://github.com/stencil-community/stencil-app-starter)) 19 | - `ionic-pwa` - allows one to spin up an Ionic PWA, complete with tabs layout and routing. This is a **community-driven** project, 20 | and is not formally owned by the Stencil team. ([Source Code](https://github.com/stencil-community/stencil-ionic-starter)) 21 | 22 | The CLI can also generate projects using starters that are not officially developed by Ionic or the Stencil Community. 23 | See the documentation for [Command Mode](#command-mode) for additional information on using additional templates. 24 | 25 | ## Usage 26 | 27 | The create-stencil CLI can be run in one of two modes - Interactive Mode or Command Mode. 28 | 29 | ### Interactive Mode 30 | 31 | Interactive Mode allows a user to interactively select options for creating a new Stencil project. 32 | create-stencil can be started in Interactive Mode by running: 33 | ```console 34 | $ npm init stencil 35 | ``` 36 | 37 | Running the CLI in Interactive Mode will prompt you to select one of the [available starters](#starters) to use: 38 | ```console 39 | $ npm init stencil 40 | 41 | ✔ Select a starter project. 42 | 43 | Starters marked as [community] are developed by the Stencil Community, 44 | rather than Ionic. For more information on the Stencil Community, please see 45 | https://github.com/stencil-community › - Use arrow-keys. Return to submit. 46 | ❯ component Collection of web components that can be used anywhere 47 | app [community] Minimal starter for building a Stencil app or website 48 | ionic-pwa [community] Ionic PWA starter with tabs layout and routes 49 | ``` 50 | 51 | Followed by a name for your new project: 52 | ```console 53 | ✔ Project name > my-stencil-library 54 | ``` 55 | 56 | After confirming your selections, your project will be created. 57 | In this example, a new [component library starter](#starters) will have been copied into a newly created `my-stencil-library` directory: 58 | ```console 59 | ✔ Confirm? … yes 60 | ✔ All setup in 29 ms 61 | 62 | We suggest that you begin by typing: 63 | 64 | $ cd my-stencil-library 65 | $ npm install 66 | $ npm start 67 | 68 | You may find the following commands will be helpful: 69 | 70 | $ npm start 71 | Starts the development server. 72 | 73 | $ npm run build 74 | Builds your project in production mode. 75 | 76 | $ npm test 77 | Starts the test runner. 78 | 79 | 80 | Further reading: 81 | 82 | - https://github.com/stenciljs/component-starter 83 | - https://stenciljs.com/docs 84 | 85 | Happy coding! 🎈 86 | ``` 87 | 88 | ### Command Mode 89 | 90 | Command Mode allows you to create a new Stencil project by specifying all project options upfront. 91 | 92 | To run the CLI in Command Mode, a [starter](#starters) and project name must be specified: 93 | ``` 94 | npm init stencil [starter] [project-name] 95 | ``` 96 | 97 | An example of creating a component starter with the name "my-stencil-library" is shown below: 98 | ``` 99 | npm init stencil component my-stencil-library 100 | ``` 101 | In the example above, a new [component library starter](#starters) will have been created in a newly created `my-stencil-library` directory. 102 | 103 | #### Custom Templates 104 | In addition to the provided template options, users may choose to use one of their own custom templates hosted on [GitHub.com](https://github.com). 105 | 106 | To use a custom starter template, provide the GitHub repository owner and repository name as the starter name, using the format `REPO_OWNER/REPO_NAME`. 107 | For example, to retrieve a template that is owned by 'my-organization' that has the name 'my-stencil-template': 108 | ``` 109 | npm init stencil my-organization/my-stencil-template my-stencil-library 110 | ``` 111 | The command above will create a copy of the `my-organization/my-stencil-template` repository, and place it under `my-stencil-library` on disk. 112 | 113 | This can be used in conjunction with [Self Hosted GitHub Instances](#stencilselfhostedurl) to use custom starter templates that live on a self-hosted GitHub instance. 114 | 115 | ### Additional Flags 116 | 117 | **Note:** When passing flags to the create-stencil CLI, a double dash ('--') must be placed between `npm init stencil` 118 | and the flag(s) passed to the CLI: 119 | ```console 120 | $ npm init stencil -- --help 121 | ``` 122 | 123 | #### `--help`, `-h` 124 | 125 | The `--help` flag shows usage examples for the CLI. 126 | 127 | #### `--info` 128 | 129 | The `--info` will print the current version of the CLI. 130 | 131 | ### Environment Variables 132 | 133 | #### `https_proxy` 134 | 135 | If you are behind a proxy, the `https_proxy` environment variable can be set when running the CLI: 136 | ```console 137 | $ https_proxy=https://[IP_ADDRESS] npm init stencil 138 | ``` 139 | 140 | Stencil uses [https-proxy-agent](https://github.com/TooTallNate/proxy-agents/tree/main/packages/https-proxy-agent) 141 | under the hood to connect to the specified proxy server. 142 | The value provided for `https_proxy` will be passed directly to the constructor for a new 143 | [`HttpsProxyAgent` instance](https://github.com/TooTallNate/proxy-agents/tree/main/packages/https-proxy-agent#api). 144 | 145 | #### `stencil_self_hosted_url` 146 | 147 | In some scenarios, teams may find themselves working solely out of a self-hosted GitHub instance. 148 | 149 | Users wishing to point the create-stencil CLI at a GitHub instance other than [GitHub](https://github.com) have two options: 150 | 151 | 1. Set `stencil_self_hosted_url` in your `.npmrc` file, like so: 152 | ``` 153 | // .npmrc 154 | stencil_self_hosted_url=https://your_self_hosted_github_repo.com/ 155 | ``` 156 | 157 | Using this option, the CLI can be called as such, automatically picking up the value in `stencil_self_hosted_url`: 158 | ``` 159 | npm init stencil [starter] [project-name] 160 | ``` 161 | 162 | 2. Set [`stencil_self_hosted_url`](#stencilselfhostedurl) at invocation time: 163 | ```console 164 | stencil_self_hosted_url=https://your_self_hosted_github_repo.com/ npm init stencil 165 | ``` 166 | 167 | When using this option, `stencil_self_hosted_url` must always be set every time the CLI is called. 168 | 169 | When both options are set, the value provided on the command line takes precedence over the value in your `.npmrc` file. 170 | 171 | ## Citations 172 | 173 | Original project was created by William M. Riley: 174 | * [Twitter](https://twitter.com/splitinfinities) 175 | * [Github](https://github.com/splitinfinities) 176 | 177 | ## Related Links 178 | 179 | * The [Stencil Documentation](https://stenciljs.com/) site has more information on using Stencil. 180 | * Check out the [Stencil Discord](https://chat.stenciljs.com/) for help and general Stencil discussion! 181 | -------------------------------------------------------------------------------- /RELEASE.md: -------------------------------------------------------------------------------- 1 | # Releasing the Create Stencil Cli 2 | 3 | ## Development Releases 4 | 5 | Development Releases (or "Dev Releases", "Dev Builds") are installable instances of the Create Stencil Cli that are: 6 | - Published to the npm registry for distribution within and outside the Stencil team 7 | - Built using the same infrastructure as production releases, with less safety checks 8 | - Used to verify a fix or change to the project prior to a production release 9 | 10 | ### How to Publish 11 | 12 | Only members of the Stencil team may create dev builds of The Create Stencil Cli. 13 | To publish the package: 14 | 1. Navigate to the [Create Stencil Cli Dev Release GitHub Action](https://github.com/stenciljs/create-stencil/actions/workflows/release-dev.yml) in your browser. 15 | 2. Select the 'Run Workflow' dropdown on the right hand side of the page 16 | 3. The dropdown will ask you for a branch name to publish from. Any branch may be used here. 17 | 4. Select 'Run Workflow' 18 | 5. Allow the workflow to run. Upon completion, the output of the 'publish-npm' action will report the published version string. 19 | 20 | Following a successful run of the workflow, the CLI can be run like any other [initializer package](https://docs.npmjs.com/cli/commands/npm-init). 21 | Users must specify the version when calling `npm init`, like so: 22 | ```bash 23 | npm init stencil@DEV_VERSION 24 | ``` 25 | where `DEV_VERSION` is the version published to NPM. 26 | 27 | ### Publish Format 28 | 29 | Unlike other Stencil projects, Dev Builds are not published to the NPM registry under the `@stencil` scope. 30 | Rather, they are published directly to the package name `create-stencil`. 31 | 32 | Unlike production builds, dev builds use a specially formatted version string to express its origins. 33 | Dev builds follow the format `BASE_VERSION-dev.EPOCH_DATE.SHA`, where: 34 | - `BASE_VERSION` is the latest production release changes to the build were based off of 35 | - `EPOCH_DATE` is the number of seconds since January 1st, 1970 in UTC 36 | - `SHA` is the git short SHA of the commit used in the release 37 | 38 | As an example: `2.1.0-dev.1677185104.7c87e34` was built: 39 | - With v2.1.0 as the latest production build at the time of the dev build 40 | - On Fri, 26 Jan 2024 13:48:17 UTC 41 | - With the commit `7c87e34` -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "create-stencil", 3 | "version": "4.0.2", 4 | "description": "Quickly create a new stencil component project: npm init stencil", 5 | "repository": { 6 | "type": "git", 7 | "url": "https://github.com/stenciljs/create-stencil" 8 | }, 9 | "type": "module", 10 | "exports": "./index.js", 11 | "files": [ 12 | "index.js" 13 | ], 14 | "scripts": { 15 | "start": "node index.js", 16 | "build": "run-s build.*", 17 | "build.bundle": "rollup -c", 18 | "build.minify": "terser --compress --mangle --toplevel --output index.js -- ./dist/index.js", 19 | "test": "vitest", 20 | "prettier": "npm run prettier.base -- --write", 21 | "prettier.base": "prettier --cache \"./**/*.{ts,tsx,js,jsx}\"", 22 | "prettier.dry-run": "npm run prettier.base -- --list-different", 23 | "release": "np", 24 | "watch": "run-p watch.*", 25 | "watch.tsc": "tsc --watch", 26 | "watch.bundle": "rollup -c --watch", 27 | "watch.minify": "onchange './dist/index.js' -- npm run build.minify" 28 | }, 29 | "engines": { 30 | "node": ">=10.10.0", 31 | "npm": ">=6.0.0" 32 | }, 33 | "bin": { 34 | "create-stencil": "index.js" 35 | }, 36 | "devDependencies": { 37 | "@clack/prompts": "^0.11.0", 38 | "@ionic/prettier-config": "^4.0.0", 39 | "@rollup/plugin-commonjs": "^28.0.3", 40 | "@rollup/plugin-node-resolve": "^16.0.1", 41 | "@rollup/plugin-typescript": "^12.1.2", 42 | "@types/node": "^22.15.17", 43 | "@types/prompts": "^2.4.9", 44 | "@types/yauzl": "^2.10.3", 45 | "@vitest/coverage-v8": "^3.1.3", 46 | "colorette": "^2.0.20", 47 | "https-proxy-agent": "^7.0.6", 48 | "node-fetch": "^3.3.2", 49 | "np": "^10.2.0", 50 | "npm-run-all2": "^8.0.1", 51 | "onchange": "^7.1.0", 52 | "prettier": "3.5.3", 53 | "replace-in-file": "^8.3.0", 54 | "rollup": "^4.40.2", 55 | "sisteransi": "^1.0.5", 56 | "terser": "^5.39.1", 57 | "typescript": "~5.8.3", 58 | "vitest": "^3.1.3", 59 | "yauzl": "^3.2.0" 60 | }, 61 | "author": "Ionic Team & William M. Riley", 62 | "license": "MIT", 63 | "keywords": [ 64 | "stencil", 65 | "stenciljs", 66 | "web components", 67 | "create-app", 68 | "cli", 69 | "progress web app", 70 | "ionic" 71 | ], 72 | "prettier": "@ionic/prettier-config", 73 | "volta": { 74 | "node": "20.15.1", 75 | "npm": "10.8.3" 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /rollup.config.mjs: -------------------------------------------------------------------------------- 1 | import { builtinModules } from 'node:module' 2 | 3 | import resolve from '@rollup/plugin-node-resolve'; 4 | import typescript from '@rollup/plugin-typescript'; 5 | import commonjs from '@rollup/plugin-commonjs'; 6 | 7 | export default { 8 | input: 'src/index.ts', 9 | output: { 10 | dir: 'dist', 11 | format: 'esm', 12 | strict: false, 13 | banner: '#! /usr/bin/env node\n', 14 | generatedCode: { 15 | constBindings: true, 16 | }, 17 | }, 18 | plugins: [resolve(), commonjs(), typescript()], 19 | external: [...builtinModules], 20 | }; 21 | -------------------------------------------------------------------------------- /src/create-app.ts: -------------------------------------------------------------------------------- 1 | import fs from 'node:fs'; 2 | import path from 'node:path'; 3 | 4 | import { bold, cyan, dim, green, yellow } from 'colorette'; 5 | import { log, spinner } from '@clack/prompts'; 6 | 7 | import { downloadStarter } from './download'; 8 | import { Starter } from './starters'; 9 | import { unZipBuffer } from './unzip'; 10 | import { npm, onlyUnix, printDuration, setTmpDirectory, terminalPrompt } from './utils'; 11 | import { replaceInFile } from 'replace-in-file'; 12 | import { commitAllFiles, hasGit, inExistingGitTree, initGit } from './git'; 13 | 14 | const starterCache = new Map Promise)>>(); 15 | 16 | export async function createApp(starter: Starter, projectName: string, autoRun: boolean) { 17 | if (fs.existsSync(projectName)) { 18 | throw new Error(`Folder "./${projectName}" already exists, please choose a different project name.`); 19 | } 20 | 21 | projectName = projectName.toLowerCase().trim(); 22 | 23 | if (!validateProjectName(projectName)) { 24 | throw new Error(`Project name "${projectName}" is not valid. It must be a kebab-case name without spaces.`); 25 | } 26 | 27 | const loading = spinner(); 28 | loading.start(bold('Preparing starter')); 29 | 30 | const startT = Date.now(); 31 | const moveTo = await prepareStarter(starter); 32 | if (!moveTo) { 33 | throw new Error('starter install failed'); 34 | } 35 | await moveTo(projectName); 36 | loading.stop('Done!'); 37 | 38 | const time = printDuration(Date.now() - startT); 39 | let didGitSucceed = initGitForStarter(projectName); 40 | 41 | if (didGitSucceed) { 42 | log.success(`${green('✔')} ${bold('All setup')} ${onlyUnix('🎉')} ${dim(time)}`); 43 | } else { 44 | // an error occurred setting up git for the project. log it, but don't block creating the project 45 | log.warn(`${yellow('❗')} We were unable to ensure git was configured for this project.`); 46 | log.success(`${green('✔')} ${bold('However, your project was still created')} ${onlyUnix('🎉')} ${dim(time)}`); 47 | } 48 | 49 | // newline here is intentional in relation to the previous logged statements 50 | console.log(` 51 | ${dim('We suggest that you begin by typing:')} 52 | 53 | ${dim(terminalPrompt())} ${green('cd')} ${projectName} 54 | ${dim(terminalPrompt())} ${green('npm install')} 55 | ${dim(terminalPrompt())} ${green('npm start')} 56 | 57 | ${dim('You may find the following commands will be helpful:')} 58 | 59 | ${dim(terminalPrompt())} ${green('npm start')} 60 | Starts the development server. 61 | 62 | ${dim(terminalPrompt())} ${green('npm run build')} 63 | Builds your project in production mode. 64 | 65 | ${dim(terminalPrompt())} ${green('npm test')} 66 | Starts the test runner. 67 | 68 | ${renderDocs(starter)} 69 | 70 | 🗣️ ${dim(`Join the Stencil Community on Discord: `)} 71 | ${cyan('https://chat.stenciljs.com')} 72 | 73 | Happy coding! 🎈 74 | `); 75 | 76 | if (autoRun) { 77 | await npm('start', projectName, 'inherit'); 78 | } 79 | } 80 | 81 | function renderDocs(starter: Starter) { 82 | const docs = starter.docs; 83 | if (!docs) { 84 | return ''; 85 | } 86 | return ` 87 | 📚 ${dim('Further reading:')} 88 | ${dim('-')} ${cyan(docs)} 89 | ${dim('-')} ${cyan('https://stenciljs.com/docs')}`; 90 | } 91 | 92 | export function prepareStarter(starter: Starter) { 93 | let promise = starterCache.get(starter); 94 | if (!promise) { 95 | promise = prepare(starter); 96 | // silent crash, we will handle later 97 | promise.catch((err: unknown) => { 98 | const error = err instanceof Error ? err.message : String(err); 99 | console.error(`\n\nFailed to setup starter project "${starter.name}": ${error}\n\n`); 100 | return; 101 | }); 102 | starterCache.set(starter, promise); 103 | } 104 | return promise; 105 | } 106 | 107 | async function prepare(starter: Starter) { 108 | const baseDir = process.cwd(); 109 | const tmpPath = path.join(baseDir, '.tmp-stencil-starter'); 110 | const buffer = await downloadStarter(starter); 111 | setTmpDirectory(tmpPath); 112 | 113 | await unZipBuffer(buffer, tmpPath); 114 | await npm('ci', tmpPath); 115 | 116 | return async (projectName: string) => { 117 | const filePath = path.join(baseDir, projectName); 118 | await fs.promises.rename(tmpPath, filePath); 119 | await replaceInFile({ 120 | files: [path.join(filePath, '*'), path.join(filePath, 'src/*')], 121 | from: /stencil-starter-project-name/g, 122 | to: projectName, 123 | glob: { 124 | windowsPathsNoEscape: true, 125 | }, 126 | }); 127 | setTmpDirectory(null); 128 | }; 129 | } 130 | 131 | function validateProjectName(projectName: string) { 132 | return !/[^a-zA-Z0-9-]/.test(projectName); 133 | } 134 | 135 | /** 136 | * Helper for performing the necessary steps to create a git repository for a new project 137 | * @param directory the name of the new project's directory 138 | * @returns true if no issues were encountered, false otherwise 139 | */ 140 | const initGitForStarter = (directory: string): boolean => { 141 | if (!changeDir(directory) || !hasGit()) { 142 | // we failed to swtich to the directory to check/create the repo 143 | // _or_ we didn't have `git` on the path 144 | return false; 145 | } 146 | 147 | if (inExistingGitTree()) { 148 | // we're already in a git tree, don't attempt to create one 149 | return true; 150 | } 151 | 152 | if (!initGit()) { 153 | // we failed to create a new git repo 154 | return false; 155 | } 156 | 157 | return commitAllFiles(); 158 | }; 159 | 160 | /** 161 | * Helper method for switching to a new directory on disk 162 | * @param moveTo the directory name to switch to 163 | * @returns true if the switch occurred successfully, false otherwise 164 | */ 165 | export function changeDir(moveTo: string): boolean { 166 | let wasSuccess = false; 167 | try { 168 | process.chdir(moveTo); 169 | wasSuccess = true; 170 | } catch (err: unknown) { 171 | console.error(err); 172 | } 173 | return wasSuccess; 174 | } 175 | -------------------------------------------------------------------------------- /src/download.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it, afterEach } from 'vitest'; 2 | import { Starter } from './starters'; 3 | import { getGitHubUrl, getStarterUrl, verifyStarterExists } from './download'; 4 | 5 | describe('download', () => { 6 | describe('verifyStarterExists', () => { 7 | it('returns false if starter does not exist', async () => { 8 | expect( 9 | await verifyStarterExists({ 10 | repo: 'foo/bar', 11 | name: 'foo-bar-starter', 12 | }), 13 | ).toBe(false); 14 | }); 15 | 16 | it('returns true if starter does exist', async () => { 17 | expect( 18 | await verifyStarterExists({ 19 | repo: 'stenciljs/core', 20 | name: 'stencil', 21 | }), 22 | ).toBe(true); 23 | }); 24 | }); 25 | 26 | describe('getStarterUrl', () => { 27 | it('returns a well formed URL from the given starter', () => { 28 | const repo = 'ionic-team/mock-stencil-template'; 29 | const starter: Starter = { 30 | name: 'test-starter', 31 | repo, 32 | }; 33 | 34 | expect(getStarterUrl(starter)).toBe(`https://github.com/${repo}/archive/main.zip`); 35 | }); 36 | 37 | describe('self-hosted url', () => { 38 | afterEach(() => { 39 | delete process.env['npm_config_stencil_self_hosted_url']; 40 | delete process.env['stencil_self_hosted_url']; 41 | }); 42 | 43 | it.each(['https://ionic.io/', 'https://ionic.io'])( 44 | "returns a well formed self-hosted URL '(%s)' when npm_config_stencil_self_hosted_url is set", 45 | (selfHostedUrl) => { 46 | process.env['npm_config_stencil_self_hosted_url'] = selfHostedUrl; 47 | 48 | expect(getGitHubUrl()).toBe(selfHostedUrl); 49 | }, 50 | ); 51 | 52 | it.each(['https://ionic.io/', 'https://ionic.io'])( 53 | "returns a well formed self-hosted URL '(%s)' when stencil_self_hosted_url is set", 54 | (selfHostedUrl) => { 55 | process.env['stencil_self_hosted_url'] = selfHostedUrl; 56 | 57 | expect(getGitHubUrl()).toBe(selfHostedUrl); 58 | }, 59 | ); 60 | 61 | it('uses stencil_self_hosted_url over npm_config_stencil_self_hosted_url', () => { 62 | const npmConfigUrl = 'https://ionic.io/opt-1'; 63 | 64 | process.env['stencil_self_hosted_url'] = npmConfigUrl; 65 | process.env['npm_config_stencil_self_hosted_url'] = 'https://ionic.io/opt-2'; 66 | 67 | expect(getGitHubUrl()).toBe(npmConfigUrl); 68 | }); 69 | }); 70 | }); 71 | 72 | describe('getGitHubUrl', () => { 73 | describe('self-hosted url', () => { 74 | afterEach(() => { 75 | delete process.env['stencil_self_hosted_url']; 76 | }); 77 | 78 | it('returns a self-hosted url when one is provided', () => { 79 | const mockSelfHostedUrl = 'https://ionic.io/'; 80 | process.env['stencil_self_hosted_url'] = mockSelfHostedUrl; 81 | 82 | expect(getGitHubUrl()).toBe(mockSelfHostedUrl); 83 | }); 84 | }); 85 | 86 | it('returns the default GitHub host when no self-hosted option is provided', () => { 87 | expect(getGitHubUrl()).toBe('https://github.com/'); 88 | }); 89 | }); 90 | }); 91 | -------------------------------------------------------------------------------- /src/download.ts: -------------------------------------------------------------------------------- 1 | import fetch, { type RequestInit } from 'node-fetch'; 2 | import { HttpsProxyAgent } from 'https-proxy-agent'; 3 | 4 | import { Starter } from './starters'; 5 | 6 | /** 7 | * Build a URL to retrieve a starter template from a GitHub instance 8 | * 9 | * This function assumes that the starter will always be in a GitHub instance, as it returns a URL in string form that 10 | * is specific to GitHub. 11 | * 12 | * @param starter metadata for the starter template to build a URL for 13 | * @returns the generated URL to pull the template from 14 | */ 15 | export function getStarterUrl(starter: Starter): string { 16 | return new URL(`${starter.repo}/archive/main.zip`, getGitHubUrl()).toString(); 17 | } 18 | 19 | /** 20 | * Retrieve the URL for the GitHub instance to pull the starter template from 21 | * 22 | * This function searches for the following environment variables (in order), using the first one that is found: 23 | * 1. stencil_self_hosted_url 24 | * 2. npm_config_stencil_self_hosted_url 25 | * 3. None - default to the publicly available GitHub instance 26 | * 27 | * @returns the URL for GitHub 28 | */ 29 | export function getGitHubUrl(): string { 30 | return ( 31 | process.env['stencil_self_hosted_url'] ?? process.env['npm_config_stencil_self_hosted_url'] ?? 'https://github.com/' 32 | ); 33 | } 34 | 35 | function getRequestOptions(starter: string | Starter) { 36 | const url = new URL(typeof starter === 'string' ? starter : getStarterUrl(starter)); 37 | const options: RequestInit = { 38 | follow: Infinity, 39 | }; 40 | if (process.env['https_proxy']) { 41 | const agent = new HttpsProxyAgent(process.env['https_proxy']); 42 | options.agent = agent; 43 | } 44 | return { url, options }; 45 | } 46 | 47 | export async function downloadStarter(starter: Starter | string): Promise { 48 | const { url, options } = getRequestOptions(starter); 49 | const response = await fetch(url, options); 50 | return response.arrayBuffer(); 51 | } 52 | 53 | export async function verifyStarterExists(starter: Starter | string) { 54 | const { url, options } = getRequestOptions(starter); 55 | options.method = 'HEAD'; 56 | const response = await fetch(url, options); 57 | return response.status === 200; 58 | } 59 | -------------------------------------------------------------------------------- /src/git.test.ts: -------------------------------------------------------------------------------- 1 | import { execSync } from 'node:child_process'; 2 | 3 | import { describe, expect, it, beforeEach, afterEach, vi } from 'vitest'; 4 | import { commitAllFiles, hasGit, inExistingGitTree, initGit } from './git'; 5 | import { getPkgVersion } from './version'; 6 | 7 | vi.mock('node:child_process', () => ({ 8 | execSync: vi.fn(), 9 | })); 10 | 11 | const MOCK_PKG_JSON_VERSION = '3.0.0'; 12 | vi.mock('./version', () => ({ 13 | getPkgVersion: vi.fn().mockReturnValue('3.0.0'), 14 | })); 15 | 16 | vi.mock('@clack/prompts', () => ({ 17 | log: { 18 | warn: vi.fn(), 19 | success: vi.fn(), 20 | error: vi.fn(), 21 | }, 22 | intro: vi.fn(), 23 | outro: vi.fn(), 24 | })); 25 | 26 | describe('git', () => { 27 | beforeEach(() => { 28 | vi.spyOn(console, 'error').mockImplementation(() => {}); 29 | vi.spyOn(console, 'info').mockImplementation(() => {}); 30 | vi.spyOn(console, 'warn').mockImplementation(() => {}); 31 | }); 32 | 33 | afterEach(() => { 34 | vi.restoreAllMocks(); 35 | }); 36 | 37 | describe('hasGit', () => { 38 | it('returns true when git is on the path', () => { 39 | vi.mocked(execSync).mockImplementation((cmd: string, _options: unknown | undefined) => { 40 | console.log('cmd', cmd); 41 | switch (cmd) { 42 | case 'git --version': 43 | return Buffer.alloc(0); 44 | default: 45 | throw new Error(`unmocked command ${cmd}`); 46 | } 47 | }); 48 | 49 | expect(hasGit()).toBe(true); 50 | }); 51 | 52 | it('returns false when git is not on the path', () => { 53 | vi.mocked(execSync).mockImplementation((cmd: string, _options: unknown | undefined) => { 54 | switch (cmd) { 55 | case 'git --version': 56 | throw new Error('`git` could not be found'); 57 | default: 58 | throw new Error(`unmocked command ${cmd}`); 59 | } 60 | }); 61 | 62 | expect(hasGit()).toBe(false); 63 | }); 64 | }); 65 | 66 | describe('inExistingGitTree', () => { 67 | it('returns status of true when a project is in existing git repo', () => { 68 | vi.mocked(execSync).mockImplementation((cmd: string, _options: unknown | undefined) => { 69 | switch (cmd) { 70 | case 'git rev-parse --is-inside-work-tree': 71 | return Buffer.alloc(0); 72 | default: 73 | throw new Error(`unmocked command ${cmd}`); 74 | } 75 | }); 76 | 77 | expect(inExistingGitTree()).toBe(true); 78 | }); 79 | 80 | it('returns status of false when a project is not in existing git repo', () => { 81 | vi.mocked(execSync).mockImplementation(() => { 82 | throw new Error('fatal: not a git repository (or any of the parent directories): .git'); 83 | }); 84 | expect(inExistingGitTree()).toBe(false); 85 | }); 86 | }); 87 | 88 | describe('initGit', () => { 89 | it('returns true when git is successfully initialized', () => { 90 | vi.mocked(execSync).mockImplementation((cmd: string, _options: unknown | undefined) => { 91 | switch (cmd) { 92 | case 'git init': 93 | return Buffer.alloc(0); 94 | default: 95 | throw new Error(`unmocked command ${cmd}`); 96 | } 97 | }); 98 | expect(initGit()).toBe(true); 99 | }); 100 | 101 | it('returns false when git repo initialization fails', () => { 102 | vi.mocked(execSync).mockImplementation((cmd: string, _options: unknown | undefined) => { 103 | switch (cmd) { 104 | case 'git init': 105 | throw new Error('`git init` failed for some reason'); 106 | default: 107 | throw new Error(`unmocked command ${cmd}`); 108 | } 109 | }); 110 | expect(initGit()).toBe(false); 111 | }); 112 | }); 113 | 114 | describe('commitGit', () => { 115 | beforeEach(() => { 116 | vi.mocked(execSync).mockImplementation((cmd: string, _options: unknown | undefined) => { 117 | switch (cmd) { 118 | case 'git add -A': 119 | return Buffer.alloc(0); 120 | case `git commit -m "init with create-stencil v${MOCK_PKG_JSON_VERSION}"`: 121 | case `git commit -m "init with create-stencil"`: 122 | return Buffer.alloc(0); 123 | default: 124 | throw new Error(`unmocked command ${cmd}`); 125 | } 126 | }); 127 | }); 128 | 129 | it('returns true when files are committed', () => { 130 | vi.mocked(getPkgVersion).mockReturnValue('3.0.0'); 131 | expect(commitAllFiles()).toBe(true); 132 | }); 133 | 134 | describe("'git add' fails", () => { 135 | beforeEach(() => { 136 | vi.mocked(execSync).mockImplementation((cmd: string, _options: unknown | undefined) => { 137 | switch (cmd) { 138 | case 'git add -A': 139 | throw new Error('git add has failed for some reason'); 140 | case `git commit -m "init with create-stencil v${MOCK_PKG_JSON_VERSION}"`: 141 | throw new Error('git commit should not have been reached!'); 142 | default: 143 | throw new Error(`unmocked command ${cmd}`); 144 | } 145 | }); 146 | }); 147 | 148 | it('returns false ', () => { 149 | expect(commitAllFiles()).toBe(false); 150 | }); 151 | 152 | it('does not attempt to commit files', () => { 153 | commitAllFiles(); 154 | 155 | expect(vi.mocked(execSync)).toHaveBeenCalledTimes(1); 156 | expect(vi.mocked(execSync)).toHaveBeenCalledWith('git add -A', { stdio: 'ignore' }); 157 | }); 158 | }); 159 | 160 | describe("'git commit' fails", () => { 161 | it("returns false when 'git commit' fails", () => { 162 | vi.mocked(execSync).mockImplementation((cmd: string, _options: unknown | undefined) => { 163 | switch (cmd) { 164 | case 'git add -A': 165 | return Buffer.alloc(0); 166 | case `git commit -m "init with create-stencil v${MOCK_PKG_JSON_VERSION}"`: 167 | throw new Error('git commit has failed for some reason'); 168 | default: 169 | throw new Error(`unmocked command ${cmd}`); 170 | } 171 | }); 172 | expect(commitAllFiles()).toBe(false); 173 | }); 174 | }); 175 | }); 176 | }); 177 | -------------------------------------------------------------------------------- /src/git.ts: -------------------------------------------------------------------------------- 1 | import { execSync } from 'node:child_process'; 2 | 3 | import { log } from '@clack/prompts'; 4 | import { green, red, yellow } from 'colorette'; 5 | import { getPkgVersion } from './version'; 6 | 7 | /** 8 | * Verify that git is available on the user's path. 9 | * 10 | * @returns true if git is available on the user's path, false otherwise 11 | */ 12 | export const hasGit = (): boolean => { 13 | let wasSuccess = false; 14 | try { 15 | // if `git` is not on the user's path, this will return a non-zero exit code 16 | // also returns a non-zero exit code if it times out 17 | execSync('git --version', { stdio: 'ignore' }); 18 | wasSuccess = true; 19 | } catch (err: unknown) { 20 | console.error(err); 21 | } 22 | 23 | return wasSuccess; 24 | }; 25 | 26 | /** 27 | * Check whether the current process is in a git work tree. 28 | * 29 | * This is desirable for detecting cases where a user is creating a directory inside a larger repository (e.g. monorepo) 30 | * 31 | * This function assumes that the process that invokes it is already in the desired directory. It also assumes that git 32 | * is on the user's path. 33 | * 34 | * @returns true if the process is in a git repository already, false otherwise 35 | */ 36 | export const inExistingGitTree = (): boolean => { 37 | let isInTree = false; 38 | try { 39 | // we may be in a subtree of an existing git repository (e.g. a monorepo), this call performs that check. 40 | // this call is expected fail if we are _not_ in an existing repo (I.E we go all the way up the dir tree and can't 41 | // find a git repo) 42 | execSync('git rev-parse --is-inside-work-tree', { stdio: 'ignore' }); 43 | 44 | log.warn(`${yellow('❗')} An existing git repo was detected, a new one will not be created`); 45 | isInTree = true; 46 | } catch (_err: unknown) { 47 | log.success(`${green('✔')} A new git repo was initialized`); 48 | } 49 | return isInTree; 50 | }; 51 | 52 | /** 53 | * Initialize a new git repository for the current working directory of the current process. 54 | * 55 | * This function assumes that the process that invokes it is already in the desired directory and that the repository 56 | * should be created. It also assumes that git is on the user's path. 57 | * 58 | * @returns true if the repository was successfully created, false otherwise 59 | */ 60 | export const initGit = (): boolean => { 61 | let wasSuccess = false; 62 | try { 63 | // init can fail for reasons like a malformed git config, permissions, etc. 64 | execSync('git init', { stdio: 'ignore' }); 65 | wasSuccess = true; 66 | } catch (err: unknown) { 67 | const errorMessage = err instanceof Error ? err.message : 'An unknown error occurred'; 68 | log.error(`${red('❌')} Failed to initialize git repository: ${errorMessage}`); 69 | } 70 | 71 | return wasSuccess; 72 | }; 73 | 74 | /** 75 | * Stage all files and commit them for the current working directory of the current process. 76 | * 77 | * This function assumes that the process that invokes it is in the desired directory. It also assumes that git is on 78 | * the user's path. 79 | * 80 | * @returns true if the files are committed successfully, false otherwise 81 | */ 82 | export const commitAllFiles = (): boolean => { 83 | let wasSuccess = false; 84 | let createStencilVersion = null; 85 | try { 86 | createStencilVersion = ` v${getPkgVersion()}`; 87 | } catch (err: unknown) { 88 | // do nothing - determining the CLI version isn't strictly needed for a commit message 89 | } 90 | 91 | try { 92 | // add all files (including dotfiles) 93 | execSync('git add -A', { stdio: 'ignore' }); 94 | // commit them 95 | const commitMessage = `init with create-stencil${createStencilVersion ?? ''}`; 96 | execSync(`git commit -m "${commitMessage}"`, { stdio: 'ignore' }); 97 | wasSuccess = true; 98 | } catch (err: unknown) { 99 | console.error(err); 100 | } 101 | return wasSuccess; 102 | }; 103 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import { log, intro, outro } from '@clack/prompts'; 2 | 3 | import { createApp } from './create-app'; 4 | import { runInteractive } from './interactive'; 5 | import { getStarterRepo } from './starters'; 6 | import { cleanup, nodeVersionWarning } from './utils'; 7 | import { getPkgVersion } from './version'; 8 | 9 | const USAGE_DOCS = `create-stencil CLI Help 10 | 11 | This CLI has two operation modes, interactive and command mode. 12 | 13 | Interactive Mode Usage: 14 | 15 | npm init stencil 16 | 17 | Command Mode Usage: 18 | 19 | npm init stencil [starter] [project-name] 20 | 21 | General Use Flags: 22 | 23 | --help - show usage examples for the CLI 24 | --info - print the current version of the CLI 25 | 26 | Additional Information: https://github.com/stenciljs/create-stencil 27 | `; 28 | 29 | async function run() { 30 | let args = process.argv.slice(2); 31 | 32 | const autoRun = args.indexOf('--run') >= 0; 33 | const help = args.indexOf('--help') >= 0 || args.indexOf('-h') >= 0; 34 | const info = args.indexOf('--info') >= 0; 35 | 36 | args = args.filter((a) => a[0] !== '-'); 37 | 38 | if (info) { 39 | console.log('create-stencil:', getPkgVersion(), '\n'); 40 | return 0; 41 | } 42 | if (help) { 43 | console.log(USAGE_DOCS); 44 | return 0; 45 | } 46 | 47 | nodeVersionWarning(); 48 | intro('Create Stencil App 🚀'); 49 | 50 | let didError = false; 51 | try { 52 | if (args.length === 2) { 53 | const starterName = args[0]; 54 | const projectName = args[1]; 55 | if (!starterName || !projectName) { 56 | throw new Error(USAGE_DOCS); 57 | } 58 | await createApp(getStarterRepo(starterName), projectName, autoRun); 59 | } else if (args.length < 2) { 60 | await runInteractive(args[0], autoRun); 61 | } else { 62 | throw new Error(USAGE_DOCS); 63 | } 64 | } catch (e) { 65 | didError = true; 66 | log.error(`${e instanceof Error ? e.message : e}`); 67 | outro(`Bye! 👋`); 68 | } 69 | cleanup(didError); 70 | } 71 | 72 | run(); 73 | -------------------------------------------------------------------------------- /src/interactive.ts: -------------------------------------------------------------------------------- 1 | import fs from 'node:fs'; 2 | 3 | import { select, isCancel, cancel, text, confirm, outro } from '@clack/prompts'; 4 | import { dim, bold } from 'colorette'; 5 | 6 | import { verifyStarterExists } from './download'; 7 | import { createApp, prepareStarter } from './create-app'; 8 | import { STARTERS, Starter, getStarterRepo } from './starters'; 9 | 10 | /** 11 | * A prefix for community-driven projects 12 | */ 13 | const COMMUNITY_PREFIX = '[community]'; 14 | 15 | export async function runInteractive(starterName: string | undefined, autoRun: boolean) { 16 | // Get starter's repo 17 | if (!starterName) { 18 | starterName = await askStarterName(); 19 | } 20 | const starter = getStarterRepo(starterName); 21 | 22 | // verify that the starter exists 23 | const starterExist = await verifyStarterExists(starter); 24 | if (!starterExist) { 25 | throw new Error(`Starter template "${starter.repo}" not found`); 26 | } 27 | 28 | // start downloading in the background 29 | prepareStarter(starter); 30 | 31 | // Get project name 32 | const projectName = await askProjectName(); 33 | 34 | // Ask for confirmation 35 | const confirm = await askConfirm(starter, projectName); 36 | if (confirm) { 37 | await createApp(starter, projectName, autoRun); 38 | outro(`Project "${projectName}" created successfully! 🎉`); 39 | } else { 40 | outro(`Aborting!\n\nBye! 👋`); 41 | } 42 | } 43 | 44 | /** 45 | * Prompt the user for the name of a starter project to bootstrap with 46 | * @returns the name of the starter project to use 47 | */ 48 | async function askStarterName(): Promise { 49 | let starterName = await select({ 50 | /** 51 | * the width of this message is intentionally kept to ~80 characters. this is a slightly arbitrary decision to 52 | * prevent one long single line message in wide terminal windows. this _should_ be changeable without any 53 | * negative impact on the code. 54 | */ 55 | message: 'Select a starter project.', 56 | options: [ 57 | ...getChoices(), 58 | { 59 | value: 'custom', 60 | label: 'Type a custom starter', 61 | }, 62 | ], 63 | }); 64 | 65 | if (isCancel(starterName)) { 66 | cancel('Operation cancelled.'); 67 | process.exit(0); 68 | } 69 | 70 | if (starterName === 'custom') { 71 | starterName = await text({ 72 | message: 'Type a custom starter', 73 | placeholder: 'e.g. https://github.com/stencil-community/stencil-app-starter', 74 | }); 75 | } 76 | 77 | if (isCancel(starterName)) { 78 | cancel('Operation cancelled.'); 79 | process.exit(0); 80 | } 81 | 82 | if (!starterName) { 83 | throw new Error(`No starter was provided, try again.`); 84 | } 85 | 86 | return starterName; 87 | } 88 | 89 | /** 90 | * Generate a terminal-friendly list of options for the user to select from 91 | * @returns a formatted list of starter options 92 | */ 93 | function getChoices(): { label: string; value: string }[] { 94 | const maxLength = Math.max(...STARTERS.map((s) => generateStarterName(s).length)) + 1; 95 | return [ 96 | ...STARTERS.filter((s) => s.hidden !== true).map((s) => { 97 | const description = s.description ? dim(s.description) : ''; 98 | return { 99 | label: `${padEnd(generateStarterName(s), maxLength)} ${description}`, 100 | value: s.name, 101 | hint: s.isCommunity ? 'Community-driven starter project' : undefined, 102 | }; 103 | }), 104 | ]; 105 | } 106 | 107 | /** 108 | * Generate the user-displayed name of the starter project 109 | * @param starter the starter project to format 110 | * @returns the formatted name 111 | */ 112 | function generateStarterName(starter: Starter): string { 113 | // ensure that community packages are differentiated from those supported by Ionic/the Stencil team 114 | return starter.isCommunity ? `${starter.name} ${COMMUNITY_PREFIX}` : starter.name; 115 | } 116 | 117 | async function askProjectName() { 118 | const projectName = await text({ 119 | message: 'Project name', 120 | validate: (value) => { 121 | if (!value) { 122 | return 'Project name is required'; 123 | } 124 | if (fs.existsSync(value)) { 125 | return `Project "${value}" name already exists`; 126 | } 127 | }, 128 | }); 129 | 130 | if (isCancel(projectName)) { 131 | cancel('Operation cancelled.'); 132 | process.exit(0); 133 | } 134 | 135 | return projectName; 136 | } 137 | 138 | async function askConfirm(starter: Starter, projectName: string) { 139 | const ok = await confirm({ 140 | message: `Create ${bold(starter.name)} project with name "${projectName}"? 141 | 142 | Confirm?`, 143 | initialValue: true, 144 | }); 145 | 146 | if (isCancel(ok)) { 147 | cancel('Operation cancelled.'); 148 | process.exit(0); 149 | } 150 | 151 | return ok; 152 | } 153 | 154 | function padEnd(str: string, targetLength: number, padString = ' ') { 155 | targetLength = targetLength >> 0; 156 | if (str.length > targetLength) { 157 | return str; 158 | } 159 | 160 | targetLength = targetLength - str.length; 161 | if (targetLength > padString.length) { 162 | padString += padString.repeat(targetLength / padString.length); 163 | } 164 | 165 | return String(str) + padString.slice(0, targetLength); 166 | } 167 | -------------------------------------------------------------------------------- /src/starters.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, it } from 'vitest'; 2 | import { getStarterRepo, Starter } from './starters'; 3 | 4 | describe('starters', () => { 5 | describe('getStarterRepo', () => { 6 | it('returns the correct starter info for a custom repo', () => { 7 | const starterRepo = 'github.com/custom-stencil-starter'; 8 | 9 | const starterInfo = getStarterRepo(starterRepo); 10 | 11 | expect(starterInfo).toEqual({ 12 | name: starterRepo, 13 | repo: starterRepo, 14 | }); 15 | }); 16 | 17 | it('returns a known starter project when specified by name', () => { 18 | const starterInfo = getStarterRepo('component'); 19 | 20 | expect(starterInfo).toEqual({ 21 | description: 'Collection of web components that can be used anywhere', 22 | docs: 'https://github.com/stenciljs/component-starter', 23 | name: 'component', 24 | repo: 'stenciljs/component-starter', 25 | }); 26 | }); 27 | 28 | it('throws an error for an unknown starter project', () => { 29 | expect(() => getStarterRepo('unknown')).toThrow('Starter "unknown" does not exist.'); 30 | }); 31 | }); 32 | }); 33 | -------------------------------------------------------------------------------- /src/starters.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Metadata for a starter project that the CLI will use to bootstrap a user's project. 3 | */ 4 | export interface Starter { 5 | /** 6 | * The name of the starter. 7 | */ 8 | name: string; 9 | /** 10 | * The GitHub repository the starter can be found in. The base URL is assumed to exist and does not need to be 11 | * provided. 12 | */ 13 | repo: string; 14 | /** 15 | * A brief description of the starter project. 16 | */ 17 | description?: string; 18 | /** 19 | * A link to the starter's documentation. 20 | */ 21 | docs?: string; 22 | /** 23 | * When true, the starter should be hidden from the list of possible choices. This allows the `name` to be used with 24 | * the `npm init stencil component ` command, without cluttering the options list. 25 | */ 26 | hidden?: boolean; 27 | /** 28 | * When true, the starter is a community-driven project, rather than one owned by Ionic 29 | */ 30 | isCommunity?: boolean; 31 | } 32 | 33 | /** 34 | * Existing Stencil project starters available for CLI users to select from 35 | */ 36 | export const STARTERS: ReadonlyArray = [ 37 | { 38 | name: 'component', 39 | repo: 'stenciljs/component-starter', 40 | description: 'Collection of web components that can be used anywhere', 41 | docs: 'https://github.com/stenciljs/component-starter', 42 | }, 43 | { 44 | name: 'components', 45 | repo: 'stenciljs/component-starter', 46 | description: 'Collection of web components that can be used anywhere', 47 | docs: 'https://github.com/stenciljs/component-starter', 48 | hidden: true, 49 | }, 50 | { 51 | name: 'app', 52 | repo: 'stencil-community/stencil-app-starter', 53 | description: 'Minimal starter for building a Stencil app or website', 54 | docs: 'https://github.com/stencil-community/stencil-app-starter', 55 | isCommunity: true, 56 | }, 57 | { 58 | name: 'ionic-pwa', 59 | repo: 'stencil-community/stencil-ionic-starter', 60 | description: 'Ionic PWA starter with tabs layout and routes', 61 | docs: 'https://github.com/stencil-community/stencil-ionic-starter', 62 | isCommunity: true, 63 | }, 64 | ]; 65 | 66 | /** 67 | * Retrieve a starter project's metadata based on a CLI user's input. 68 | * 69 | * @param starterName the name of the starter project to retrieve. Starter names that include a forward slash ('/') are 70 | * assumed to be custom starter templates. Such templates are assumed to be the name of the repository that this CLI 71 | * can retrieve the starter template from. 72 | * @returns the starter project metadata 73 | */ 74 | export function getStarterRepo(starterName: string): Starter { 75 | if (starterName.includes('/')) { 76 | return { 77 | name: starterName, 78 | repo: starterName, 79 | }; 80 | } 81 | const repo = STARTERS.find((starter) => starter.name === starterName); 82 | if (!repo) { 83 | throw new Error(`Starter "${starterName}" does not exist.`); 84 | } 85 | return repo; 86 | } 87 | -------------------------------------------------------------------------------- /src/unzip.ts: -------------------------------------------------------------------------------- 1 | import fs from 'fs'; 2 | import path from 'path'; 3 | import * as yauzl from 'yauzl'; 4 | 5 | export function unZipBuffer(buffer: ArrayBuffer, projectName: string) { 6 | return new Promise((resolve, reject) => { 7 | yauzl.fromBuffer(Buffer.from(buffer), { lazyEntries: true }, handleZipFile(projectName, resolve, reject)); 8 | }); 9 | } 10 | 11 | function handleZipFile(projectName: string, resolve: any, reject: any) { 12 | return (err: any, zipfile: any) => { 13 | if (err) { 14 | throw err; 15 | } 16 | 17 | // track when we've closed all our file handles 18 | zipfile.readEntry(); 19 | zipfile.on('entry', (entry: any) => { 20 | const segments = entry.fileName.split('/'); 21 | segments[0] = projectName; 22 | const fileName = segments.join(path.sep); 23 | 24 | if (fileName[fileName.length - 1] === path.sep) { 25 | // Directory file names end with '/'. 26 | // Note that entires for directories themselves are optional. 27 | // An entry's fileName implicitly requires its parent directories to exist. 28 | zipfile.readEntry(); 29 | } else { 30 | // ensure parent directory exists 31 | mkdirp(path.dirname(fileName), () => { 32 | zipfile.openReadStream(entry, (errL: any, readStream: any) => { 33 | if (errL) { 34 | throw errL; 35 | } 36 | readStream.on('end', () => { 37 | zipfile.readEntry(); 38 | }); 39 | // pump file contents 40 | readStream.pipe(fs.createWriteStream(fileName)); 41 | }); 42 | }); 43 | } 44 | }); 45 | zipfile.once('error', reject); 46 | zipfile.once('end', () => { 47 | resolve(); 48 | }); 49 | }; 50 | } 51 | 52 | function mkdirp(dir: string, cb: any) { 53 | if (dir === '.') return cb(); 54 | fs.stat(dir, (err) => { 55 | if (err == null) return cb(); // already exists 56 | 57 | const parent = path.dirname(dir); 58 | mkdirp(parent, () => { 59 | fs.mkdir(dir, cb); 60 | }); 61 | }); 62 | } 63 | -------------------------------------------------------------------------------- /src/utils.test.ts: -------------------------------------------------------------------------------- 1 | import fs from 'node:fs'; 2 | import { spawn } from 'node:child_process'; 3 | 4 | import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; 5 | import { 6 | setTmpDirectory, 7 | cleanup, 8 | npm, 9 | rimraf, 10 | onlyUnix, 11 | printDuration, 12 | isWin, 13 | terminalPrompt, 14 | nodeVersionWarning, 15 | getPackageJson, 16 | } from './utils'; 17 | 18 | // Mock the fs module 19 | vi.mock('node:fs', () => ({ 20 | default: { 21 | existsSync: vi.fn(), 22 | readdirSync: vi.fn(), 23 | lstatSync: vi.fn(), 24 | unlinkSync: vi.fn(), 25 | rmdirSync: vi.fn(), 26 | readFileSync: vi.fn(), 27 | }, 28 | })); 29 | 30 | // Mock child_process 31 | vi.mock('node:child_process', () => ({ 32 | spawn: vi.fn(), 33 | })); 34 | 35 | describe('utils', () => { 36 | beforeEach(() => { 37 | vi.clearAllMocks(); 38 | }); 39 | 40 | afterEach(() => { 41 | vi.resetModules(); 42 | }); 43 | 44 | describe('setTmpDirectory', () => { 45 | it('should set tmp directory and register cleanup handlers', () => { 46 | const processOn = vi.spyOn(process, 'once'); 47 | setTmpDirectory('/tmp/test'); 48 | expect(processOn).toHaveBeenCalledTimes(4); 49 | }); 50 | }); 51 | 52 | describe('cleanup', () => { 53 | it('should cleanup and exit with code 0 on success', () => { 54 | const exitSpy = vi.spyOn(process, 'exit').mockImplementation(() => undefined as never); 55 | vi.useFakeTimers(); 56 | 57 | cleanup(false); 58 | vi.runAllTimers(); 59 | 60 | expect(exitSpy).toHaveBeenCalledWith(0); 61 | }); 62 | 63 | it('should cleanup and exit with code 1 on error', () => { 64 | const exitSpy = vi.spyOn(process, 'exit').mockImplementation(() => undefined as never); 65 | vi.useFakeTimers(); 66 | 67 | cleanup(true); 68 | vi.runAllTimers(); 69 | 70 | expect(exitSpy).toHaveBeenCalledWith(1); 71 | }); 72 | }); 73 | 74 | describe('npm', () => { 75 | it('should spawn npm process with correct arguments', async () => { 76 | const mockSpawn = vi.mocked(spawn); 77 | const mockProcess: any = { 78 | once: vi.fn().mockImplementation((event, cb) => { 79 | if (event === 'exit') cb(); 80 | return mockProcess; 81 | }), 82 | }; 83 | mockSpawn.mockReturnValue(mockProcess); 84 | 85 | await npm('install', '/project/path'); 86 | 87 | expect(mockSpawn).toHaveBeenCalledWith( 88 | 'npm', 89 | ['install'], 90 | expect.objectContaining({ 91 | shell: true, 92 | stdio: 'ignore', 93 | cwd: '/project/path', 94 | }), 95 | ); 96 | }); 97 | }); 98 | 99 | describe('rimraf', () => { 100 | it('should remove directory recursively', () => { 101 | vi.mocked(fs.existsSync).mockReturnValue(true); 102 | vi.mocked(fs.readdirSync) 103 | .mockReturnValueOnce(['file1', 'dir1'] as unknown as fs.Dirent[]) 104 | .mockReturnValueOnce([]); 105 | vi.mocked(fs.lstatSync).mockImplementation( 106 | (path) => 107 | ({ 108 | isDirectory: () => (path as string).endsWith('dir1'), 109 | }) as any, 110 | ); 111 | 112 | rimraf('/test/dir'); 113 | 114 | expect(fs.unlinkSync).toHaveBeenCalled(); 115 | expect(fs.rmdirSync).toHaveBeenCalled(); 116 | }); 117 | }); 118 | 119 | describe('onlyUnix', () => { 120 | it('should return empty string on Windows', () => { 121 | vi.spyOn(process, 'platform', 'get').mockReturnValue('win32'); 122 | expect(onlyUnix('test')).toBe('test'); 123 | }); 124 | 125 | it('should return string on Unix', () => { 126 | vi.spyOn(process, 'platform', 'get').mockReturnValue('darwin'); 127 | expect(onlyUnix('test')).toBe(''); 128 | }); 129 | }); 130 | 131 | describe('printDuration', () => { 132 | it('should format duration in seconds', () => { 133 | expect(printDuration(1500)).toBe('in 1.50 s'); 134 | }); 135 | 136 | it('should format duration in milliseconds', () => { 137 | expect(printDuration(500)).toBe('in 500 ms'); 138 | }); 139 | }); 140 | 141 | describe('isWin', () => { 142 | it('should detect Windows platform', () => { 143 | vi.spyOn(process, 'platform', 'get').mockReturnValue('win32'); 144 | expect(isWin()).toBe(true); 145 | }); 146 | 147 | it('should detect non-Windows platform', () => { 148 | vi.spyOn(process, 'platform', 'get').mockReturnValue('darwin'); 149 | expect(isWin()).toBe(false); 150 | }); 151 | }); 152 | 153 | describe('terminalPrompt', () => { 154 | it('should return ">" on Windows', () => { 155 | vi.spyOn(process, 'platform', 'get').mockReturnValue('win32'); 156 | expect(terminalPrompt()).toBe('>'); 157 | }); 158 | 159 | it('should return "$" on Unix', () => { 160 | vi.spyOn(process, 'platform', 'get').mockReturnValue('darwin'); 161 | expect(terminalPrompt()).toBe('$'); 162 | }); 163 | }); 164 | 165 | describe('nodeVersionWarning', () => { 166 | const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); 167 | 168 | it('should show warning for Node version < 10', () => { 169 | vi.spyOn(process, 'version', 'get').mockReturnValue('v8.0.0'); 170 | nodeVersionWarning(); 171 | expect(consoleSpy).toHaveBeenCalled(); 172 | }); 173 | 174 | it('should not show warning for Node version >= 10', () => { 175 | vi.spyOn(process, 'version', 'get').mockReturnValue('v10.0.0'); 176 | nodeVersionWarning(); 177 | expect(consoleSpy).not.toHaveBeenCalled(); 178 | }); 179 | }); 180 | 181 | describe('getPackageJson', () => { 182 | it('should read and parse package.json', () => { 183 | const mockData = { name: 'test-package' }; 184 | vi.mocked(fs.readFileSync).mockReturnValue(JSON.stringify(mockData)); 185 | 186 | expect(getPackageJson()).toEqual(mockData); 187 | expect(fs.readFileSync).toHaveBeenCalledWith(expect.stringContaining('package.json')); 188 | }); 189 | }); 190 | }); 191 | -------------------------------------------------------------------------------- /src/utils.ts: -------------------------------------------------------------------------------- 1 | import fs from 'node:fs'; 2 | import url from 'node:url'; 3 | import path from 'node:path'; 4 | import { ChildProcess, spawn } from 'node:child_process'; 5 | import { yellow } from 'colorette'; 6 | 7 | const __dirname = path.dirname(url.fileURLToPath(import.meta.url)); 8 | 9 | const childrenProcesses: ChildProcess[] = []; 10 | let tmpDirectory: string | null = null; 11 | 12 | export function setTmpDirectory(dir: string | null) { 13 | tmpDirectory = dir; 14 | if (dir) { 15 | rimraf(dir); 16 | process.once('uncaughtException', () => cleanup(true)); 17 | process.once('exit', () => cleanup()); 18 | process.once('SIGINT', () => cleanup()); 19 | process.once('SIGTERM', () => cleanup()); 20 | } 21 | } 22 | 23 | export function cleanup(didError = false) { 24 | if (tmpDirectory) { 25 | killChildren(); 26 | } 27 | setTimeout(() => { 28 | if (tmpDirectory) { 29 | rimraf(tmpDirectory); 30 | tmpDirectory = null; 31 | } 32 | process.exit(didError ? 1 : 0); 33 | }, 200); 34 | } 35 | 36 | export function killChildren() { 37 | childrenProcesses.forEach((p) => p.kill('SIGINT')); 38 | } 39 | 40 | export function npm(command: string, projectPath: string, stdio: any = 'ignore') { 41 | return new Promise((resolve, reject) => { 42 | const p = spawn('npm', [command], { 43 | shell: true, 44 | stdio, 45 | cwd: projectPath, 46 | }); 47 | p.once('exit', () => resolve()); 48 | p.once('error', reject); 49 | childrenProcesses.push(p); 50 | }); 51 | } 52 | 53 | export function rimraf(dir_path: string) { 54 | if (fs.existsSync(dir_path)) { 55 | fs.readdirSync(dir_path).forEach((entry) => { 56 | const entry_path = path.join(dir_path, entry); 57 | if (fs.lstatSync(entry_path).isDirectory()) { 58 | rimraf(entry_path); 59 | } else { 60 | fs.unlinkSync(entry_path); 61 | } 62 | }); 63 | fs.rmdirSync(dir_path); 64 | } 65 | } 66 | 67 | export function onlyUnix(str: string) { 68 | return isWin() ? str : ''; 69 | } 70 | 71 | export function printDuration(duration: number) { 72 | if (duration > 1000) { 73 | return `in ${(duration / 1000).toFixed(2)} s`; 74 | } else { 75 | const ms = parseFloat(duration.toFixed(3)); 76 | return `in ${ms} ms`; 77 | } 78 | } 79 | 80 | export function isWin() { 81 | return process.platform === 'win32'; 82 | } 83 | 84 | export function terminalPrompt() { 85 | return isWin() ? '>' : '$'; 86 | } 87 | 88 | export function nodeVersionWarning() { 89 | try { 90 | const v = process.version.replace('v', '').split('.'); 91 | // assume a major version number of '0' if for some reason the major version is parsed as `undefined` 92 | const major = parseInt(v[0] ?? '0', 10); 93 | if (major < 10) { 94 | console.log( 95 | yellow( 96 | `Your current version of Node is ${process.version}, however the recommendation is a minimum of Node v10. Note that future versions of Stencil will eventually remove support for non-LTS Node versions.`, 97 | ), 98 | ); 99 | } 100 | } catch (e) {} 101 | } 102 | 103 | /** 104 | * Returns the result of attempting to read and parse the project's `package.json` 105 | */ 106 | export const getPackageJson = () => { 107 | const packageJsonPath = path.join(__dirname, 'package.json'); 108 | return JSON.parse(fs.readFileSync(packageJsonPath).toString()); 109 | }; 110 | -------------------------------------------------------------------------------- /src/version.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, it, expect, afterEach, vi } from 'vitest'; 2 | 3 | import { getPkgVersion } from './version'; 4 | import { getPackageJson } from './utils'; 5 | 6 | vi.mock('./utils', () => ({ 7 | getPackageJson: vi.fn(), 8 | })); 9 | 10 | describe('version', () => { 11 | describe('getPkgVersion', () => { 12 | afterEach(() => { 13 | vi.mocked(getPackageJson).mockRestore(); 14 | }); 15 | 16 | it('throws if package.json cannot be found', () => { 17 | vi.mocked(getPackageJson).mockImplementation(() => null); 18 | expect(() => getPkgVersion()).toThrow('the version of this package could not be determined'); 19 | }); 20 | 21 | it('throws if the version number cannot be found in package.json', () => { 22 | vi.mocked(getPackageJson).mockImplementation(() => ({})); 23 | expect(() => getPkgVersion()).toThrow('the version of this package could not be determined'); 24 | }); 25 | 26 | it('returns the version number found in package.json', () => { 27 | vi.mocked(getPackageJson).mockImplementation(() => ({ version: '0.0.0' })); 28 | expect(getPkgVersion()).toBe('0.0.0'); 29 | }); 30 | }); 31 | }); 32 | -------------------------------------------------------------------------------- /src/version.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Pull the current version of the CLI from the project's `package.json` file. 3 | * 4 | * @returns the current version of this CLI 5 | */ 6 | import { getPackageJson } from './utils'; 7 | 8 | export function getPkgVersion(): string { 9 | let packageJson: any = null; 10 | 11 | try { 12 | packageJson = getPackageJson(); 13 | } catch (e) { 14 | // do nothing, we'll check that the package.json file could be found 15 | // and that it has a version field in the same check 16 | } 17 | 18 | if (!packageJson || !packageJson.version) { 19 | throw 'the version of this package could not be determined'; 20 | } 21 | 22 | return packageJson.version; 23 | } 24 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "allowJs": true, 4 | "allowSyntheticDefaultImports": true, 5 | "declaration": false, 6 | "exactOptionalPropertyTypes": true, 7 | "experimentalDecorators": true, 8 | "emitDecoratorMetadata": true, 9 | "module": "esnext", 10 | "moduleResolution": "bundler", 11 | "noEmitOnError": false, 12 | "noImplicitOverride": true, 13 | "noPropertyAccessFromIndexSignature": true, 14 | "noUncheckedIndexedAccess": true, 15 | "outDir": "dist/src", 16 | "sourceMap": false, 17 | "strict": true, 18 | "target": "es2022", 19 | "useUnknownInCatchVariables": true 20 | }, 21 | "files": ["src/index.ts"] 22 | } 23 | -------------------------------------------------------------------------------- /vitest.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vitest/config'; 2 | 3 | export default defineConfig({ 4 | test: { 5 | coverage: { 6 | enabled: true, 7 | provider: 'v8', 8 | exclude: ['index.js', 'vitest.config.ts', 'rollup.config.mjs', 'dist'], 9 | thresholds: { 10 | branches: 80, 11 | functions: 80, 12 | lines: 39, 13 | statements: 39, 14 | }, 15 | }, 16 | }, 17 | }); 18 | --------------------------------------------------------------------------------