Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

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

Closed
KasparEtter opened this issue Apr 3, 2020 · 7 comments
Closed

Comments

@KasparEtter
Copy link
Contributor

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!)

@cspotcode
Copy link
Collaborator

Shebangs only allow passing a single argument to the executable. So you cannot do any more than this:

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

This is a Linux kernel limitation. Linux splits on the first space and nowhere else, and everything before the first space needs to be an absolute path. You're probably on Mac, which has a non-standard shebang implementation in its kernel.

ts-node-script is, indeed, the recommended entrypoint because it locates a tsconfig relative to the location of the script, not to cwd. You can specify all other ts-node options inside the tsconfig file. We support a "ts-node" sub-object inside your tsconfig file, for options like "transpileOnly."

I recommend using a bootstrapper script to keep your stuff portable. This will use the local installations of ts-node and typescript, so you don't need to install anything globally.

#!/usr/bin/env node
// cli-bootstrap.js
require('ts-node').register();
require('./my-cli.ts');

@KasparEtter
Copy link
Contributor Author

Thanks for your suggestion, @cspotcode. I tried it and it didn't work for me. My ~/projects/ts-hello/package.json is now:

{
    "name": "ts-hello",
    "version": "0.0.0",
    "private": true,
    "bin": {
        "hello": "src/cli-bootstrap.js"
    },
    "dependencies": {},
    "devDependencies": {
        "@types/node": "^13.11.0",
        "ts-node": "^8.8.1",
        "typescript": "^3.8.3"
    }
}

… and I added ~/projects/ts-hello/src/cli-bootstrap.js as you suggested:

#!/usr/bin/env node

require('ts-node').register();
require('./hello.ts');

In order to link the new script, I run:

$ cd ~/projects/ts-hello/
$ npm unlink
removed 1 package in 0.079s
$ npm link
audited 10 packages in 1.247s
found 0 vulnerabilities

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

As long as I'm in the project directory, the command works fine. As soon as I step out of it, I get the same errors again:

$ cd ~/projects/ts-hello/
$ hello a b
Hello, <user>!
Your arguments: [ 'a', 'b' ]
$ cd ..
$ hello a b

~/projects/ts-hello/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 (~/projects/ts-hello/node_modules/ts-node/src/index.ts:421:12)
    at reportTSError (~/projects/ts-hello/node_modules/ts-node/src/index.ts:425:19)
    at getOutput (~/projects/ts-hello/node_modules/ts-node/src/index.ts:553:36)
    at Object.compile (~/projects/ts-hello/node_modules/ts-node/src/index.ts:758:32)
    at Module.m._compile (~/projects/ts-hello/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] (~/projects/ts-hello/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 Module.require (internal/modules/cjs/loader.js:1036:19)

If we can get this bootstrap script to work, then this is probably the best solution for now (even though it's a hassle if you want to expose many command line scripts from the same package). Regarding passing multiple arguments to the executable in the shebang, I referenced the documentation and a hack around the Linux kernel limitation.

To make my questions and feature requests as concrete as possible:

  1. Do you agree that what I (and so many others) try to achieve is not an edge case but rather what one would legitimately expect from a tool like ts-node?
  2. Does anything speak against simply checking whether the script path references a symbolic link and if so follow the link before returning the directory path? Being able to just use #!/usr/bin/env ts-node-script would make life so much easier.
  3. Is there a reason why --script-mode is not enabled by default? As far as I can judge, this would make ts-node behave more like node, which should be desirable.
  4. Is there a reason why neither --script-mode nor ts-node-script is documented in the README? Their use is not deprecated or discouraged, right? (Furthermore, "Typescript Node loads tsconfig.json automatically." is not very informative and even inaccurate in the use case that I describe in this issue.)

@cspotcode
Copy link
Collaborator

You'll probably need to pass flags to register(), telling it which tsconfig to load.

For your use-case, is your package meant to be used by anyone else? Or only yourself? If the former, we expect you'll pre-compile and publish to npm, or at least npm pack into a tarball that can be installed by others. You're transpiling and type-checking at script startup, which is slowing down the user experience. For stuff that is only meant for myself, I hate pre-compiling, but I still enable --transpile-only to speed it up.

As to whether script-mode should be the default, that discussion should happen in #949. There's a fair bit already there, especially when you read the linked thread from another ticket. Give it a look and comment on that ticket if we're missing anything.

IMO resolving symlinks should match the default behavior of nodejs. Node has --preserve-symlinks, and TypeScript has --preserveSymlinks, to toggle this behavior. We should probably match --preserveSymlinks since we already load and have easy access to the TS CompilerOptions. There have been other issues where resolving a symlink into a node_modules subdirectory makes the TS compiler refuse to compile it. There's a pragmatic short-term solution: don't resolve the symlink; and a proper long-term solution: implement a custom TS module resolver. Re the code you linked, sounds like a bug to me, but we'll need to test that it doesn't break due to the node_modules quirk.

Typescript does load tsconfig.json automatically. The question is where it attempts to locate it. tsc uses your cwd, perhaps the initial implementation tried to mimick that? Discussion should happen in #949.

Sometimes features evolve or are added without the README receiving an update. They may only be documented via API tooltips and the tsconfig JSON schema. We are generally happy to merge PRs that improve the docs. (yay open source!) Omission from the README is not necessarily an indicator that a feature is discouraged, deprecated, or buggy. In this case, I definitely encourage --script-mode, and if it's buggy, we should fix it.

I hope this answers everything!

@KasparEtter
Copy link
Contributor Author

Thanks a lot for your elaborate response! You make a lot of good points, especially regarding the delivery of my scripts. While I studied computer science, I'm still relatively new to this whole "Node.js world" and there is still a lot of stuff that confuses me. (And Node.js has only become an option for me since I discovered TypeScript. 🤓) For now, I'm mostly trying to figure out a reasonable toolchain for myself without concerns about performance but a big focus on quick iterations. Thus, I guess I also fall in the "for personal stuff I hate pre-compilation" camp. 😉 (I just want an alternative to Bash scripts that I can tweak whenever my use case changes slightly without having to think about compilation.)

We support a "ts-node" sub-object inside your tsconfig file, for options like "transpileOnly."

This is a great suggestion and I already wanted to ask you for an example because I wasn't sure about the nesting and the transformation from kebab-case to camelCase but I found one: 🙂

{
  "ts-node": {
    "transpileOnly": true
  },
  "compilerOptions": {}
}

If I make a pull request later on in order to improve the documentation in the README, I would include this as well. (As far as I can judge by just skimming the README once more, this feature is also not documented there.)

I'm confused about --preserveSymlinks. TypeScript's default is false, which means that symlinks would be resolved. If you say that the linked code looks like a bug, you mean that the preserveSymlinks flag should be considered there? (And given that by default the symlink would be resolved, I would get the behavior I wanted and could then simply use #!/usr/bin/env ts-node-script? 🙄) If not, what is the bug and will you file a separate issue for that?

KasparEtter added a commit to KasparEtter/ts-node that referenced this issue Apr 7, 2020
KasparEtter added a commit to KasparEtter/ts-node that referenced this issue Apr 7, 2020
@KasparEtter
Copy link
Contributor Author

Here is a pull request that addresses the missing documentation in the README, @cspotcode: #1000

cspotcode added a commit that referenced this issue Apr 20, 2020
* Improve the coverage of the README (#995)

* Revert formatting changes

* revert formatting changes

* revert formatting changes

* Add explanation for -vv flag

* tweaking docs

* more tweaks

Co-authored-by: Andrew Bradley <cspotcode@gmail.com>
@cspotcode
Copy link
Collaborator

cspotcode commented Apr 20, 2020

Closing as answered, and all necessary work is tracked by other tickets.

@hpx7
Copy link

hpx7 commented May 18, 2020

@KasparEtter what solution did you end up going with? I'm also trying to use #!/usr/bin/env ts-node-script but am finding that it doesn't work outside the project directory (so I can't use it with npm install -g). Is there any way to get it working without having to precompile the project?

EDIT: it's working now -- I had to upgrade my globally installed version of ts-node

D-Pow added a commit to D-Pow/react-app-boilerplate that referenced this issue Jan 12, 2022
This allows `ts-node` to be used to run TypeScript files without having to compile them with `tsc` first. It also adds the necessary configs, including `tsconfig-paths` for `paths` import-alias resolution. Unfortunately, this means we have to remove the `--experimental-module-resolution=node` since `ts-node` uses its own loader and thus form of resolving modules.

See:

Setup
* https://medium.com/@jimcraft123hd/setting-up-path-alias-in-typescript-and-tsc-build-without-error-9f1dbc0bccd2

Issues with `ts-node`, ESM, and aliases
* TypeStrong/ts-node#1007
    - TypeStrong/ts-node#476
    - dividab/tsconfig-paths#122 (comment)
    - TypeStrong/ts-node#1450 (comment)
* TypeStrong/ts-node#1414
* TypeStrong/ts-node#995
    - TypeStrong/ts-node#639

Node issues with ESM
* https://nodejs.org/api/packages.html#determining-module-system
* nodejs/node#37468
D-Pow added a commit to D-Pow/react-app-boilerplate that referenced this issue Jan 12, 2022
This allows `ts-node` to be used to run TypeScript files without having to compile them with `tsc` first. It also adds the necessary configs, including `tsconfig-paths` for `paths` import-alias resolution. Unfortunately, this means we have to remove the `--experimental-module-resolution=node` since `ts-node` uses its own loader and thus form of resolving modules.

See:

Setup
* https://medium.com/@jimcraft123hd/setting-up-path-alias-in-typescript-and-tsc-build-without-error-9f1dbc0bccd2

Issues with `ts-node`, ESM, and aliases
* TypeStrong/ts-node#1007
    - TypeStrong/ts-node#476
    - dividab/tsconfig-paths#122 (comment)
    - TypeStrong/ts-node#1450 (comment)
* TypeStrong/ts-node#1414
* TypeStrong/ts-node#995
    - TypeStrong/ts-node#639

Node issues with ESM
* https://nodejs.org/api/packages.html#determining-module-system
* nodejs/node#37468
D-Pow added a commit to D-Pow/react-app-boilerplate that referenced this issue Jan 12, 2022
This allows `ts-node` to be used to run TypeScript files without having to compile them with `tsc` first. It also adds the necessary configs, including `tsconfig-paths` for `paths` import-alias resolution. Unfortunately, this means we have to remove the `--experimental-module-resolution=node` since `ts-node` uses its own loader and thus form of resolving modules.

See:

Setup
* https://medium.com/@jimcraft123hd/setting-up-path-alias-in-typescript-and-tsc-build-without-error-9f1dbc0bccd2

Issues with `ts-node`, ESM, and aliases
* TypeStrong/ts-node#1007
    - TypeStrong/ts-node#476
    - dividab/tsconfig-paths#122 (comment)
    - TypeStrong/ts-node#1450 (comment)
* TypeStrong/ts-node#1414
* TypeStrong/ts-node#995
    - TypeStrong/ts-node#639

Node issues with ESM
* https://nodejs.org/api/packages.html#determining-module-system
* nodejs/node#37468
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

3 participants