Skip to content

Is this really the best way to make a command line script with ts-node? #995

@KasparEtter

Description

@KasparEtter

Desired behavior

I would like to be able to have the following script at ~/projects/ts-hello/src/hello.ts:

#!/usr/bin/env ts-node

import os from 'os';

console.log(`Hello, ${os.userInfo().username}!`);
console.log('Your arguments:', process.argv.slice(2));

… and have npm link the executable with the following configuration at ~/projects/ts-hello/package.json:

{
    "name": "ts-hello",
    "version": "0.0.0",
    "private": true,
    "bin": {
        "hello": "src/hello.ts"
    },
    "dependencies": {},
    "devDependencies": {
        "@types/node": "^13.9.8"
    }
}

For this to work, both typescript and ts-node have to be installed globally (see more on this below):

$ npm install --global typescript ts-node

Additionally, we need to enable ES module interoperability with the following argument in ~/projects/ts-hello/tsconfig.json:

{
    "compilerOptions": {
        "esModuleInterop": true
    }
}

We can now change to the project directory, install the dependency and link the package:

$ cd ~/projects/ts-hello
$ npm install
$ npm link

Actual behavior

Since the motivation for this issue is in part to document the best approach for others (as far as I can tell a lot of people already struggled with this problem before me), I will elaborate step by step how to arrive at an acceptable solution so that search engines can index all the error messages as well.

First, let's test that the script works without relying on the shebang:

$ cd ~/projects/ts-hello
$ ts-node src/hello.ts first second
Hello, <user>!
Your arguments: [ 'first', 'second' ]

The problems start when you move a directory up:

$ cd ..
$ pwd
~/projects
$ ts-node ts-hello/src/hello.ts first second

/usr/local/lib/node_modules/ts-node/src/index.ts:421
    return new TSError(diagnosticText, diagnosticCodes)
           ^
TSError: ⨯ Unable to compile TypeScript:
ts-hello/src/hello.ts:7:16 - error TS2307: Cannot find module 'os'.

7 import os from 'os';
                 ~~~~
ts-hello/src/hello.ts:10:32 - error TS2580: Cannot find name 'process'. Do you need to install type definitions for node? Try `npm i @types/node`.

10 console.log('Your arguments:', process.argv.slice(2));
                                  ~~~~~~~

    at createTSError (/usr/local/lib/node_modules/ts-node/src/index.ts:421:12)
    at reportTSError (/usr/local/lib/node_modules/ts-node/src/index.ts:425:19)
    at getOutput (/usr/local/lib/node_modules/ts-node/src/index.ts:553:36)
    at Object.compile (/usr/local/lib/node_modules/ts-node/src/index.ts:758:32)
    at Module.m._compile (/usr/local/lib/node_modules/ts-node/src/index.ts:837:43)
    at Module._extensions..js (internal/modules/cjs/loader.js:1167:10)
    at Object.require.extensions.<computed> [as .ts] (/usr/local/lib/node_modules/ts-node/src/index.ts:840:12)
    at Module.load (internal/modules/cjs/loader.js:996:32)
    at Function.Module._load (internal/modules/cjs/loader.js:896:14)
    at Function.executeUserEntryPoint [as runMain] (internal/modules/run_main.js:71:12)

Since node ts-hello/src/hello.js first second works without problems given the following script at ~/projects/ts-hello/src/hello.js:

#!/usr/bin/env node

const os = require('os');

console.log(`Hello, ${os.userInfo().username}!`);
console.log('Your arguments:', process.argv.slice(2));

… this is disappointing but can easily be fixed by providing the TypeScript JSON project file explicitly as documented:

$ ts-node --project ts-hello/tsconfig.json ts-hello/src/hello.ts first second

(And no, this is not just due to os being a native Node.js module. node also picks up dependencies specified in ~/projects/ts-hello/package.json while ts-node does not. It would be great if ts-node could work as similar to node as possible, just on .ts instead of .js files, of course.)

You can simplify the above with the undocumented --script-mode option that I discovered:

$ ts-node --script-mode ts-hello/src/hello.ts first second

There is also an undocumented command that does the same:

$ ts-node-script ts-hello/src/hello.ts first second

(Given the usefulness of these options, why are they not mentioned in the README?)

Once we give our script the permission to execute it directly (based on the shebang in the first line) with chmod u+x ts-hello/src/hello.ts, ts-hello/src/hello.ts first second fails with the same errors as above (whereas ts-hello/src/hello.js first second runs just fine after giving it the necessary permission as well).

By now, we know how to fix this. Just replace the first line of ~/projects/ts-hello/src/hello.ts with:

#!/usr/bin/env ts-node-script

(It seems to me that ts-node-script exists for exactly this purpose. Why was it not recommended in #73, #298, and #639? And if we're already at it: #116 would also benefit from a reference to this issue.)

This solves the errors and at this point you could just add an alias to this script to your shell startup script:

$ echo "alias hello='~/projects/ts-hello/src/hello.ts'" >> ~/.bashrc

However, I would rather like to use the bin functionality of npm for this so that others could install my script easily if I decided to publish my package on www.npmjs.com.

As the output of the npm link command above told you, it created the following symlinks for your script and package:

/usr/local/bin/hello -> /usr/local/lib/node_modules/ts-hello/src/hello.ts
/usr/local/lib/node_modules/ts-hello -> ~/projects/ts-hello

So what happens if we use this linked command now?

$ hello

/usr/local/lib/node_modules/ts-node/src/index.ts:421
    return new TSError(diagnosticText, diagnosticCodes)
           ^
TSError: ⨯ Unable to compile TypeScript:
~/projects/ts-hello/src/hello.ts:7:16 - error TS2307: Cannot find module 'os'.

7 import os from 'os';
                 ~~~~
~/projects/ts-hello/src/hello.ts:10:32 - error TS2580: Cannot find name 'process'. Do you need to install type definitions for node? Try `npm i @types/node`.

10 console.log('Your arguments:', process.argv.slice(2));
                                  ~~~~~~~

    at createTSError (/usr/local/lib/node_modules/ts-node/src/index.ts:421:12)
    at reportTSError (/usr/local/lib/node_modules/ts-node/src/index.ts:425:19)
    at getOutput (/usr/local/lib/node_modules/ts-node/src/index.ts:553:36)
    at Object.compile (/usr/local/lib/node_modules/ts-node/src/index.ts:758:32)
    at Module.m._compile (/usr/local/lib/node_modules/ts-node/src/index.ts:837:43)
    at Module._extensions..js (internal/modules/cjs/loader.js:1167:10)
    at Object.require.extensions.<computed> [as .ts] (/usr/local/lib/node_modules/ts-node/src/index.ts:840:12)
    at Module.load (internal/modules/cjs/loader.js:996:32)
    at Function.Module._load (internal/modules/cjs/loader.js:896:14)
    at Function.executeUserEntryPoint [as runMain] (internal/modules/run_main.js:71:12)

Back to field one. Since --script-mode just determines the directory name from the script path (see the code) and the script is located in /usr/local/bin whereas the package exists (resp. is linked) at /usr/local/lib/node_modules/ts-hello, this couldn't work.

So how do we solve this? The best I could come up with is:

#!/usr/bin/env -S ts-node --project /usr/local/lib/node_modules/ts-hello/tsconfig.json

A few explanations:

  • -S allows you to pass arguments to the specified interpreter.
  • If this doesn't work on your platform, you can try to hack around this limitation.
  • Ideally, the path to the global node_modules directory would be determined dynamically to be more platform-independent but I couldn't get #!/usr/bin/env -S ts-node --project $(npm get prefix)/lib/node_modules/ts-hello/tsconfig.json to work.
  • While it might be tempting to use #!/usr/bin/env -S npx ts-node --project /usr/local/lib/node_modules/ts-hello/tsconfig.json in order not to require a global installation of ts-node, the user needs to install typescript manually globally anyway as typescript is only a peer dependency of ts-node. (Is there a way around this?)

Coming back to the original question: Is this currently really the best way to make a command line script with ts-node? (My solution is both complicated and platform-dependent.)

If yes, it would be so much easier (and it would have saved me hours) if --script-mode could follow the symbolic link before determining the directory. (And the README should state prominently that the correct shebang to use is #!/usr/bin/env ts-node-script!)

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions