├── .changeset ├── README.md ├── config.json └── renovate-3c877b4.md ├── .editorconfig ├── .github ├── ISSUE_TEMPLATE │ ├── bug_report.md │ └── config.yml ├── PULL_REQUEST_TEMPLATE.md ├── renovate.json └── workflows │ ├── continuous_delivery.yml │ ├── continuous_integration.yml │ ├── conventional_commit.yml │ ├── dependency_changelog.yml │ └── workflow.yml ├── .gitignore ├── .npmrc ├── .nvmrc ├── .vscode ├── extensions.json └── settings.json ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── eslint.config.js ├── examples ├── command │ ├── package.json │ ├── src │ │ └── index.ts │ └── tsconfig.json ├── default │ ├── package.json │ ├── src │ │ └── index.ts │ └── tsconfig.json ├── empty │ ├── package.json │ ├── src │ │ └── index.ts │ └── tsconfig.json ├── input │ ├── package.json │ ├── src │ │ └── index.ts │ └── tsconfig.json ├── option │ ├── package.json │ ├── src │ │ └── index.ts │ └── tsconfig.json └── task │ ├── package.json │ ├── src │ └── index.ts │ └── tsconfig.json ├── package.json ├── pnpm-lock.yaml ├── pnpm-workspace.yaml ├── termost ├── CHANGELOG.md ├── README.md ├── package.json ├── src │ ├── __snapshots__ │ │ └── index.test.ts.snap │ ├── api │ │ ├── command │ │ │ ├── command.ts │ │ │ ├── controller │ │ │ │ ├── index.ts │ │ │ │ └── queue.ts │ │ │ └── index.ts │ │ ├── input │ │ │ └── index.ts │ │ ├── option │ │ │ └── index.ts │ │ └── task │ │ │ └── index.ts │ ├── helpers │ │ ├── process │ │ │ ├── index.test.ts │ │ │ └── index.ts │ │ ├── stdin │ │ │ ├── index.test.ts │ │ │ └── index.ts │ │ └── stdout │ │ │ └── index.ts │ ├── index.test.ts │ ├── index.ts │ ├── termost.ts │ └── types.ts └── tsconfig.json ├── tsconfig.json └── turbo.json /.changeset/README.md: -------------------------------------------------------------------------------- 1 | # Changesets 2 | 3 | Hello and welcome! This folder has been automatically generated by `@changesets/cli`, a build tool that works 4 | with multi-package repos, or single-package repos to help you version and publish your code. You can 5 | find the full documentation for it [in our repository](https://github.com/changesets/changesets) 6 | 7 | We have a quick list of common questions to get you started engaging with this project in 8 | [our documentation](https://github.com/changesets/changesets/blob/main/docs/common-questions.md) 9 | -------------------------------------------------------------------------------- /.changeset/config.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://raw.githubusercontent.com/changesets/changesets/main/packages/config/schema.json", 3 | "access": "public", 4 | "baseBranch": "main", 5 | "changelog": ["@changesets/changelog-github", { "repo": "adbayb/termost" }], 6 | "commit": false, 7 | "ignore": ["@examples/*"], 8 | "updateInternalDependencies": "patch", 9 | "___experimentalUnsafeOptions_WILL_CHANGE_IN_PATCH": { 10 | "onlyUpdatePeerDependentsWhenOutOfRange": true, 11 | "updateInternalDependents": "out-of-range" 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /.changeset/renovate-3c877b4.md: -------------------------------------------------------------------------------- 1 | --- 2 | "termost": minor 3 | --- 4 | 5 | Updated dependency `@types/node` to `22.15.3`. 6 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_size = 4 5 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: "🐛 Bug Report" 3 | about: Report a reproducible bug or regression. 4 | title: "Bug: " 5 | labels: ["bug", "triage"] 6 | --- 7 | 8 | ## Description 9 | 10 | 14 | 15 | ## Reproduction 16 | 17 | 20 | 21 | ## Environment 22 | 23 | 29 | 30 | ## Additional context 31 | 32 | 35 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | blank_issues_enabled: false 2 | contact_links: 3 | - name: 🤔 Questions and Help 4 | about: Issues are dedicated to bugs, if you have any question, please use the dedicated GitHub discussion category. 5 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | 11 | 12 | ## Description 13 | 14 | Please include a concise summary of the change with the relevant context and main highlights: 15 | 16 | - [ ] What I've done 1 17 | - [ ] What I've done 2... 18 | 19 | ## Screenshots 20 | 21 | | Before | After | 22 | | ------ | ----- | 23 | | Image | Image | 24 | 25 | ## Resources 26 | 27 | - Issue (GitHub, ...) 28 | - Specification (ADRs, RFCs, ...) 29 | - ... 30 | -------------------------------------------------------------------------------- /.github/renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://docs.renovatebot.com/renovate-schema.json", 3 | "extends": [ 4 | ":automergeAll", 5 | ":automergePr", 6 | ":automergeRequireAllStatusChecks", 7 | ":enableVulnerabilityAlerts", 8 | ":label(dependencies)", 9 | ":maintainLockFilesMonthly", 10 | ":prConcurrentLimit10", 11 | ":rebaseStalePrs", 12 | ":semanticCommits", 13 | ":semanticCommitScopeDisabled", 14 | ":semanticPrefixFixDepsChoreOthers", 15 | ":timezone(Europe/Paris)", 16 | "npm:unpublishSafe", 17 | "replacements:all", 18 | "schedule:monthly", 19 | "workarounds:all" 20 | ], 21 | "commitBodyTable": true, 22 | "platformAutomerge": false, 23 | "ignoreDeps": [], 24 | "packageRules": [ 25 | { 26 | "matchPackagePatterns": ["*"], 27 | "rangeStrategy": "auto" 28 | }, 29 | { 30 | "matchDepTypes": ["devDependencies"], 31 | "rangeStrategy": "pin" 32 | }, 33 | { 34 | "matchDepTypes": ["dependencies"], 35 | "rangeStrategy": "bump" 36 | }, 37 | { 38 | "description": "Synchronize CircleCI Docker image version with the Node.js one", 39 | "matchPackageNames": ["cimg/node"], 40 | "versioning": "node" 41 | }, 42 | { 43 | "groupName": "package dependencies", 44 | "matchManagers": ["npm"], 45 | "matchDepTypes": [ 46 | "dependencies", 47 | "devDependencies", 48 | "optionalDependencies", 49 | "peerDependencies" 50 | ] 51 | }, 52 | { 53 | "groupName": "infrastructure dependencies", 54 | "matchManagers": [ 55 | "circleci", 56 | "github-actions", 57 | "dockerfile", 58 | "terraform", 59 | "terraform-version", 60 | "docker-compose", 61 | "kubernetes" 62 | ] 63 | }, 64 | { 65 | "groupName": "engine dependencies", 66 | "matchPackagePatterns": ["node", "pnpm"] 67 | } 68 | ] 69 | } 70 | -------------------------------------------------------------------------------- /.github/workflows/continuous_delivery.yml: -------------------------------------------------------------------------------- 1 | name: Continous delivery 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | 8 | concurrency: ${{ github.workflow }}-${{ github.ref }} 9 | 10 | jobs: 11 | integrate: 12 | uses: ./.github/workflows/workflow.yml 13 | release: 14 | timeout-minutes: 5 15 | needs: integrate 16 | runs-on: ubuntu-latest 17 | permissions: 18 | contents: write 19 | pull-requests: write 20 | steps: 21 | - uses: actions/checkout@v4 22 | - uses: pnpm/action-setup@v4 23 | - name: Get node version 24 | run: echo "version=$(cat .nvmrc)" >> $GITHUB_OUTPUT 25 | id: node 26 | - name: Setup node ${{ steps.node.outputs.version }} 27 | uses: actions/setup-node@v4 28 | with: 29 | node-version: ${{ steps.node.outputs.version }} 30 | cache: pnpm 31 | - name: Setup .npmrc 32 | run: | 33 | cat << EOF > "$HOME/.npmrc" 34 | //registry.npmjs.org/:_authToken=$NPM_TOKEN 35 | EOF 36 | env: 37 | NPM_TOKEN: ${{ secrets.NPM_TOKEN }} 38 | - name: Install dependencies 39 | run: pnpm install --frozen-lockfile 40 | - name: Publish pre-release version(s) 41 | if: "!contains(github.event.head_commit.message, 'chore: release package(s)')" 42 | run: | 43 | pnpm --filter=\!@examples/\* --recursive exec pnpm version "$(pnpm show ./ version)-next-${GITHUB_SHA::7}" 44 | pnpm --filter=\!@examples/\* --recursive exec pnpm publish --tag next --no-git-checks 45 | - name: Create release pull request 46 | if: "!contains(github.event.head_commit.message, 'chore: release package(s)')" 47 | uses: changesets/action@v1 48 | with: 49 | commit: "chore: release package(s)" 50 | title: "chore: release package(s)" 51 | env: 52 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 53 | - name: Publish stable version(s) 54 | if: "contains(github.event.head_commit.message, 'chore: release package(s)')" 55 | uses: changesets/action@v1 56 | with: 57 | version: pnpm release:version 58 | publish: pnpm release:publish 59 | env: 60 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 61 | NPM_TOKEN: ${{ secrets.NPM_TOKEN }} 62 | -------------------------------------------------------------------------------- /.github/workflows/continuous_integration.yml: -------------------------------------------------------------------------------- 1 | name: Continuous integration 2 | 3 | on: 4 | push: 5 | branches-ignore: main 6 | 7 | jobs: 8 | main: 9 | uses: ./.github/workflows/workflow.yml 10 | -------------------------------------------------------------------------------- /.github/workflows/conventional_commit.yml: -------------------------------------------------------------------------------- 1 | name: Conventional commit 2 | 3 | on: 4 | pull_request: 5 | types: [opened, edited] 6 | 7 | jobs: 8 | main: 9 | timeout-minutes: 5 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: amannn/action-semantic-pull-request@v5 13 | id: check_pr_rule 14 | env: 15 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 16 | with: 17 | types: | 18 | build 19 | chore 20 | ci 21 | docs 22 | feat 23 | fix 24 | perf 25 | refactor 26 | revert 27 | style 28 | test 29 | requireScope: false 30 | subjectPattern: ^(?![A-Z]).+$ 31 | subjectPatternError: The subject must start with a lowercase character 32 | # Create a sticky comment to display the detailed error 33 | - uses: marocchino/sticky-pull-request-comment@v2 34 | if: always() && (steps.check_pr_rule.outputs.error_message != null) 35 | with: 36 | header: check_pr_comment 37 | message: | 38 | Pull request titles must follow the [Conventional Commits specification](https://www.conventionalcommits.org/). 39 | Please adjust your title following: 40 | ``` 41 | ${{ steps.check_pr_rule.outputs.error_message }} 42 | ``` 43 | # Delete a previous comment when the issue has been resolved 44 | - if: ${{ steps.check_pr_rule.outputs.error_message == null }} 45 | uses: marocchino/sticky-pull-request-comment@v2 46 | with: 47 | header: check_pr_comment 48 | delete: true 49 | -------------------------------------------------------------------------------- /.github/workflows/dependency_changelog.yml: -------------------------------------------------------------------------------- 1 | name: Dependency changelog 2 | 3 | on: 4 | pull_request_target: 5 | paths: 6 | - "**/pnpm-lock.yaml" 7 | 8 | jobs: 9 | main: 10 | timeout-minutes: 5 11 | runs-on: ubuntu-latest 12 | if: github.actor == 'renovate[bot]' 13 | steps: 14 | - name: Checkout 15 | uses: actions/checkout@v4 16 | with: 17 | fetch-depth: 2 18 | ref: ${{ github.head_ref }} 19 | - name: Configure Git 20 | run: | 21 | git config --global user.email adbayb@gmail.com 22 | git config --global user.name 'Ayoub Adib' 23 | - name: Create changelog entry 24 | uses: actions/github-script@v7 25 | # Credits to https://github.com/backstage/backstage/blob/bcc11651add5a2589898dfb9d665ebb9d412201b/.github/workflows/sync_renovate-changesets.yml 26 | with: 27 | script: | 28 | const { promises: fs } = require("fs"); 29 | 30 | async function getPackagesNames(files) { 31 | const names = []; 32 | 33 | for (const file of files) { 34 | const data = JSON.parse(await fs.readFile(file, "utf8")); 35 | 36 | if (!data.private) { 37 | names.push(data.name); 38 | } 39 | } 40 | 41 | return names; 42 | } 43 | 44 | async function createChangeset(fileName, packageBumps, packages) { 45 | let message = ""; 46 | 47 | for (const [pkg, bump] of packageBumps) { 48 | message = message + `Updated dependency \`${pkg}\` to \`${bump}\`.\n`; 49 | } 50 | 51 | const pkgs = packages.map((pkg) => `"${pkg}": minor`).join("\n"); 52 | const body = `---\n${pkgs}\n---\n\n${message.trim()}\n`; 53 | 54 | await fs.writeFile(fileName, body); 55 | } 56 | 57 | async function getBumps(files) { 58 | const bumps = new Map(); 59 | 60 | for (const file of files) { 61 | const { stdout: changes } = await exec.getExecOutput("git", [ 62 | "show", 63 | file, 64 | ]); 65 | 66 | for (const change of changes.split("\n")) { 67 | if (!change.match(/^\+\s/)) { 68 | continue; 69 | } 70 | 71 | const match = change.match(/"(.*?)"/g); 72 | 73 | bumps.set(match[0].replace(/"/g, ""), match[1].replace(/"/g, "")); 74 | } 75 | } 76 | 77 | return bumps; 78 | } 79 | 80 | const branch = await exec.getExecOutput("git branch --show-current"); 81 | 82 | if (!branch.stdout.startsWith("renovate/")) { 83 | console.log("Not a renovate branch, skipping"); 84 | 85 | return; 86 | } 87 | 88 | const diffOutput = await exec.getExecOutput("git diff --name-only HEAD~1"); 89 | const diffFiles = diffOutput.stdout.split("\n"); 90 | 91 | if (diffFiles.find((f) => f.startsWith(".changeset"))) { 92 | console.log("Changeset already exists, skipping"); 93 | 94 | return; 95 | } 96 | 97 | const files = diffFiles 98 | .filter((file) => file !== "package.json") // skip root package.json 99 | .filter((file) => file.includes("package.json")); 100 | const packageNames = await getPackagesNames(files); 101 | 102 | if (!packageNames.length) { 103 | console.log("No package.json changes to published packages, skipping"); 104 | 105 | return; 106 | } 107 | 108 | const { stdout: shortHash } = await exec.getExecOutput( 109 | "git rev-parse --short HEAD" 110 | ); 111 | const fileName = `.changeset/renovate-${shortHash.trim()}.md`; 112 | const packageBumps = await getBumps(files); 113 | 114 | await createChangeset(fileName, packageBumps, packageNames); 115 | await exec.exec("git", ["add", fileName]); 116 | await exec.exec("git commit -C HEAD --amend --no-edit"); 117 | await exec.exec("git push --force"); 118 | -------------------------------------------------------------------------------- /.github/workflows/workflow.yml: -------------------------------------------------------------------------------- 1 | name: Main shareable workflow 2 | 3 | on: 4 | workflow_call: 5 | 6 | jobs: 7 | main: 8 | timeout-minutes: 5 9 | runs-on: ubuntu-latest 10 | steps: 11 | - name: Checkout the code 12 | uses: actions/checkout@v4 13 | - uses: pnpm/action-setup@v4 14 | - name: Get node version 15 | run: echo "version=$(cat .nvmrc)" >> $GITHUB_OUTPUT 16 | id: node 17 | - name: Setup node ${{ steps.node.outputs.version }} 18 | uses: actions/setup-node@v4 19 | with: 20 | node-version: ${{ steps.node.outputs.version }} 21 | cache: pnpm 22 | - name: Setup cache 23 | id: cache 24 | uses: actions/cache@v4 25 | with: 26 | path: | 27 | ./node_modules 28 | ./turbo 29 | key: ${{ runner.os }}-cache-${{ github.sha }} 30 | restore-keys: | 31 | ${{ runner.os }}-cache- 32 | - name: Install dependencies 33 | run: pnpm install --frozen-lockfile 34 | - name: Build 35 | run: pnpm build 36 | - name: Check (static analysis including linters, types, and commit message) 37 | run: pnpm check 38 | - name: Test 39 | run: pnpm test 40 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | lerna-debug.log* 8 | .pnpm-debug.log* 9 | 10 | # Diagnostic reports (https://nodejs.org/api/report.html) 11 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 12 | 13 | # Runtime data 14 | pids 15 | *.pid 16 | *.seed 17 | *.pid.lock 18 | 19 | # Directory for instrumented libs generated by jscoverage/JSCover 20 | lib-cov 21 | 22 | # Coverage directory used by tools like istanbul 23 | coverage 24 | *.lcov 25 | 26 | # nyc test coverage 27 | .nyc_output 28 | 29 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 30 | .grunt 31 | 32 | # Bower dependency directory (https://bower.io/) 33 | bower_components 34 | 35 | # node-waf configuration 36 | .lock-wscript 37 | 38 | # Compiled binary addons (https://nodejs.org/api/addons.html) 39 | build/Release 40 | 41 | # Dependency directories 42 | node_modules/ 43 | jspm_packages/ 44 | 45 | # Snowpack dependency directory (https://snowpack.dev/) 46 | web_modules/ 47 | 48 | # TypeScript cache 49 | *.tsbuildinfo 50 | 51 | # Optional npm cache directory 52 | .npm 53 | 54 | # Optional eslint cache 55 | .eslintcache 56 | 57 | # Optional stylelint cache 58 | .stylelintcache 59 | 60 | # Microbundle cache 61 | .rpt2_cache/ 62 | .rts2_cache_cjs/ 63 | .rts2_cache_es/ 64 | .rts2_cache_umd/ 65 | 66 | # Optional REPL history 67 | .node_repl_history 68 | 69 | # Output of 'npm pack' 70 | *.tgz 71 | 72 | # Yarn Integrity file 73 | .yarn-integrity 74 | 75 | # dotenv environment variable files 76 | .env 77 | .env.development.local 78 | .env.test.local 79 | .env.production.local 80 | .env.local 81 | 82 | # parcel-bundler cache (https://parceljs.org/) 83 | .cache 84 | .parcel-cache 85 | 86 | # Next.js build output 87 | .next 88 | out 89 | 90 | # Nuxt.js build / generate output 91 | .nuxt 92 | dist 93 | 94 | # Gatsby files 95 | .cache/ 96 | # Comment in the public line in if your project uses Gatsby and not Next.js 97 | # https://nextjs.org/blog/next-9-1#public-directory-support 98 | # public 99 | 100 | # vuepress build output 101 | .vuepress/dist 102 | 103 | # vuepress v2.x temp and cache directory 104 | .temp 105 | .cache 106 | 107 | # Docusaurus cache and generated files 108 | .docusaurus 109 | 110 | # Serverless directories 111 | .serverless/ 112 | 113 | # FuseBox cache 114 | .fusebox/ 115 | 116 | # DynamoDB Local files 117 | .dynamodb/ 118 | 119 | # TernJS port file 120 | .tern-port 121 | 122 | # Stores VSCode versions used for testing VSCode extensions 123 | .vscode-test 124 | 125 | # yarn v2 126 | .yarn/cache 127 | .yarn/unplugged 128 | .yarn/build-state.yml 129 | .yarn/install-state.gz 130 | .pnp.* 131 | 132 | # OS files 133 | .DS_Store 134 | .turbo 135 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | save-exact=true 2 | public-hoist-pattern[]=*changesets* 3 | public-hoist-pattern[]=*commitlint* 4 | public-hoist-pattern[]=*eslint* 5 | public-hoist-pattern[]=*prettier* 6 | public-hoist-pattern[]=*turbo* 7 | public-hoist-pattern[]=*typescript* 8 | public-hoist-pattern[]=*types* 9 | -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | 22.15.0 2 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": [ 3 | "dbaeumer.vscode-eslint", 4 | "unifiedjs.vscode-mdx", 5 | "yoavbls.pretty-ts-errors", 6 | "znck.grammarly" 7 | ] 8 | } 9 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "eslint.validate": [ 3 | "javascript", 4 | "javascriptreact", 5 | "typescript", 6 | "typescriptreact", 7 | "markdown", 8 | "mdx", 9 | "astro" 10 | ], 11 | "editor.codeActionsOnSave": { 12 | "source.fixAll.eslint": "explicit" 13 | }, 14 | "workbench.editor.labelFormat": "short", 15 | "typescript.tsdk": "node_modules/typescript/lib", 16 | "typescript.enablePromptUseWorkspaceTsdk": true, 17 | "grammarly.selectors": [ 18 | { 19 | "language": "mdx", 20 | "scheme": "file" 21 | }, 22 | { 23 | "language": "md", 24 | "scheme": "file" 25 | } 26 | ] 27 | } 28 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | Thanks for showing interest to contribute to `termost` 🥳. 4 | We are open to contributions, we look forward to improving it with your help! 5 | 6 | Here you will find guidelines that will help you to know how to contribute to this repository. 7 | It can be about reporting an issue, proposing a bug fix, adding some features, and even more... 8 | 9 | ## 🥇 Your first contribution 10 | 11 | TODO 12 | 13 | ## 🗂️ Conventions 14 | 15 | TODO 16 | 17 | ## 👨‍🍳 Recipes 18 | 19 | ### How to XXX? 20 | 21 | TODO 22 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Ayoub Adib 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 | ./termost/README.md -------------------------------------------------------------------------------- /eslint.config.js: -------------------------------------------------------------------------------- 1 | export { default } from "@adbayb/stack/eslint"; 2 | -------------------------------------------------------------------------------- /examples/command/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@examples/command", 3 | "version": "0.0.0", 4 | "private": true, 5 | "scripts": { 6 | "start": "node -r esbuild-register ./src/index.ts" 7 | }, 8 | "dependencies": { 9 | "termost": "workspace:^" 10 | }, 11 | "devDependencies": { 12 | "esbuild-register": "3.6.0" 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /examples/command/src/index.ts: -------------------------------------------------------------------------------- 1 | import { helpers, termost } from "termost"; 2 | 3 | import { name, version } from "../package.json" with { type: "json" }; 4 | 5 | type ProgramContext = { 6 | globalFlag: boolean; 7 | }; 8 | 9 | const program = termost({ 10 | name, 11 | description: "Example to showcase the `command` API", 12 | version, 13 | }); 14 | 15 | program.option({ 16 | key: "globalFlag", 17 | name: "global", 18 | description: "Shared flag between commands", 19 | defaultValue: false, 20 | }); 21 | 22 | type BuildCommandContext = { 23 | localFlag: string; 24 | }; 25 | 26 | program 27 | .command({ 28 | name: "build", 29 | description: "Transpile and bundle in production mode", 30 | }) 31 | .option({ 32 | key: "localFlag", 33 | name: "local", 34 | description: "Local command flag", 35 | defaultValue: "local-value", 36 | }) 37 | .task({ 38 | handler(context, argv) { 39 | const { globalFlag, localFlag } = context; 40 | 41 | helpers.message(`👋 Hello, I'm the ${argv.command} command`, { 42 | lineBreak: { end: true, start: false }, 43 | }); 44 | helpers.message(`👉 Shared global flag = ${globalFlag}`, { 45 | label: false, 46 | }); 47 | helpers.message(`👉 Local command flag = ${localFlag}`, { 48 | lineBreak: true, 49 | }); 50 | helpers.message(`👉 Context value = ${JSON.stringify(context)}`, { 51 | lineBreak: { end: true, start: true }, 52 | }); 53 | helpers.message(`👉 Argv value = ${JSON.stringify(argv)}`); 54 | }, 55 | }); 56 | 57 | program 58 | .command({ 59 | name: "watch", 60 | description: "Rebuild your assets on any code change", 61 | }) 62 | .task({ 63 | handler(context, argv) { 64 | const { globalFlag } = context; 65 | 66 | helpers.message(`👋 Hello, I'm the ${argv.command} command`); 67 | helpers.message(`👉 Shared global flag = ${globalFlag}`); 68 | helpers.message(`👉 Context value = ${JSON.stringify(context)}`); 69 | helpers.message(`👉 Argv value = ${JSON.stringify(argv)}`); 70 | }, 71 | }); 72 | -------------------------------------------------------------------------------- /examples/command/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json", 3 | "include": ["src"], 4 | "exclude": ["node_modules", "dist"] 5 | } 6 | -------------------------------------------------------------------------------- /examples/default/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@examples/default", 3 | "version": "0.0.0", 4 | "private": true, 5 | "scripts": { 6 | "start": "node -r esbuild-register ./src/index.ts" 7 | }, 8 | "dependencies": { 9 | "termost": "workspace:^" 10 | }, 11 | "devDependencies": { 12 | "@types/node": "22.15.3", 13 | "esbuild-register": "3.6.0" 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /examples/default/src/index.ts: -------------------------------------------------------------------------------- 1 | import process from "node:process"; 2 | 3 | import { helpers, termost } from "termost"; 4 | 5 | import { name, version } from "../package.json" with { type: "json" }; 6 | 7 | type ProgramContext = { 8 | option: string; 9 | sharedOutput: string; 10 | }; 11 | 12 | const program = termost({ 13 | name, 14 | description: 15 | "Program description placeholder. Program name and version are retrieved from your `package.json`. You can override this automatic retrieval by using the `termost({ name, description, version })` builder form.", 16 | onException(error) { 17 | console.log( 18 | "`onException` catches `uncaughtException` and `unhandledRejection`", 19 | error, 20 | ); 21 | }, 22 | onShutdown() { 23 | console.log( 24 | "`onShutdown` catches `SIGINT` and `SIGTERM` OS signals (useful, for example, to release resources before interrupting the process)", 25 | ); 26 | }, 27 | version, 28 | }); 29 | 30 | program 31 | .option({ 32 | key: "option", 33 | name: { long: "flag", short: "f" }, 34 | description: "A super useful CLI flag", 35 | defaultValue: "Default value", 36 | }) 37 | .task({ 38 | key: "sharedOutput", 39 | label: "Retrieves files", 40 | async handler() { 41 | return helpers.exec('echo "Hello from task"', { 42 | cwd: process.cwd(), 43 | }); 44 | }, 45 | }) 46 | .task({ 47 | handler(context) { 48 | helpers.message(`Task value: ${context.sharedOutput}`); 49 | helpers.message(`Option value: ${context.option}`, { 50 | type: "warning", 51 | }); 52 | }, 53 | }); 54 | -------------------------------------------------------------------------------- /examples/default/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json", 3 | "include": ["src"], 4 | "exclude": ["node_modules", "dist"] 5 | } 6 | -------------------------------------------------------------------------------- /examples/empty/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@examples/empty", 3 | "version": "0.0.0", 4 | "private": true, 5 | "scripts": { 6 | "start": "node -r esbuild-register ./src/index.ts" 7 | }, 8 | "dependencies": { 9 | "termost": "workspace:^" 10 | }, 11 | "devDependencies": { 12 | "esbuild-register": "3.6.0" 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /examples/empty/src/index.ts: -------------------------------------------------------------------------------- 1 | import { termost } from "termost"; 2 | 3 | import { name, version } from "../package.json" with { type: "json" }; 4 | 5 | type ProgramContext = { 6 | option: string; 7 | }; 8 | 9 | const program = termost({ 10 | name, 11 | description: "Example to showcase empty `command` fallback", 12 | version, 13 | }); 14 | 15 | program 16 | .command({ 17 | name: "build", 18 | description: "Transpile and bundle in production mode", 19 | }) 20 | .option({ 21 | key: "option", 22 | name: "longOption", 23 | description: "Useful CLI flag", 24 | defaultValue: "defaultValue", 25 | }); 26 | 27 | program.command({ 28 | name: "watch", 29 | description: "Rebuild your assets on any code change", 30 | }); 31 | -------------------------------------------------------------------------------- /examples/empty/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json", 3 | "include": ["src"], 4 | "exclude": ["node_modules", "dist"] 5 | } 6 | -------------------------------------------------------------------------------- /examples/input/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@examples/input", 3 | "version": "0.0.0", 4 | "private": true, 5 | "scripts": { 6 | "start": "node -r esbuild-register ./src/index.ts" 7 | }, 8 | "dependencies": { 9 | "termost": "workspace:^" 10 | }, 11 | "devDependencies": { 12 | "esbuild-register": "3.6.0" 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /examples/input/src/index.ts: -------------------------------------------------------------------------------- 1 | import { helpers, termost } from "termost"; 2 | 3 | import { name, version } from "../package.json" with { type: "json" }; 4 | 5 | type ProgramContext = { 6 | input1: "singleOption1" | "singleOption2"; 7 | input2: ("multipleOption1" | "multipleOption2")[]; 8 | input3: boolean; 9 | input4: string; 10 | }; 11 | 12 | const program = termost({ 13 | name, 14 | description: "Example to showcase the `input` API", 15 | version, 16 | }); 17 | 18 | program 19 | .input({ 20 | key: "input1", 21 | label: "What is your single choice?", 22 | defaultValue: "singleOption2", 23 | options: ["singleOption1", "singleOption2"], 24 | type: "select", 25 | }) 26 | .input({ 27 | key: "input2", 28 | label: "What is your multiple choices?", 29 | defaultValue: ["multipleOption2"], 30 | options: ["multipleOption1", "multipleOption2"], 31 | type: "multiselect", 32 | }) 33 | .input({ 34 | key: "input3", 35 | label: "Are you sure to skip next input?", 36 | defaultValue: false, 37 | type: "confirm", 38 | }) 39 | .input({ 40 | key: "input4", 41 | label: (context) => 42 | `Dynamic input label generated from a contextual value: ${context.input1}`, 43 | defaultValue: "Empty input", 44 | skip(context, argv) { 45 | console.log(argv); 46 | 47 | return Boolean(context.input3); 48 | }, 49 | type: "text", 50 | }) 51 | .task({ 52 | handler(context) { 53 | helpers.message(JSON.stringify(context, null, 4)); 54 | }, 55 | }); 56 | -------------------------------------------------------------------------------- /examples/input/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json", 3 | "include": ["src"], 4 | "exclude": ["node_modules", "dist"] 5 | } 6 | -------------------------------------------------------------------------------- /examples/option/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@examples/option", 3 | "version": "0.0.0", 4 | "private": true, 5 | "scripts": { 6 | "start": "node -r esbuild-register ./src/index.ts" 7 | }, 8 | "dependencies": { 9 | "termost": "workspace:^" 10 | }, 11 | "devDependencies": { 12 | "esbuild-register": "3.6.0" 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /examples/option/src/index.ts: -------------------------------------------------------------------------------- 1 | import { helpers, termost } from "termost"; 2 | 3 | import { name, version } from "../package.json" with { type: "json" }; 4 | 5 | type ProgramContext = { 6 | optionWithAlias: number; 7 | optionWithoutAlias: string; 8 | }; 9 | 10 | const program = termost({ 11 | name, 12 | description: "Example to showcase the `option` API", 13 | version, 14 | }); 15 | 16 | program 17 | .option({ 18 | key: "optionWithAlias", 19 | name: { long: "shortOption", short: "s" }, 20 | description: "Useful CLI flag", 21 | defaultValue: 0, 22 | }) 23 | .option({ 24 | key: "optionWithoutAlias", 25 | name: "longOption", 26 | description: "Useful CLI flag", 27 | defaultValue: "defaultValue", 28 | }) 29 | .task({ 30 | handler(context) { 31 | helpers.message(JSON.stringify(context, null, 2)); 32 | }, 33 | }); 34 | -------------------------------------------------------------------------------- /examples/option/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json", 3 | "include": ["src"], 4 | "exclude": ["node_modules", "dist"] 5 | } 6 | -------------------------------------------------------------------------------- /examples/task/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@examples/task", 3 | "version": "0.0.0", 4 | "private": true, 5 | "scripts": { 6 | "start": "node -r esbuild-register ./src/index.ts" 7 | }, 8 | "dependencies": { 9 | "termost": "workspace:^" 10 | }, 11 | "devDependencies": { 12 | "esbuild-register": "3.6.0" 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /examples/task/src/index.ts: -------------------------------------------------------------------------------- 1 | import { helpers, termost } from "termost"; 2 | 3 | import { name, version } from "../package.json" with { type: "json" }; 4 | 5 | type ProgramContext = { 6 | computedFromOtherTaskValues: "big" | "small"; 7 | execOutput: string; 8 | size: number; 9 | }; 10 | 11 | const program = termost({ 12 | name, 13 | description: "Example to showcase the `task` API", 14 | version, 15 | }); 16 | 17 | program 18 | .task({ 19 | key: "size", 20 | label: "Task with returned value (persisted)", 21 | handler() { 22 | return 45; 23 | }, 24 | }) 25 | .task({ 26 | label: "Task with side-effect only (no persisted value)", 27 | async handler() { 28 | // @note: side-effect only handler 29 | await wait(500); 30 | }, 31 | }) 32 | .task({ 33 | key: "computedFromOtherTaskValues", 34 | label: "Task can also access other persisted task values", 35 | handler(context) { 36 | if (context.size > 2000) { 37 | return "big" as const; 38 | } 39 | 40 | return "small" as const; 41 | }, 42 | }) 43 | .task({ 44 | key: "execOutput", 45 | label: "Or even execute external commands thanks to its provided helpers", 46 | async handler() { 47 | return helpers.exec("echo 'Hello from shell'"); 48 | }, 49 | }) 50 | .task({ 51 | label: "A task can be skipped as well", 52 | async handler() { 53 | await wait(2000); 54 | }, 55 | skip(context) { 56 | const needOptimization = context.size > 2000; 57 | 58 | return !needOptimization; 59 | }, 60 | }) 61 | .task({ 62 | label: (context) => 63 | `A task can have a dynamic label generated from contextual values: ${context.computedFromOtherTaskValues}`, 64 | handler() { 65 | return; 66 | }, 67 | }) 68 | .task({ 69 | handler(context) { 70 | helpers.message( 71 | 'If you don\'t specify a label, the handler is executed in "live mode" (the output is not hidden by the label and is displayed gradually).', 72 | { label: "Label & console output" }, 73 | ); 74 | 75 | helpers.message( 76 | `A task with a specified "key" can be retrieved here. Size = ${context.size}. If no "key" was specified the task returned value cannot be persisted across program instructions.`, 77 | { label: "Context management" }, 78 | ); 79 | }, 80 | }) 81 | .task({ 82 | handler(context) { 83 | const content = 84 | "The `message` helpers can be used to display task content in a nice way"; 85 | 86 | helpers.message(content, { 87 | label: "Output formatting", 88 | }); 89 | helpers.message(content, { type: "warning" }); 90 | helpers.message(content, { type: "error" }); 91 | helpers.message(content, { type: "success" }); 92 | helpers.message(content, { 93 | label: "👋 You can also customize the label", 94 | type: "information", 95 | }); 96 | console.log( 97 | helpers.format( 98 | "\nYou can also have a total control on the formatting through the `format` helper.", 99 | { 100 | color: "white", 101 | modifiers: ["italic", "strikethrough", "bold"], 102 | }, 103 | ), 104 | ); 105 | 106 | console.info(JSON.stringify(context, null, 2)); 107 | }, 108 | }); 109 | 110 | const wait = async (delay: number) => { 111 | return new Promise((resolve) => setTimeout(resolve, delay)); 112 | }; 113 | -------------------------------------------------------------------------------- /examples/task/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json", 3 | "include": ["src"], 4 | "exclude": ["node_modules", "dist"] 5 | } 6 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "root", 3 | "version": "0.0.0", 4 | "private": true, 5 | "type": "module", 6 | "scripts": { 7 | "build": "stack build", 8 | "check": "stack check", 9 | "clean": "stack clean", 10 | "fix": "stack fix", 11 | "prepare": "stack install && pnpm build", 12 | "release:log": "stack release --log", 13 | "release:publish": "stack release --publish", 14 | "release:version": "stack release --tag", 15 | "start": "stack start", 16 | "test": "stack test", 17 | "watch": "stack watch" 18 | }, 19 | "prettier": "@adbayb/stack/prettier", 20 | "devDependencies": { 21 | "@adbayb/stack": "2.21.0" 22 | }, 23 | "packageManager": "pnpm@10.10.0", 24 | "engines": { 25 | "node": ">=22.0.0", 26 | "pnpm": ">=10.0.0" 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /pnpm-workspace.yaml: -------------------------------------------------------------------------------- 1 | packages: 2 | - "examples/*" 3 | - "packages/*" 4 | - "termost" 5 | -------------------------------------------------------------------------------- /termost/CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # termost 2 | 3 | ## 1.4.0 4 | 5 | ### Minor Changes 6 | 7 | - [`a14c632`](https://github.com/adbayb/termost/commit/a14c6329bf0108365f28fbc29c598353574f47e7) Thanks [@adbayb](https://github.com/adbayb)! - Remove default line breaks for message helper. 8 | 9 | ## 1.3.0 10 | 11 | ### Minor Changes 12 | 13 | - [`838b409`](https://github.com/adbayb/termost/commit/838b409603e86968027f0175c1d7318229491ef0) Thanks [@adbayb](https://github.com/adbayb)! - Add more options to `helpers.message` to configure line breaks and no default label display. 14 | 15 | - [`e5b2458`](https://github.com/adbayb/termost/commit/e5b24586ab5e460c9d509928799940b86ec62763) Thanks [@adbayb](https://github.com/adbayb)! - Message helper accepts now a boolean to configure line breaks at once for all positions. 16 | 17 | ## 1.2.0 18 | 19 | ### Minor Changes 20 | 21 | - [`b4224bc`](https://github.com/adbayb/termost/commit/b4224bc11098b5c40a1629cf9cb081de8edb3211) Thanks [@adbayb](https://github.com/adbayb)! - Update `termost(input)` input contract to allow a single configuration object and make name, version, and description fields required. 22 | 23 | ## 0.18.0 24 | 25 | ### Minor Changes 26 | 27 | - [`ef758a6`](https://github.com/adbayb/termost/commit/ef758a65119a3693160d3f12b813beb4255574cf) Thanks [@adbayb](https://github.com/adbayb)! - Display uncaught error by default and allow `helpers.message` to accept Error-like objects. 28 | 29 | Please note that the `helpers.message` do not accept anymore an array of strings. 30 | 31 | ## 0.17.0 32 | 33 | ### Minor Changes 34 | 35 | - [`f090498`](https://github.com/adbayb/termost/commit/f090498b1c4dca3078dfdf558390d8793979fdcc) Thanks [@adbayb](https://github.com/adbayb)! - Reduce task error output noise. 36 | 37 | ## 0.16.0 38 | 39 | ### Minor Changes 40 | 41 | - [`70d3dd0`](https://github.com/adbayb/termost/commit/70d3dd07466e5aff16108579646f62bd85cd3840) Thanks [@adbayb](https://github.com/adbayb)! - Log the stack trace in case of task error(s). 42 | 43 | ## 0.15.0 44 | 45 | ### Minor Changes 46 | 47 | - [`73542c2`](https://github.com/adbayb/termost/commit/73542c289093ac4d964e90684095227f6a0f5309) Thanks [@adbayb](https://github.com/adbayb)! - Enable ES Module resolution by default. 48 | 49 | ## 0.14.0 50 | 51 | ### Minor Changes 52 | 53 | - [`ad4aa85`](https://github.com/adbayb/termost/commit/ad4aa858bce68bf91c798b80b04a5c5cf37e85db) Thanks [@adbayb](https://github.com/adbayb)! - Make termost not runnable by removing uneeded bin folder. 54 | 55 | ### Patch Changes 56 | 57 | - [`ef991db`](https://github.com/adbayb/termost/commit/ef991dbd3a1cfdab9a2bc19223a62266152b489b) Thanks [@adbayb](https://github.com/adbayb)! - Fix a version resolution regression. 58 | 59 | ## 0.13.2 60 | 61 | ### Patch Changes 62 | 63 | - [`c344f46`](https://github.com/adbayb/termost/commit/c344f4606e8a3dd4731dc7ff60ebc9e72fd3eaa7) Thanks [@adbayb](https://github.com/adbayb)! - Help fallback prevents the version being displayed. 64 | 65 | ## 0.13.1 66 | 67 | ### Patch Changes 68 | 69 | - [`7b157f6`](https://github.com/adbayb/termost/commit/7b157f6b5f165b7a732d2f50b1fba7c9fe52f617) Thanks [@adbayb](https://github.com/adbayb)! - Fix enquirer simulated cjs import in esm-only environment. 70 | 71 | - [`2fb995f`](https://github.com/adbayb/termost/commit/2fb995fb4c6543ab3ecd60f4e1a02d7995a7d943) Thanks [@adbayb](https://github.com/adbayb)! - Make package metadata resolution compatible with esm-only environment. 72 | 73 | ## 0.13.0 74 | 75 | ### Minor Changes 76 | 77 | - [`0d66524`](https://github.com/adbayb/termost/commit/0d66524a1347c4c834619cebf5f9005e05b548f3) Thanks [@adbayb](https://github.com/adbayb)! - Implement help fallback if the default command has no output. 78 | 79 | - [`31329c1`](https://github.com/adbayb/termost/commit/31329c1b56032fb1603cc2d54c5551aecfe6d53c) Thanks [@adbayb](https://github.com/adbayb)! - Update dependencies. 80 | 81 | ## 0.12.1 82 | 83 | ### Patch Changes 84 | 85 | - [#31](https://github.com/adbayb/termost/pull/31) [`84ce62c`](https://github.com/adbayb/termost/commit/84ce62c1a83db1cf2413edcdcdb64d63195247af) Thanks [@garronej](https://github.com/garronej)! - Fixes always crash if can't resolve metadata 86 | 87 | ## 0.12.0 88 | 89 | ### Minor Changes 90 | 91 | - [`b365ee6`](https://github.com/adbayb/termost/commit/b365ee6d047c0dbef64e3651251b98881267766a) Thanks [@adbayb](https://github.com/adbayb)! - Replace chalk with picocolors 92 | 93 | ## 0.11.1 94 | 95 | ### Patch Changes 96 | 97 | - [`917e380`](https://github.com/adbayb/termost/commit/917e3800f2bb848be4ca1c8b3279e8d0e4409250) Thanks [@adbayb](https://github.com/adbayb)! - Fix regression on CJS module by downgrading Chalk version 98 | 99 | ## 0.11.0 100 | 101 | ### Minor Changes 102 | 103 | - [`50ae237`](https://github.com/adbayb/termost/commit/50ae237c4269f624bd707976dc61c0f9fbddebb2) Thanks [@adbayb](https://github.com/adbayb)! - Installation size optimization by introducing the following updates: 104 | 105 | - Chalk major bump 106 | - Litsr2 major bump 107 | - Prompts replaced in favor of Enquirer 108 | 109 | ## 0.10.0 110 | 111 | ### Minor Changes 112 | 113 | - [`e924eac`](https://github.com/adbayb/termost/commit/e924eaca807c7dd78c889ad6506825b25aa8a96f) Thanks [@adbayb](https://github.com/adbayb)! - Allow promise as a return value for unkeyed handlers 114 | 115 | ### Patch Changes 116 | 117 | - [`d8177ee`](https://github.com/adbayb/termost/commit/d8177eed3aa6a7351637a15285b33365e97fbae4) Thanks [@adbayb](https://github.com/adbayb)! - Fix task key autocompletion 118 | -------------------------------------------------------------------------------- /termost/README.md: -------------------------------------------------------------------------------- 1 |
2 |
3 |

💻 Termost

4 | Get the most of your terminal 5 |
6 |
7 |
8 | 9 | ## ✨ Features 10 | 11 | Termost allows building command line tools in a minute thanks to its: 12 | 13 | - [Fluent](https://en.wikipedia.org/wiki/Fluent_interface) syntax to express your CLI configurations with instructions such as: 14 | - [Subcommand](examples/command/src/index.ts) support 15 | - Long and short [option](examples/option/src/index.ts) support 16 | - [User input](examples/input/src/index.ts) support 17 | - [Task](examples/task/src/index.ts) support 18 | - Shareable output between instructions 19 | - Auto-generated help and version metadata 20 | - TypeScript support to foster a type-safe API 21 | - Built-in helpers to make stdin/stdout management a breeze (including exec, and message helpers...) 22 | 23 |
24 | 25 | ## 🚀 Quickstart 26 | 27 | Install the library: 28 | 29 | ```bash 30 | # Npm 31 | npm install termost 32 | # Pnpm 33 | pnpm add termost 34 | # Yarn 35 | yarn add termost 36 | ``` 37 | 38 | Once you're done, you can play with the API: 39 | 40 | ```ts 41 | #!/usr/bin/env node 42 | 43 | import { helpers, termost } from "termost"; 44 | import { name, version } from "../package.json" with { type: "json" }; // Depending on your `package.json` location. 45 | 46 | type ProgramContext = { 47 | globalFlag: string; 48 | }; 49 | 50 | type DebugCommandContext = { 51 | localFlag: string; 52 | }; 53 | 54 | const program = termost({ 55 | name, 56 | description: "CLI description", 57 | version, 58 | onException(error) { 59 | console.error(`Error logic ${error.message}`); 60 | }, 61 | onShutdown() { 62 | console.log("Clean-up logic"); 63 | }, 64 | }); 65 | 66 | program.option({ 67 | key: "globalFlag", 68 | name: { long: "global", short: "g" }, 69 | description: 70 | "A global flag/option example accessible by all commands (key is used to persist the value into the context object)", 71 | defaultValue: 72 | "A default value can be set if no flag is provided by the user", 73 | }); 74 | 75 | program 76 | .command({ 77 | name: "build", 78 | description: 79 | "A custom command example runnable via `bin-name build` (command help available via `bin-name build --help`)", 80 | }) 81 | .task({ 82 | label: "A label can be displayed to follow the task progress", 83 | async handler() { 84 | await fakeBuild(); 85 | }, 86 | }); 87 | 88 | program 89 | .command({ 90 | name: "debug", 91 | description: "A command to play with Termost capabilities", 92 | }) 93 | .option({ 94 | key: "localFlag", 95 | name: "local", 96 | description: "A local flag accessible only by the `debug` command", 97 | defaultValue: "local-value", 98 | }) 99 | .task({ 100 | handler(context, argv) { 101 | helpers.message(`Hello, I'm the ${argv.command} command`); 102 | helpers.message(`Context value = ${JSON.stringify(context)}`); 103 | helpers.message(`Argv value = ${JSON.stringify(argv)}`); 104 | }, 105 | }); 106 | 107 | const fakeBuild = async () => { 108 | return new Promise((resolve) => { 109 | setTimeout(resolve, 3000); 110 | }); 111 | }; 112 | ``` 113 | 114 | Depending on the command, the output will look like this (`bin-name` is the program name automatically retrieved from the `package.json>name`): 115 | 116 | | Command | Preview | 117 | | :---------------------- | :-------------------------------------------------------------------------------------------------------------------------------------------------: | 118 | | `bin-name --help` | Global help | 119 | | `bin-name debug --help` | Local help | 120 | | `bin-name build` | Subcommand with task example | 121 | | `bin-name debug` | Subcommand with option and context example | 122 | 123 |
124 | 125 | ## ✍️ Usage 126 | 127 | Here's an API overview: 128 | 129 |
130 | command({ name, description }) 131 |

132 | 133 | The `command` API creates a new subcommand context. 134 | Please note that the root command context is shared across subcommands but subcommand's contexts are scoped and not accessible between each other. 135 | 136 | ```ts 137 | #!/usr/bin/env node 138 | 139 | import { termost, helpers } from "termost"; 140 | import { name, version } from "../package.json" with { type: "json" }; // Depending on your `package.json` location. 141 | 142 | const program = termost({ 143 | name, 144 | description: "CLI description", 145 | version, 146 | }); 147 | 148 | program 149 | .command({ 150 | name: "build", 151 | description: "Transpile and bundle in production mode", 152 | }) 153 | .task({ 154 | handler(context, argv) { 155 | helpers.message(`👋 Hello, I'm the ${argv.command} command`); 156 | }, 157 | }); 158 | 159 | program 160 | .command({ 161 | name: "watch", 162 | description: "Rebuild your assets on any code change", 163 | }) 164 | .task({ 165 | handler(context, argv) { 166 | helpers.message(`👋 Hello, I'm the ${argv.command} command`, { 167 | type: "warning", 168 | }); 169 | }, 170 | }); 171 | ``` 172 | 173 |

174 |
175 | 176 |
177 | input({ key, label, type, skip, ...typeParameters }) 178 |

179 | 180 | The `input` API creates an interactive prompt. 181 | It supports several types: 182 | 183 | ```ts 184 | #!/usr/bin/env node 185 | 186 | import { termost, helpers } from "termost"; 187 | import { name, version } from "../package.json" with { type: "json" }; // Depending on your `package.json` location. 188 | 189 | type ProgramContext = { 190 | input1: "singleOption1" | "singleOption2"; 191 | input2: Array<"multipleOption1" | "multipleOption2">; 192 | input3: boolean; 193 | input4: string; 194 | }; 195 | 196 | const program = termost({ 197 | name, 198 | description: "CLI description", 199 | version, 200 | }); 201 | 202 | program 203 | .input({ 204 | type: "select", 205 | key: "input1", 206 | label: "What is your single choice?", 207 | options: ["singleOption1", "singleOption2"], 208 | defaultValue: "singleOption2", 209 | }) 210 | .input({ 211 | type: "multiselect", 212 | key: "input2", 213 | label: "What is your multiple choices?", 214 | options: ["multipleOption1", "multipleOption2"], 215 | defaultValue: ["multipleOption2"], 216 | }) 217 | .input({ 218 | type: "confirm", 219 | key: "input3", 220 | label: "Are you sure to skip next input?", 221 | defaultValue: false, 222 | }) 223 | .input({ 224 | type: "text", 225 | key: "input4", 226 | label: (context) => 227 | `Dynamic input label generated from a contextual value: ${context.input1}`, 228 | defaultValue: "Empty input", 229 | skip(context) { 230 | return Boolean(context.input3); 231 | }, 232 | }) 233 | .task({ 234 | handler(context) { 235 | helpers.message(JSON.stringify(context, null, 4)); 236 | }, 237 | }); 238 | ``` 239 | 240 |

241 |
242 | 243 |
244 | option({ key, name, description, defaultValue, skip }) 245 |

246 | 247 | The `option` API defines a contextual CLI option. 248 | The option value can be accessed through its `key` property from the current context. 249 | 250 | ```ts 251 | #!/usr/bin/env node 252 | 253 | import { termost, helpers } from "termost"; 254 | import { name, version } from "../package.json" with { type: "json" }; // Depending on your `package.json` location. 255 | 256 | type ProgramContext = { 257 | optionWithAlias: number; 258 | optionWithoutAlias: string; 259 | }; 260 | 261 | const program = termost({ 262 | name, 263 | description: "CLI description", 264 | version, 265 | }); 266 | 267 | program 268 | .option({ 269 | key: "optionWithAlias", 270 | name: { long: "shortOption", short: "s" }, 271 | description: "Useful CLI flag", 272 | defaultValue: 0, 273 | }) 274 | .option({ 275 | key: "optionWithoutAlias", 276 | name: "longOption", 277 | description: "Useful CLI flag", 278 | defaultValue: "defaultValue", 279 | }) 280 | .task({ 281 | handler(context) { 282 | helpers.message(JSON.stringify(context, null, 2)); 283 | }, 284 | }); 285 | ``` 286 | 287 |

288 |
289 | 290 |
291 | task({ key, label, handler, skip }) 292 |

293 | 294 | The `task` executes a handler (either a synchronous or an asynchronous one). 295 | The output can be either: 296 | 297 | - Displayed gradually if no `label` is provided 298 | - Displayed until the promise is fulfilled if a `label` property is specified (in the meantime, a spinner with the label is showcased) 299 | 300 | ```ts 301 | #!/usr/bin/env node 302 | 303 | import { helpers, termost } from "../src"; 304 | import { name, version } from "../package.json" with { type: "json" }; // Depending on your `package.json` location. 305 | 306 | type ProgramContext = { 307 | computedFromOtherTaskValues: "big" | "small"; 308 | execOutput: string; 309 | size: number; 310 | }; 311 | 312 | const program = termost({ 313 | name, 314 | description: "CLI description", 315 | version, 316 | }); 317 | 318 | program 319 | .task({ 320 | key: "size", 321 | label: "Task with returned value (persisted)", 322 | async handler() { 323 | return 45; 324 | }, 325 | }) 326 | .task({ 327 | label: "Task with side-effect only (no persisted value)", 328 | async handler() { 329 | await wait(500); 330 | // @note: side-effect only handler 331 | }, 332 | }) 333 | .task({ 334 | key: "computedFromOtherTaskValues", 335 | label: "Task can also access other persisted task values", 336 | handler(context) { 337 | if (context.size > 2000) { 338 | return Promise.resolve("big"); 339 | } 340 | 341 | return Promise.resolve("small"); 342 | }, 343 | }) 344 | .task({ 345 | key: "execOutput", 346 | label: "Or even execute external commands thanks to its provided helpers", 347 | handler() { 348 | return helpers.exec("echo 'Hello from shell'"); 349 | }, 350 | }) 351 | .task({ 352 | label: "A task can be skipped as well", 353 | async handler() { 354 | await wait(2000); 355 | 356 | return Promise.resolve("Super long task"); 357 | }, 358 | skip(context) { 359 | const needOptimization = context.size > 2000; 360 | 361 | return !needOptimization; 362 | }, 363 | }) 364 | .task({ 365 | label: (context) => 366 | `A task can have a dynamic label generated from contextual values: ${context.computedFromOtherTaskValues}`, 367 | async handler() {}, 368 | }) 369 | .task({ 370 | handler(context) { 371 | helpers.message( 372 | `If you don't specify a label, the handler is executed in "live mode" (the output is not hidden by the label and is displayed gradually).`, 373 | { label: "Label & console output" }, 374 | ); 375 | 376 | helpers.message( 377 | `A task with a specified "key" can be retrieved here. Size = ${context.size}. If no "key" was specified the task returned value cannot be persisted across program instructions.`, 378 | { label: "Context management" }, 379 | ); 380 | }, 381 | }) 382 | .task({ 383 | handler(context) { 384 | const content = 385 | "The `message` helpers can be used to display task content in a nice way"; 386 | 387 | helpers.message(content, { 388 | label: "Output formatting", 389 | }); 390 | helpers.message(content, { type: "warning" }); 391 | helpers.message(content, { type: "error" }); 392 | helpers.message(content, { type: "success" }); 393 | helpers.message(content, { 394 | type: "information", 395 | label: "👋 You can also customize the label", 396 | }); 397 | console.log( 398 | helpers.format( 399 | "\nYou can also have a total control on the formatting through the `format` helper.", 400 | { 401 | color: "white", 402 | modifiers: ["italic", "strikethrough", "bold"], 403 | }, 404 | ), 405 | ); 406 | 407 | console.info(JSON.stringify(context, null, 2)); 408 | }, 409 | }); 410 | 411 | const wait = (delay: number) => { 412 | return new Promise((resolve) => setTimeout(resolve, delay)); 413 | }; 414 | ``` 415 | 416 |

417 |
418 | 419 |
420 | 421 | ## 🤩 Built with Termost 422 | 423 | - [Quickbundle](https://github.com/adbayb/quickbundle) The zero-configuration transpiler and bundler for the web. 424 | 425 |
426 | 427 | ## 💙 Acknowledgements 428 | 429 | This project is built upon solid open-source foundations. We'd like to thank: 430 | 431 | - [`enquirer`](https://www.npmjs.com/package/enquirer) for managing `input` internals 432 | - [`listr2`](https://www.npmjs.com/package/listr2) for managing `task` internals 433 | 434 |
435 | 436 | ## 📖 License 437 | 438 | [MIT](https://github.com/adbayb/termost/blob/main/LICENSE "License MIT") 439 | -------------------------------------------------------------------------------- /termost/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "termost", 3 | "version": "1.4.0", 4 | "description": "Get the most of your terminal", 5 | "keywords": [ 6 | "cli", 7 | "terminal", 8 | "args", 9 | "argument", 10 | "option", 11 | "command", 12 | "task", 13 | "question", 14 | "message" 15 | ], 16 | "homepage": "https://github.com/adbayb/termost/tree/main/termost#readme", 17 | "bugs": "https://github.com/adbayb/termost/issues", 18 | "repository": { 19 | "type": "git", 20 | "url": "git@github.com:adbayb/termost.git", 21 | "directory": "termost" 22 | }, 23 | "license": "MIT", 24 | "author": "Ayoub Adib (https://twitter.com/adbayb)", 25 | "sideEffects": false, 26 | "type": "module", 27 | "exports": { 28 | "source": "./src/index.ts", 29 | "types": "./dist/index.d.ts", 30 | "require": "./dist/index.cjs", 31 | "import": "./dist/index.mjs", 32 | "default": "./dist/index.mjs" 33 | }, 34 | "files": [ 35 | "dist" 36 | ], 37 | "scripts": { 38 | "build": "quickbundle build", 39 | "prepublishOnly": "pnpm build", 40 | "start": "pnpm watch", 41 | "test": "vitest --test-timeout=20000", 42 | "watch": "quickbundle watch" 43 | }, 44 | "dependencies": { 45 | "enquirer": "^2.4.1", 46 | "listr2": "^8.2.5", 47 | "picocolors": "^1.1.1" 48 | }, 49 | "devDependencies": { 50 | "@types/node": "22.15.3", 51 | "quickbundle": "2.12.0", 52 | "vitest": "3.0.8" 53 | }, 54 | "publishConfig": { 55 | "access": "public" 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /termost/src/__snapshots__/index.test.ts.snap: -------------------------------------------------------------------------------- 1 | // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html 2 | 3 | exports[`termost > should display \`help\` 1`] = ` 4 | " 5 | USAGE: 6 | @examples/default […options] 7 |  8 | DESCRIPTION: 9 | Program description placeholder. Program name and version are retrieved from your \`package.json\`. You can override this automatic retrieval by using the \`termost({ name, description, version })\` builder form. 10 |  11 | OPTIONS: 12 |  -h, --help  Display the help center 13 |  -v, --version  Print the version 14 |  -f, --flag  A super useful CLI flag" 15 | `; 16 | 17 | exports[`termost > should display \`help\` 2`] = ` 18 | " 19 | USAGE: 20 | @examples/default […options] 21 |  22 | DESCRIPTION: 23 | Program description placeholder. Program name and version are retrieved from your \`package.json\`. You can override this automatic retrieval by using the \`termost({ name, description, version })\` builder form. 24 |  25 | OPTIONS: 26 |  -h, --help  Display the help center 27 |  -v, --version  Print the version 28 |  -f, --flag  A super useful CLI flag" 29 | `; 30 | 31 | exports[`termost > should display \`help\` given empty command 1`] = ` 32 | " 33 | USAGE: 34 | @examples/empty […options] 35 |  36 | DESCRIPTION: 37 | Example to showcase empty \`command\` fallback 38 |  39 | COMMANDS: 40 |  build  Transpile and bundle in production mode 41 |  watch  Rebuild your assets on any code change 42 |  43 | OPTIONS: 44 |  -h, --help  Display the help center 45 |  -v, --version  Print the version" 46 | `; 47 | 48 | exports[`termost > should display \`help\` given empty command 2`] = ` 49 | " 50 | USAGE: 51 | @examples/empty build […options] 52 |  53 | DESCRIPTION: 54 | Transpile and bundle in production mode 55 |  56 | OPTIONS: 57 |  -h, --help  Display the help center 58 |  -v, --version  Print the version 59 |  --longOption  Useful CLI flag" 60 | `; 61 | 62 | exports[`termost > should display \`help\` given empty command 3`] = ` 63 | " 64 | USAGE: 65 | @examples/empty build […options] 66 |  67 | DESCRIPTION: 68 | Transpile and bundle in production mode 69 |  70 | OPTIONS: 71 |  -h, --help  Display the help center 72 |  -v, --version  Print the version 73 |  --longOption  Useful CLI flag" 74 | `; 75 | 76 | exports[`termost > should display \`help\` given empty command 4`] = ` 77 | " 78 | USAGE: 79 | @examples/empty watch […options] 80 |  81 | DESCRIPTION: 82 | Rebuild your assets on any code change 83 |  84 | OPTIONS: 85 |  -h, --help  Display the help center 86 |  -v, --version  Print the version" 87 | `; 88 | 89 | exports[`termost > should display \`version\` 1`] = `"0.0.0"`; 90 | 91 | exports[`termost > should display \`version\` 2`] = `"0.0.0"`; 92 | 93 | exports[`termost > should handle \`command\` api 1`] = ` 94 | " 95 | USAGE: 96 | @examples/command […options] 97 |  98 | DESCRIPTION: 99 | Example to showcase the \`command\` API 100 |  101 | COMMANDS: 102 |  build  Transpile and bundle in production mode 103 |  watch  Rebuild your assets on any code change 104 |  105 | OPTIONS: 106 |  -h, --help  Display the help center 107 |  -v, --version  Print the version 108 |  --global  Shared flag between commands" 109 | `; 110 | 111 | exports[`termost > should handle \`command\` api 2`] = ` 112 | "ℹ️ Information 113 |  👋 Hello, I'm the build command 114 | 115 | ℹ️ 👉 Shared global flag = false 116 |  117 | ℹ️ Information 118 |  👉 Local command flag = local-value 119 | 120 |  121 | ℹ️ Information 122 |  👉 Context value = {"globalFlag":false,"localFlag":"local-value"} 123 | 124 | ℹ️ Information 125 |  👉 Argv value = {"command":"build","operands":[],"options":{}}" 126 | `; 127 | 128 | exports[`termost > should handle \`command\` api 3`] = ` 129 | "ℹ️ Information 130 |  👋 Hello, I'm the watch command 131 | ℹ️ Information 132 |  👉 Shared global flag = false 133 | ℹ️ Information 134 |  👉 Context value = {"globalFlag":false} 135 | ℹ️ Information 136 |  👉 Argv value = {"command":"watch","operands":[],"options":{}}" 137 | `; 138 | 139 | exports[`termost > should handle \`command\` api 4`] = ` 140 | "ℹ️ Information 141 |  👋 Hello, I'm the build command 142 | 143 | ℹ️ 👉 Shared global flag = true 144 |  145 | ℹ️ Information 146 |  👉 Local command flag = hello 147 | 148 |  149 | ℹ️ Information 150 |  👉 Context value = {"globalFlag":true,"localFlag":"hello"} 151 | 152 | ℹ️ Information 153 |  👉 Argv value = {"command":"build","operands":[],"options":{"global":true,"local":"hello"}}" 154 | `; 155 | 156 | exports[`termost > should handle \`command\` api 5`] = ` 157 | "ℹ️ Information 158 |  👋 Hello, I'm the watch command 159 | ℹ️ Information 160 |  👉 Shared global flag = true 161 | ℹ️ Information 162 |  👉 Context value = {"globalFlag":true} 163 | ℹ️ Information 164 |  👉 Argv value = {"command":"watch","operands":[],"options":{"global":true}}" 165 | `; 166 | 167 | exports[`termost > should handle \`command\` api 6`] = ` 168 | " 169 | USAGE: 170 | @examples/command build […options] 171 |  172 | DESCRIPTION: 173 | Transpile and bundle in production mode 174 |  175 | OPTIONS: 176 |  -h, --help  Display the help center 177 |  -v, --version  Print the version 178 |  --global  Shared flag between commands 179 |  --local  Local command flag" 180 | `; 181 | 182 | exports[`termost > should handle \`command\` api 7`] = ` 183 | " 184 | USAGE: 185 | @examples/command watch […options] 186 |  187 | DESCRIPTION: 188 | Rebuild your assets on any code change 189 |  190 | OPTIONS: 191 |  -h, --help  Display the help center 192 |  -v, --version  Print the version 193 |  --global  Shared flag between commands" 194 | `; 195 | 196 | exports[`termost > should handle \`option\` api 1`] = ` 197 | "ℹ️ Information 198 |  { 199 | "optionWithAlias": 0, 200 | "optionWithoutAlias": "defaultValue" 201 | }" 202 | `; 203 | 204 | exports[`termost > should handle \`task\` api 1`] = ` 205 | "❯ Task with returned value (persisted) 206 | ✔ Task with returned value (persisted) 207 | ❯ Task with side-effect only (no persisted value) 208 | ✔ Task with side-effect only (no persisted value) 209 | ❯ Task can also access other persisted task values 210 | ✔ Task can also access other persisted task values 211 | ❯ Or even execute external commands thanks to its provided helpers 212 | ✔ Or even execute external commands thanks to its provided helpers 213 | ❯ A task can have a dynamic label generated from contextual values: small 214 | ✔ A task can have a dynamic label generated from contextual values: small 215 | ℹ️ Label & console output 216 |  If you don't specify a label, the handler is executed in "live mode" (the output is not hidden by the label and is displayed gradually). 217 | ℹ️ Context management 218 |  A task with a specified "key" can be retrieved here. Size = 45. If no "key" was specified the task returned value cannot be persisted across program instructions. 219 | ℹ️ Output formatting 220 |  The \`message\` helpers can be used to display task content in a nice way 221 | ✅ Success 222 |  The \`message\` helpers can be used to display task content in a nice way 223 | ℹ️ 👋 You can also customize the label 224 |  The \`message\` helpers can be used to display task content in a nice way 225 |  226 | You can also have a total control on the formatting through the \`format\` helper. 227 | { 228 | "size": 45, 229 | "computedFromOtherTaskValues": "small", 230 | "execOutput": "Hello from shell" 231 | }" 232 | `; 233 | -------------------------------------------------------------------------------- /termost/src/api/command/command.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable sonarjs/cognitive-complexity */ 2 | import type { ObjectLikeConstraint, ProgramMetadata } from "../../types"; 3 | import { format } from "../../helpers/stdout"; 4 | import { 5 | createCommandController, 6 | getCommandController, 7 | getCommandDescriptionCollection, 8 | } from "./controller"; 9 | import type { CommandController } from "./controller"; 10 | 11 | export type CommandParameters = { 12 | name: string; 13 | description: string; 14 | }; 15 | 16 | export const createCommand = ( 17 | { name, description }: CommandParameters, 18 | metadata: ProgramMetadata, 19 | ) => { 20 | const { name: rootCommandName, argv, version } = metadata; 21 | const isRootCommand = name === rootCommandName; 22 | const isActiveCommand = argv.command === name; 23 | const controller = createCommandController(name, description); 24 | const rootController = getCommandController(rootCommandName); 25 | 26 | /* 27 | * Timeout to force evaluating help output at the end of the program instructions chaining. 28 | * It allows collecting all needed input to fill the output: 29 | */ 30 | setTimeout(() => { 31 | /** 32 | * By design, the root command instructions are always executed 33 | * even with subcommands (to share options, messages...). 34 | */ 35 | if (isRootCommand && !isActiveCommand) { 36 | void rootController.enable(); 37 | } 38 | 39 | // Enable the current active command instructions: 40 | if (isActiveCommand) { 41 | /** 42 | * SetTimeout 0 allows to run activation logic in the next event loop iteration. 43 | * It'll allow to make sure that the `metadata` is correctly filled with all commands 44 | * metadata (especially to let the global help option to display all available commands). 45 | */ 46 | const optionKeys = Object.keys(argv.options); 47 | 48 | const help = () => { 49 | showHelp({ 50 | controller, 51 | currentCommandName: name, 52 | isRootCommand, 53 | rootCommandName, 54 | }); 55 | }; 56 | 57 | if ( 58 | optionKeys.includes(OPTION_VERSION_NAMES[0]) || 59 | optionKeys.includes(OPTION_VERSION_NAMES[1]) 60 | ) { 61 | console.info(version); 62 | 63 | return; 64 | } 65 | 66 | if ( 67 | optionKeys.includes(OPTION_HELP_NAMES[0]) || 68 | optionKeys.includes(OPTION_HELP_NAMES[1]) 69 | ) { 70 | help(); 71 | 72 | return; 73 | } 74 | 75 | if (metadata.isEmptyCommand[name]) { 76 | // Show help by default if no processing is done for the current command 77 | help(); 78 | } else { 79 | void controller.enable(); 80 | } 81 | } 82 | }, 0); 83 | 84 | return name; 85 | }; 86 | 87 | const OPTION_HELP_NAMES = ["help", "h"] as const; 88 | const OPTION_VERSION_NAMES = ["version", "v"] as const; 89 | 90 | const showHelp = ({ 91 | controller, 92 | currentCommandName, 93 | isRootCommand, 94 | rootCommandName, 95 | }: { 96 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 97 | controller: CommandController; 98 | currentCommandName: string; 99 | isRootCommand: boolean; 100 | rootCommandName: string; 101 | // eslint-disable-next-line sonarjs/cyclomatic-complexity 102 | }) => { 103 | const commandMetadata = controller.getMetadata(rootCommandName); 104 | const { description, options } = commandMetadata; 105 | const commands = getCommandDescriptionCollection(); 106 | const optionKeys = Object.keys(commandMetadata.options); 107 | const commandKeys = Object.keys(commands); 108 | const hasOptions = optionKeys.length > 0; 109 | const hasCommands = isRootCommand && commandKeys.length > 1; 110 | 111 | printTitle("Usage"); 112 | print( 113 | `${format( 114 | `${rootCommandName}${ 115 | isRootCommand ? "" : ` ${String(currentCommandName)}` 116 | }`, 117 | { 118 | color: "green", 119 | }, 120 | )} ${hasCommands ? " " : ""}${hasOptions ? "[…options]" : ""}`, 121 | ); 122 | 123 | if (description) { 124 | printTitle("Description"); 125 | print(description); 126 | } 127 | 128 | const padding = [...commandKeys, ...optionKeys].reduce((value, item) => { 129 | return Math.max(value, item.length); 130 | }, 0); 131 | 132 | if (hasCommands) { 133 | printTitle("Commands"); 134 | 135 | for (const name of commandKeys) { 136 | if (name === rootCommandName) continue; 137 | 138 | const commandDescription = commands[name]; 139 | 140 | if (commandDescription) 141 | printLabelValue(name, commandDescription, padding); 142 | } 143 | } 144 | 145 | if (hasOptions) { 146 | printTitle("Options"); 147 | 148 | for (const key of optionKeys) { 149 | printLabelValue(key, options[key] as string, padding); 150 | } 151 | } 152 | }; 153 | 154 | const print = (...parameters: Parameters) => { 155 | console.log(format(...parameters)); 156 | }; 157 | 158 | const printTitle = (message: string) => { 159 | print(`\n${message}:`, { 160 | color: "yellow", 161 | modifiers: ["bold", "underline", "uppercase"], 162 | }); 163 | }; 164 | 165 | const printLabelValue = (label: string, value: string, padding: number) => { 166 | print( 167 | ` ${format(label.padEnd(padding + 1, " "), { 168 | color: "green", 169 | })} ${value}`, 170 | ); 171 | }; 172 | -------------------------------------------------------------------------------- /termost/src/api/command/controller/index.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | CommandName, 3 | Context, 4 | EmptyObject, 5 | ObjectLikeConstraint, 6 | } from "../../../types"; 7 | import { createQueue } from "./queue"; 8 | 9 | export type CommandController< 10 | Values extends ObjectLikeConstraint = EmptyObject, 11 | > = { 12 | addInstruction: (instruction: Instruction) => void; 13 | addOptionDescription: (key: string, description: string) => void; 14 | addValue: (key: Key, value: Values[Key]) => void; 15 | /** 16 | * Enables a command by iterating over instructions and executing them. 17 | */ 18 | enable: () => Promise; 19 | getContext: (rootCommandName: CommandName) => Context; 20 | getMetadata: (rootCommandName: CommandName) => CommandMetadata; 21 | }; 22 | 23 | export const getCommandController = ( 24 | name: CommandName, 25 | ) => { 26 | const controller = commandControllerCollection[name]; 27 | 28 | if (!controller) { 29 | throw new Error( 30 | `No controller has been set for the \`${name}\` command.\nHave you run the \`termost\` constructor?`, 31 | ); 32 | } 33 | 34 | return controller as CommandController; 35 | }; 36 | 37 | export const createCommandController = ( 38 | name: CommandName, 39 | description: CommandMetadata["description"], 40 | ) => { 41 | const instructions = createQueue(); 42 | // eslint-disable-next-line @typescript-eslint/consistent-type-assertions 43 | let context = {} as Context; 44 | 45 | const metadata: CommandMetadata = { 46 | description, 47 | options: { 48 | "-h, --help": "Display the help center", 49 | "-v, --version": "Print the version", 50 | }, 51 | }; 52 | 53 | const controller: CommandController = { 54 | addInstruction(instruction) { 55 | instructions.enqueue(instruction); 56 | }, 57 | addOptionDescription(key, value) { 58 | metadata.options[key] = value; 59 | }, 60 | addValue(key, value) { 61 | context[key] = value; 62 | }, 63 | async enable() { 64 | while (!instructions.isEmpty()) { 65 | const task = instructions.dequeue(); 66 | 67 | if (task) { 68 | await task(); 69 | } 70 | } 71 | }, 72 | getContext(rootCommandName) { 73 | /** 74 | * By design, global values are accessible to subcommands. 75 | * Consequently, root command values are merged with the current command ones. 76 | */ 77 | if (name !== rootCommandName) { 78 | const rootController = getCommandController(rootCommandName); 79 | 80 | const globalContext = 81 | rootController.getContext(rootCommandName); 82 | 83 | context = { 84 | ...globalContext, 85 | ...context, 86 | }; 87 | } 88 | 89 | return context; 90 | }, 91 | getMetadata(rootCommandName) { 92 | if (name !== rootCommandName) { 93 | const globalMetadata = 94 | getCommandController(rootCommandName).getMetadata( 95 | rootCommandName, 96 | ); 97 | 98 | metadata.options = { 99 | ...globalMetadata.options, 100 | ...metadata.options, 101 | }; 102 | } 103 | 104 | return metadata; 105 | }, 106 | }; 107 | 108 | commandDescriptionCollection[name] = description; 109 | commandControllerCollection[name] = controller; 110 | 111 | return controller; 112 | }; 113 | 114 | export const getCommandDescriptionCollection = () => { 115 | return commandDescriptionCollection; 116 | }; 117 | 118 | type CommandMetadata = { 119 | description: string; 120 | options: Record; 121 | }; 122 | 123 | type Instruction = () => Promise; 124 | 125 | const commandControllerCollection: Record< 126 | CommandName, 127 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 128 | CommandController 129 | > = {}; 130 | 131 | const commandDescriptionCollection: Record< 132 | CommandName, 133 | CommandMetadata["description"] 134 | > = {}; 135 | -------------------------------------------------------------------------------- /termost/src/api/command/controller/queue.ts: -------------------------------------------------------------------------------- 1 | export const createQueue = () => { 2 | const items: Item[] = []; 3 | 4 | return { 5 | dequeue() { 6 | return items.shift(); 7 | }, 8 | enqueue(item: Item) { 9 | items.push(item); 10 | }, 11 | isEmpty() { 12 | return items.length === 0; 13 | }, 14 | }; 15 | }; 16 | -------------------------------------------------------------------------------- /termost/src/api/command/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./command"; 2 | export * from "./controller"; 3 | -------------------------------------------------------------------------------- /termost/src/api/input/index.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-unsafe-assignment */ 2 | import enquirer from "enquirer"; 3 | 4 | import type { 5 | CreateInstruction, 6 | InstructionKey, 7 | InstructionParameters, 8 | Label, 9 | ObjectLikeConstraint, 10 | } from "../../types"; 11 | 12 | const { prompt } = enquirer; 13 | 14 | export const createInput: CreateInstruction< 15 | InputParameters 16 | > = (parameters) => { 17 | const { key, label, defaultValue, type } = parameters; 18 | 19 | return async function execute(context, argv) { 20 | const promptObject: Parameters[0] & { 21 | choices?: { title: string; selected?: boolean; value: string }[]; 22 | } = { 23 | name: key, 24 | initial: defaultValue, 25 | message: typeof label === "function" ? label(context, argv) : label, 26 | type, 27 | }; 28 | 29 | if (parameters.type === "select" || parameters.type === "multiselect") { 30 | const isMultiSelect = parameters.type === "multiselect"; 31 | const options = parameters.options as string[]; 32 | 33 | const choices = options.map((option) => ({ 34 | title: option, 35 | value: option, 36 | ...(isMultiSelect && { 37 | selected: ((defaultValue ?? []) as string[]).includes( 38 | option, 39 | ), 40 | }), 41 | })); 42 | 43 | promptObject.choices = choices; 44 | } 45 | 46 | const data = await prompt(promptObject); 47 | 48 | return { key, value: data[key] }; 49 | }; 50 | }; 51 | 52 | export type InputParameters< 53 | Values extends ObjectLikeConstraint, 54 | Key extends keyof Values, 55 | > = InstructionParameters< 56 | Values, 57 | InstructionKey & 58 | ( 59 | | { 60 | label: Label; 61 | defaultValue?: Values[Key] extends boolean 62 | ? Values[Key] 63 | : never; 64 | type: "confirm"; 65 | } 66 | | { 67 | label: Label; 68 | defaultValue?: Values[Key] extends string 69 | ? Values[Key] 70 | : never; 71 | options: Values[Key] extends string ? Values[Key][] : never; 72 | type: "select"; 73 | } 74 | | { 75 | label: Label; 76 | defaultValue?: Values[Key] extends string 77 | ? Values[Key] 78 | : never; 79 | type: "text"; 80 | } 81 | | { 82 | label: Label; 83 | defaultValue?: Values[Key] extends string[] 84 | ? Values[Key] 85 | : never; 86 | options: Values[Key] extends string[] ? Values[Key] : never; 87 | type: "multiselect"; 88 | } 89 | ) 90 | >; 91 | -------------------------------------------------------------------------------- /termost/src/api/option/index.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-unsafe-assignment */ 2 | import type { CommandController } from "../command"; 3 | import type { 4 | CreateInstruction, 5 | InstructionKey, 6 | InstructionParameters, 7 | ObjectLikeConstraint, 8 | ProgramMetadata, 9 | } from "../../types"; 10 | 11 | export const createOption = 12 | ( 13 | commandController: CommandController, 14 | { argv }: ProgramMetadata, 15 | ): CreateInstruction< 16 | OptionParameters 17 | > => 18 | (parameters) => { 19 | const { key, name, description, defaultValue } = parameters; 20 | 21 | const aliases = 22 | typeof name === "string" ? [name] : [name.short, name.long]; 23 | 24 | const metadataKey = aliases 25 | .map( 26 | (alias, index) => 27 | "-".repeat(aliases.length > 1 ? index + 1 : 2) + alias, 28 | ) 29 | .join(", "); 30 | 31 | commandController.addOptionDescription(metadataKey, description); 32 | 33 | return async function execute() { 34 | let value: unknown; 35 | 36 | for (const alias of aliases) { 37 | if (alias in argv.options) { 38 | value = argv.options[alias]; 39 | 40 | break; 41 | } 42 | } 43 | 44 | // eslint-disable-next-line unicorn/no-useless-promise-resolve-reject 45 | return Promise.resolve({ key, value: value ?? defaultValue }); 46 | }; 47 | }; 48 | 49 | export type OptionParameters< 50 | Values extends ObjectLikeConstraint, 51 | Key extends keyof Values, 52 | > = InstructionParameters< 53 | Values, 54 | InstructionKey & { 55 | name: string | { long: string; short: string }; 56 | description: string; 57 | defaultValue?: Values[Key]; 58 | } 59 | >; 60 | -------------------------------------------------------------------------------- /termost/src/api/task/index.ts: -------------------------------------------------------------------------------- 1 | import { Listr, PRESET_TIMER } from "listr2"; 2 | 3 | import type { 4 | ArgumentValues, 5 | Context, 6 | CreateInstruction, 7 | InstructionKey, 8 | InstructionParameters, 9 | Label, 10 | ObjectLikeConstraint, 11 | } from "../../types"; 12 | 13 | export const createTask: CreateInstruction< 14 | TaskParameters 15 | > = (parameters) => { 16 | const { key, label, handler } = parameters; 17 | 18 | const receiver = label 19 | ? new Listr([], { 20 | collectErrors: "minimal", 21 | exitOnError: true, 22 | rendererOptions: { 23 | collapseErrors: false, 24 | formatOutput: "wrap", 25 | showErrorMessage: false, 26 | timer: PRESET_TIMER, 27 | }, 28 | }) 29 | : null; 30 | 31 | return async function execute(context, argv) { 32 | let value: unknown; 33 | 34 | if (!receiver) { 35 | value = await handler(context, argv); 36 | } else { 37 | receiver.add({ 38 | ...(label && { 39 | title: 40 | typeof label === "function" 41 | ? label(context, argv) 42 | : label, 43 | }), 44 | // eslint-disable-next-line @typescript-eslint/no-unsafe-return 45 | task: async () => (value = await handler(context, argv)), 46 | }); 47 | 48 | await receiver.run(); 49 | } 50 | 51 | return { key, value }; 52 | }; 53 | }; 54 | 55 | export type TaskParameters< 56 | Values extends ObjectLikeConstraint, 57 | Key extends keyof Values | undefined = undefined, 58 | > = InstructionParameters< 59 | Values, 60 | Partial> & { 61 | label?: Label; 62 | handler: ( 63 | context: Context, 64 | argv: ArgumentValues, 65 | ) => Key extends keyof Values 66 | ? Promise | Values[Key] 67 | : Promise | void; 68 | } 69 | >; 70 | -------------------------------------------------------------------------------- /termost/src/helpers/process/index.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, test } from "vitest"; 2 | 3 | import { exec } from "."; 4 | 5 | describe("process", () => { 6 | test("should `exec` given no error", async () => { 7 | expect(await exec('echo "Plop"')).toBe("Plop"); 8 | }); 9 | 10 | test("should `exec` given error", async () => { 11 | await expect(exec("unavailable_command12345")).rejects.toThrow( 12 | /not found/, 13 | ); 14 | }); 15 | }); 16 | -------------------------------------------------------------------------------- /termost/src/helpers/process/index.ts: -------------------------------------------------------------------------------- 1 | import process from "node:process"; 2 | import { spawn } from "node:child_process"; 3 | 4 | export const exec = async (command: string, options: ExecOptions = {}) => { 5 | const { cwd, hasLiveOutput = false } = options; 6 | 7 | return new Promise((resolve, reject) => { 8 | let stdout = ""; 9 | let stderr = ""; 10 | 11 | const [bin, ...arguments_] = command.split(" ") as [ 12 | string, 13 | ...string[], 14 | ]; 15 | 16 | // eslint-disable-next-line sonarjs/os-command 17 | const childProcess = spawn(bin, arguments_, { 18 | cwd, 19 | env: { 20 | // eslint-disable-next-line n/no-process-env 21 | ...process.env, 22 | // @note: make sure to force color display for spawned processes 23 | FORCE_COLOR: "1", 24 | }, 25 | shell: true, 26 | stdio: hasLiveOutput ? "inherit" : "pipe", 27 | }); 28 | 29 | childProcess.stdout?.on("data", (chunk: string) => { 30 | stdout += chunk; 31 | }); 32 | 33 | childProcess.stderr?.on("data", (chunk: string) => { 34 | stderr += chunk; 35 | }); 36 | 37 | childProcess.on("close", (exitCode) => { 38 | if (exitCode !== 0) { 39 | const output = `${stderr}${stdout}`; 40 | 41 | reject(new Error(output.trim())); 42 | } else { 43 | resolve(stdout.trim()); 44 | } 45 | }); 46 | }); 47 | }; 48 | 49 | type ExecOptions = { 50 | cwd?: string; 51 | hasLiveOutput?: boolean; 52 | }; 53 | -------------------------------------------------------------------------------- /termost/src/helpers/stdin/index.test.ts: -------------------------------------------------------------------------------- 1 | import process from "node:process"; 2 | 3 | import { describe, expect, test } from "vitest"; 4 | 5 | import { getArguments } from "."; 6 | 7 | // termost watch operand1 --help --option1 value1 --option2=value2 -al lastValue operand2 -t short -b 8 | process.argv = [ 9 | "/bin/node", 10 | "./node_modules/.bin/termost", 11 | "watch", 12 | "operand1", 13 | "--help", 14 | "--option1", 15 | "value1", 16 | "--option2=value2", 17 | "-al", 18 | "lastValue", 19 | "operand2", 20 | "-t", 21 | "short", 22 | "-b", 23 | ]; 24 | 25 | describe("getArguments", () => { 26 | test("should parse command name", () => { 27 | expect(getArguments().command).toBe(process.argv[2]); 28 | }); 29 | 30 | test("should parse operands", () => { 31 | expect(getArguments().operands).toStrictEqual(["operand1", "operand2"]); 32 | }); 33 | 34 | test("should parse options", () => { 35 | expect(getArguments().options).toStrictEqual({ 36 | a: true, 37 | b: true, 38 | help: true, 39 | l: "lastValue", 40 | option1: "value1", 41 | option2: "value2", 42 | t: "short", 43 | }); 44 | }); 45 | 46 | test("should parse correctly given unordered argument", () => { 47 | process.argv = [ 48 | "/bin/node", 49 | "./node_modules/.bin/termost", 50 | "--lastOption", 51 | "lastValue", 52 | "watch", 53 | ]; 54 | 55 | expect(getArguments()).toStrictEqual({ 56 | command: "watch", 57 | operands: [], 58 | options: { 59 | lastOption: "lastValue", 60 | }, 61 | }); 62 | 63 | process.argv = [ 64 | "/bin/node", 65 | "./node_modules/.bin/termost", 66 | "--lastOption", 67 | "watch", 68 | "lastValue", 69 | ]; 70 | 71 | expect(getArguments()).toStrictEqual({ 72 | command: "lastValue", 73 | operands: [], 74 | options: { 75 | lastOption: "watch", 76 | }, 77 | }); 78 | }); 79 | 80 | test("should parse correctly given a last option value", () => { 81 | process.argv = [ 82 | "/bin/node", 83 | "./node_modules/.bin/termost", 84 | "watch", 85 | "--lastOption", 86 | "lastValue", 87 | ]; 88 | 89 | expect(getArguments()).toStrictEqual({ 90 | command: "watch", 91 | operands: [], 92 | options: { 93 | lastOption: "lastValue", 94 | }, 95 | }); 96 | }); 97 | }); 98 | -------------------------------------------------------------------------------- /termost/src/helpers/stdin/index.ts: -------------------------------------------------------------------------------- 1 | import process from "node:process"; 2 | 3 | export const getArguments = () => { 4 | const parameters = process.argv.slice(2); 5 | let command: string | undefined; 6 | const operands: string[] = []; 7 | const options: Record = {}; 8 | let currentOptionName: string | undefined; 9 | 10 | const addOptimisticOption = (name: string, value?: boolean | string) => { 11 | if (value) { 12 | options[name] = typeof value === "string" ? castValue(value) : true; 13 | } else { 14 | currentOptionName = name; 15 | } 16 | }; 17 | 18 | const flushOptimisticOption = () => { 19 | if (currentOptionName) { 20 | options[currentOptionName] = true; 21 | currentOptionName = undefined; 22 | } 23 | }; 24 | 25 | for (const parameter of parameters) { 26 | const shortFlagMatchResult = SHORT_FLAG_REGEX.exec(parameter)?.groups; 27 | const longFlagMatchResult = LONG_FLAG_REGEX.exec(parameter)?.groups; 28 | 29 | if (shortFlagMatchResult?.name) { 30 | flushOptimisticOption(); 31 | 32 | const optionFlags = [...shortFlagMatchResult.name]; 33 | const lastIndex = optionFlags.length - 1; 34 | 35 | optionFlags.forEach((flag, index) => { 36 | addOptimisticOption( 37 | flag, 38 | lastIndex === index ? undefined : true, 39 | ); 40 | }); 41 | } else if (longFlagMatchResult?.name) { 42 | flushOptimisticOption(); 43 | addOptimisticOption( 44 | longFlagMatchResult.name, 45 | longFlagMatchResult.value, 46 | ); 47 | } else if (currentOptionName) { 48 | options[currentOptionName] = castValue(parameter); 49 | currentOptionName = undefined; 50 | } else if (!command) { 51 | command = parameter; 52 | } else { 53 | operands.push(parameter); 54 | } 55 | } 56 | 57 | flushOptimisticOption(); 58 | 59 | return { command, operands, options }; 60 | }; 61 | 62 | const SHORT_FLAG_REGEX = /^-(?(?!-).*)$/; 63 | const LONG_FLAG_REGEX = /^--(?.*?)(?:=(?.+))?$/; 64 | 65 | const castValue = (value: string) => { 66 | try { 67 | return JSON.parse(value) as boolean | number | string; 68 | } catch { 69 | return value; 70 | } 71 | }; 72 | -------------------------------------------------------------------------------- /termost/src/helpers/stdout/index.ts: -------------------------------------------------------------------------------- 1 | import pico from "picocolors"; 2 | 3 | /** 4 | * A helper to format an arbitrary text as a message input. 5 | * @param message - The text to display. 6 | * @param options - The configuration object to control the formatting properties. 7 | * @param options.color - The color to apply. 8 | * @param options.modifiers - The modifiers to apply (can be italic, bold, ...). 9 | * @returns The formatted text. 10 | * @example 11 | * const formattedMessage = format("my message"); 12 | */ 13 | export const format = ( 14 | message: string, 15 | options: { 16 | color?: Color; 17 | modifiers?: Modifier[]; 18 | } = {}, 19 | ) => { 20 | const { color = "white", modifiers = [] } = options; 21 | const transformers: ((input: string) => string)[] = []; 22 | 23 | transformers.push(pico[colorMapper[color]]); 24 | 25 | modifiers.forEach((modifier: Modifier) => { 26 | if (modifier === "uppercase") { 27 | message = message.toUpperCase(); 28 | } else if (modifier === "lowercase") { 29 | message = message.toLowerCase(); 30 | } else { 31 | transformers.push(pico[modifierMapper[modifier]]); 32 | } 33 | }); 34 | 35 | return compose(...transformers)(message); 36 | }; 37 | 38 | /** 39 | * An opinionated helper to display arbitrary text on the console. 40 | * @param content - The content to display. A content can be either a string or an error. 41 | * @param options - The configuration object to define the display type and/or override the default label. 42 | * @param options.label - The label to display. 43 | * @param options.type - The message type. 44 | * @param options.lineBreak - Configure line break addition. 45 | * @example 46 | * message("message to log"); 47 | */ 48 | export const message = ( 49 | content: Error | string, 50 | { 51 | label: optionLabel, 52 | lineBreak: optionlineBreak, 53 | type: optionType, 54 | }: { 55 | label?: string | false; 56 | lineBreak?: LineBreakByPosition | boolean; 57 | type?: MessageType; 58 | } = {}, 59 | ) => { 60 | const isTextualContent = typeof content === "string"; 61 | const type = optionType ?? (isTextualContent ? "information" : "error"); 62 | const { color, defaultLabel, icon, method } = formatPropertiesByType[type]; 63 | const hasNoLabel = optionLabel === false; 64 | 65 | const getLineBreak = (): LineBreakByPosition => { 66 | if (optionlineBreak === undefined) { 67 | return { 68 | end: false, 69 | start: false, 70 | }; 71 | } 72 | 73 | if (isRecord(optionlineBreak)) { 74 | return optionlineBreak; 75 | } 76 | 77 | return { 78 | end: optionlineBreak, 79 | start: optionlineBreak, 80 | }; 81 | }; 82 | 83 | const getLabel = () => { 84 | if (hasNoLabel) { 85 | return isTextualContent ? content : content.message; 86 | } 87 | 88 | return optionLabel ?? defaultLabel; 89 | }; 90 | 91 | const lineBreak = getLineBreak(); 92 | 93 | const output = [ 94 | format(`${lineBreak.start ? "\n" : ""}${icon} ${getLabel()}`, { 95 | color, 96 | modifiers: ["bold"], 97 | }), 98 | !hasNoLabel && isTextualContent 99 | ? format(` ${content}`, { color }) 100 | : undefined, 101 | !isTextualContent 102 | ? // Do not format error with colors to preserve the stack trace: 103 | content 104 | : undefined, 105 | ].filter(Boolean); 106 | 107 | output.forEach((item) => { 108 | method(item); 109 | }); 110 | 111 | if (lineBreak.end) { 112 | method(); 113 | } 114 | }; 115 | 116 | type LineBreakByPosition = { end: boolean; start: boolean }; 117 | 118 | const isRecord = (value: unknown): value is Record => { 119 | return typeof value === "object" && value !== null && !Array.isArray(value); 120 | }; 121 | 122 | const compose = (...functions: ((a: T) => T)[]) => { 123 | if (!functions[0]) 124 | throw new Error( 125 | "No function is provided, defeating the purpose of composing functions. Make sure to provide at least one function as an argument.", 126 | ); 127 | 128 | return functions.reduce<(a: T) => T>( 129 | (previousFunction, nextFunction) => (value) => 130 | previousFunction(nextFunction(value)), 131 | functions[0], 132 | ); 133 | }; 134 | 135 | const formatPropertiesByType = { 136 | error: { 137 | color: "red", 138 | defaultLabel: "Error", 139 | icon: "❌", 140 | method: console.error, 141 | }, 142 | information: { 143 | color: "blue", 144 | defaultLabel: "Information", 145 | icon: "ℹ️", 146 | method: console.info, 147 | }, 148 | success: { 149 | color: "green", 150 | defaultLabel: "Success", 151 | icon: "✅", 152 | method: console.log, 153 | }, 154 | warning: { 155 | color: "yellow", 156 | defaultLabel: "Warning", 157 | icon: "⚠️ ", 158 | method: console.warn, 159 | }, 160 | } as const; 161 | 162 | const colorMapper = { 163 | black: "black", 164 | blue: "blue", 165 | cyan: "cyan", 166 | green: "green", 167 | grey: "gray", 168 | magenta: "magenta", 169 | red: "red", 170 | white: "white", 171 | yellow: "yellow", 172 | } as const; 173 | 174 | const modifierMapper = { 175 | bold: "bold", 176 | italic: "italic", 177 | strikethrough: "strikethrough", 178 | underline: "underline", 179 | } as const; 180 | 181 | type MessageType = "error" | "information" | "success" | "warning"; 182 | 183 | type Color = 184 | | "black" 185 | | "blue" 186 | | "cyan" 187 | | "green" 188 | | "grey" 189 | | "magenta" 190 | | "red" 191 | | "white" 192 | | "yellow"; 193 | 194 | type Modifier = 195 | | "bold" 196 | | "italic" 197 | | "lowercase" 198 | | "strikethrough" 199 | | "underline" 200 | | "uppercase"; 201 | -------------------------------------------------------------------------------- /termost/src/index.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, test } from "vitest"; 2 | 3 | import { exec } from "./helpers/process"; 4 | 5 | describe("termost", () => { 6 | test("should display `version`", async () => { 7 | const longFlagOutput = await safeExec( 8 | "pnpm --filter @examples/default start --version", 9 | ); 10 | 11 | const shortFlagOutput = await safeExec( 12 | "pnpm --filter @examples/default start -v", 13 | ); 14 | 15 | expect(longFlagOutput).toMatchSnapshot(); 16 | expect(shortFlagOutput).toMatchSnapshot(); 17 | }); 18 | 19 | test("should display `help`", async () => { 20 | const longFlagOutput = await safeExec( 21 | "pnpm --filter @examples/default start --help", 22 | ); 23 | 24 | const shortFlagOutput = await safeExec( 25 | "pnpm --filter @examples/default start -h", 26 | ); 27 | 28 | expect(longFlagOutput).toMatchSnapshot(); 29 | expect(shortFlagOutput).toMatchSnapshot(); 30 | }); 31 | 32 | test("should display `help` given empty command", async () => { 33 | const rootCommand = await safeExec( 34 | "pnpm --filter @examples/empty start", 35 | ); 36 | 37 | const buildCommand = await safeExec( 38 | "pnpm --filter @examples/empty start build", 39 | ); 40 | 41 | const buildCommandWithOption = await safeExec( 42 | "pnpm --filter @examples/empty start build --option test", 43 | ); 44 | 45 | const watchCommand = await safeExec( 46 | "pnpm --filter @examples/empty start watch", 47 | ); 48 | 49 | expect(rootCommand).toMatchSnapshot(); 50 | expect(buildCommand).toMatchSnapshot(); 51 | expect(buildCommandWithOption).toMatchSnapshot(); 52 | expect(watchCommand).toMatchSnapshot(); 53 | }); 54 | 55 | test("should handle `command` api", async () => { 56 | const helpOutput = await safeExec( 57 | "pnpm --filter @examples/command start --help", 58 | ); 59 | 60 | const buildOutput = await safeExec( 61 | "pnpm --filter @examples/command start build", 62 | ); 63 | 64 | const watchOutput = await safeExec( 65 | "pnpm --filter @examples/command start watch", 66 | ); 67 | 68 | const buildSharedFlagOutput = await safeExec( 69 | "pnpm --filter @examples/command start build --global --local hello", 70 | ); 71 | 72 | const watchSharedFlagOutput = await safeExec( 73 | "pnpm --filter @examples/command start watch --global", 74 | ); 75 | 76 | const buildHelpOutput = await safeExec( 77 | "pnpm --filter @examples/command start build --help", 78 | ); 79 | 80 | const watchHelpOutput = await safeExec( 81 | "pnpm --filter @examples/command start watch --help", 82 | ); 83 | 84 | expect(helpOutput).toMatchSnapshot(); 85 | expect(buildOutput).toMatchSnapshot(); 86 | expect(watchOutput).toMatchSnapshot(); 87 | expect(buildSharedFlagOutput).toMatchSnapshot(); 88 | expect(watchSharedFlagOutput).toMatchSnapshot(); 89 | expect(buildHelpOutput).toMatchSnapshot(); 90 | expect(watchHelpOutput).toMatchSnapshot(); 91 | }); 92 | 93 | test("should handle `option` api", async () => { 94 | const output = await safeExec("pnpm --filter @examples/option start"); 95 | 96 | expect(output).toMatchSnapshot(); 97 | }); 98 | 99 | test("should handle `task` api", async () => { 100 | const output = await safeExec("pnpm --filter @examples/task start"); 101 | 102 | expect(output).toMatchSnapshot(); 103 | }); 104 | }); 105 | 106 | /** 107 | * A test utility to strip contextual information (including absolute paths) output by `pnpm --filter run` command. 108 | * It allows to run tests whatever the testing environment (CI, local, ...). 109 | * @param command - The command to run. 110 | * @returns The generic command output. 111 | * @example 112 | * const shortFlagOutput = await safeExec( 113 | * "pnpm --filter @examples/default start -h", 114 | * ); 115 | */ 116 | const safeExec = async (command: string) => { 117 | const output = await exec(command); 118 | 119 | return output.toString().split("\n").slice(3).join("\n"); 120 | }; 121 | -------------------------------------------------------------------------------- /termost/src/index.ts: -------------------------------------------------------------------------------- 1 | import { format, message } from "./helpers/stdout"; 2 | import { exec } from "./helpers/process"; 3 | 4 | export { termost } from "./termost"; 5 | export type { Termost } from "./termost"; 6 | export const helpers = { 7 | exec, 8 | format, 9 | message, 10 | }; 11 | -------------------------------------------------------------------------------- /termost/src/termost.ts: -------------------------------------------------------------------------------- 1 | import process from "node:process"; 2 | 3 | import type { 4 | CommandName, 5 | CreateInstruction, 6 | EmptyObject, 7 | InstructionParameters, 8 | ObjectLikeConstraint, 9 | PackageMetadata, 10 | ProgramMetadata, 11 | } from "./types"; 12 | import { message } from "./helpers/stdout"; 13 | import { getArguments } from "./helpers/stdin"; 14 | import { createTask } from "./api/task"; 15 | import type { TaskParameters } from "./api/task"; 16 | import { createOption } from "./api/option"; 17 | import type { OptionParameters } from "./api/option"; 18 | import { createInput } from "./api/input"; 19 | import type { InputParameters } from "./api/input"; 20 | import { createCommand, getCommandController } from "./api/command"; 21 | import type { CommandParameters } from "./api/command"; 22 | 23 | /** 24 | * The termost fluent interface API. 25 | */ 26 | export type Termost = { 27 | /** 28 | * Allows to attach a new sub-command to the program. 29 | * @param name - The CLI command name. 30 | * @param description - The CLI command description. 31 | * @returns The Command API. 32 | */ 33 | command: ( 34 | parameters: CommandParameters, 35 | ) => Termost; 36 | input: ( 37 | parameters: InputParameters, 38 | ) => Termost; 39 | option: ( 40 | parameters: OptionParameters, 41 | ) => Termost; 42 | task: ( 43 | parameters: TaskParameters, 44 | ) => Termost; 45 | }; 46 | 47 | export function termost({ 48 | name, 49 | description, 50 | onException, 51 | onShutdown, 52 | version, 53 | }: PackageMetadata & TerminationCallbacks) { 54 | const { command = name, operands, options } = getArguments(); 55 | 56 | setGracefulListeners({ onException, onShutdown }); 57 | 58 | return createProgram({ 59 | name, 60 | description, 61 | argv: { command, operands, options }, 62 | isEmptyCommand: {}, 63 | version, 64 | }); 65 | } 66 | 67 | export const createProgram = ( 68 | metadata: ProgramMetadata, 69 | ): Termost => { 70 | const { name, description, argv } = metadata; 71 | const rootCommandName: CommandName = name; 72 | let currentCommandName: CommandName = rootCommandName; 73 | 74 | const createInstruction = < 75 | Parameters extends InstructionParameters, 76 | >( 77 | factory: CreateInstruction, 78 | parameters: InstructionParameters, 79 | ) => { 80 | const instruction = factory(parameters as Parameters); 81 | const controller = getCommandController(currentCommandName); 82 | 83 | controller.addInstruction(async () => { 84 | const { skip } = parameters; 85 | const context = controller.getContext(rootCommandName); 86 | 87 | if (skip?.(context, argv)) return; 88 | 89 | const output = await instruction(context, argv); 90 | 91 | if (!output || !output.key) return; 92 | 93 | controller.addValue( 94 | output.key, 95 | output.value as Values[keyof Values], 96 | ); 97 | }); 98 | }; 99 | 100 | const program: Termost = { 101 | command(parameters: CommandParameters) { 102 | currentCommandName = createCommand(parameters, metadata); 103 | metadata.isEmptyCommand[currentCommandName] = true; // This flag is disabled only for instructions that introduce stdio side effects (`option` instructions are so ignored). 104 | 105 | return this as Termost; 106 | }, 107 | input(parameters) { 108 | createInstruction(createInput, parameters); 109 | metadata.isEmptyCommand[currentCommandName] = false; 110 | 111 | return this; 112 | }, 113 | option(parameters) { 114 | createInstruction( 115 | createOption( 116 | getCommandController(currentCommandName), 117 | metadata, 118 | ), 119 | parameters, 120 | ); 121 | 122 | return this; 123 | }, 124 | task(parameters) { 125 | createInstruction(createTask, parameters); 126 | metadata.isEmptyCommand[currentCommandName] = false; 127 | 128 | return this; 129 | }, 130 | }; 131 | 132 | // @note: the root command is created by default 133 | program.command({ 134 | name, 135 | description, 136 | }); 137 | 138 | return program; 139 | }; 140 | 141 | type TerminationCallbacks = Partial<{ 142 | onException: ((error: Error) => void) | undefined; 143 | onShutdown: (() => void) | undefined; 144 | }>; 145 | 146 | const setGracefulListeners = ({ 147 | onException = () => { 148 | return; 149 | }, 150 | onShutdown = () => { 151 | return; 152 | }, 153 | }: TerminationCallbacks) => { 154 | process.on("SIGTERM", () => { 155 | onShutdown(); 156 | process.exit(0); 157 | }); 158 | 159 | process.on("SIGINT", () => { 160 | onShutdown(); 161 | process.exit(0); 162 | }); 163 | 164 | process.on("uncaughtException", (error) => { 165 | onException(error); 166 | message(error); 167 | process.exit(1); 168 | }); 169 | 170 | process.on("unhandledRejection", (reason) => { 171 | if (reason instanceof Error) { 172 | onException(reason); 173 | message(reason); 174 | } 175 | 176 | process.exit(1); 177 | }); 178 | }; 179 | -------------------------------------------------------------------------------- /termost/src/types.ts: -------------------------------------------------------------------------------- 1 | export type CommandName = string; 2 | 3 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 4 | export type ObjectLikeConstraint = Record; 5 | 6 | // eslint-disable-next-line @typescript-eslint/no-empty-object-type 7 | export type EmptyObject = {}; 8 | 9 | /** 10 | * Raw CLI arguments parsed from user inputs. 11 | */ 12 | export type ArgumentValues = { 13 | command: CommandName; 14 | operands: string[]; 15 | options: Record; 16 | }; 17 | 18 | export type PackageMetadata = { 19 | name: string; 20 | description: string; 21 | version: string; 22 | }; 23 | 24 | export type ProgramMetadata = PackageMetadata & { 25 | argv: ArgumentValues; 26 | isEmptyCommand: Record; 27 | }; 28 | 29 | export type Context = Values; 30 | 31 | export type InstructionParameters< 32 | Values extends ObjectLikeConstraint, 33 | ExtraParameters extends ObjectLikeConstraint = EmptyObject, 34 | > = ExtraParameters & { 35 | skip?: (context: Context, argv: ArgumentValues) => boolean; 36 | }; 37 | 38 | export type InstructionKey = { 39 | /** 40 | * Makes the method output available in the context object. 41 | * By default, if no provided key, the output is not included in the context. 42 | */ 43 | key: Key; 44 | }; 45 | 46 | /** 47 | * Follows the command design pattern. 48 | */ 49 | export type CreateInstruction< 50 | Parameters extends InstructionParameters, 51 | > = (parameters: Parameters) => Instruction; 52 | 53 | type Instruction = ( 54 | context: Context, 55 | argv: ArgumentValues, 56 | ) => Promise< 57 | | (Partial> & { 58 | value: ObjectLikeConstraint[number]; 59 | }) 60 | | null 61 | >; 62 | 63 | export type Label = 64 | | string 65 | | ((context: Context, argv: ArgumentValues) => string); 66 | -------------------------------------------------------------------------------- /termost/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../tsconfig.json", 3 | "include": ["src"], 4 | "exclude": ["node_modules", "dist"] 5 | } 6 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@adbayb/stack/typescript", 3 | "exclude": ["node_modules", "**/node_modules", "**/dist"] 4 | } 5 | -------------------------------------------------------------------------------- /turbo.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://turborepo.org/schema.json", 3 | "tasks": { 4 | "build": { 5 | "dependsOn": ["^build"], 6 | "outputs": ["dist/**"] 7 | }, 8 | "start": { 9 | "dependsOn": ["build"], 10 | "cache": false, 11 | "persistent": true 12 | }, 13 | "watch": { 14 | "dependsOn": ["^build"], 15 | "cache": false, 16 | "persistent": true 17 | }, 18 | "test": { 19 | "dependsOn": ["build"], 20 | "cache": false, 21 | "persistent": true 22 | } 23 | } 24 | } 25 | --------------------------------------------------------------------------------