'How to build a TypeScript shell command that installs portably and globally?

I would like to build a command-line tool written in TypeScript that becomes available in the $PATH when installed. My requirements:

  • I can run and test it from within the project folder (e.g., yarn command, npm run command)
  • It should be installable via npm install -g mycommand
  • When installed, it can be invoked like a regular shell command
  • It doesn't require other NPM modules to be explicitly globally installed
  • (soft requirement) I'd rather avoid having to use Webpack

A common solution is to use ts-node in the shebang line (#!/usr/bin/env ts-node) but that requires that ts-node is globally installed.

What I have so far...

My project structure:

├── cli.ts
├── package.json
├── tsconfig.json
└── util.ts

package.json:

{
  "name": "mycommand",
  "version": "1.0.0",
  "main": "./bin/cli.js",
  "bin": {
    "mycommand": "./bin/cli.js"
  },
  "scripts": {
    "build": "tsc -p .",
    "mycommand": "ts-node ./cli.ts",
  },
  "dependencies": {
    "@types/cli-color": "^2.0.2",
    "@types/inquirer": "^8.2.1",
    "@types/node": "^17.0.29",
    "@types/shelljs": "^0.8.11",
    "@types/yargs": "^17.0.10",
    "cli-color": "^2.0.2",
    "inquirer": "^8.2.3",
    "shelljs": "^0.8.4",
    "ts-node": "^10.7.0",
    "typescript": "^4.6.3",
    "yargs": "^17.4.1"
  }
}

tsconfig.json:

{
  "compilerOptions": {
    "outDir": "bin",
    "composite": true,
    "incremental": true,
    "strict": true,
    "target": "ES5",
    "esModuleInterop": true,
    "experimentalDecorators": true,
    "emitDecoratorMetadata": false,
    "noUnusedLocals": true,
    "declaration": true,
    "declarationMap": true,
    "downlevelIteration": true,
    "moduleResolution": "node",
    "importHelpers": true,
    "module": "ESNext",
    "skipLibCheck": true,
    "sourceMap": true,
    "useDefineForClassFields": true,
    "forceConsistentCasingInFileNames": true
  },
  "ts-node": {
    "compilerOptions": {
      "target": "ES2017",
      "module": "commonjs"
    }
  }
}

cli.ts:

#!/usr/bin/env ts-node

import * as yargs from "yargs";
import * from "./util";

yargs
    .command({
        // ...
    })
    .help()
    .vargs

This allows me to run the script locally using yarn mycommand and to install it via yarn install -g but the shell complains that ts-node is not found (since the shebang line.



Solution 1:[1]

Three modifications that need to be made:

  1. The compilerOptions in tsconfig.json need to output the correct module type supported by the version of Node:

      ...
      "lib": [
        "es2021"
      ],
      "module": "UMD",
      "target": "es2021",
      ...
    

    The TSConfig project has recommended bases for various environments. The above snippet is based on the recommendation for Node 16+, which my project targets.

  2. The shebang line should use node, not ts-node. Running it locally through a Yarn/NPM script overrides it anyway:

    #!/usr/bin/env node
    
  3. Add a preinstall script to transpile the TypeScript file into JavaScript:

      ...
      "scripts": {
        "preinstall": "yarn build",
      ...
    

Sources

This article follows the attribution requirements of Stack Overflow and is licensed under CC BY-SA 3.0.

Source: Stack Overflow

Solution Source
Solution 1 Gingi