Repository: mattallty/Caporal.js (ts)

Issue:
Add ability to display stacktraces from unhandled exceptions
**Is your feature request related to a problem? Please describe.**
When the code inside of a command throws an exception that is not caught caporal prints the message of the exception that is thrown and has the process exit with code 2.  When this thrown exception is the result of bad inputs or failures to access resources (e.g. internet, files, etc), this behavior is OK.  However, when an exception is thrown because of poor programming logic on the part of the command author, it's very difficult to diagnose what has happened, expectially when the exception is thrown by dependency libraries or node built-ins.  It would be nice if there was some way that the stack trace could be rendered to fix those cases.

**Describe the solution you'd like**
When the verbose flag is set and an unhandled exception is thrown from inside a command, print out the full stack trace of the exception in addition to the exception's message.  When the verbose flag is not set, continue to only print out the exception's message.

**Describe alternatives you've considered**
Adding another flag or environment variable to toggle this behavior is an option, but extending existing flags is probably more desirable.

**Additional context**
The documentation so far doesn't really lay out best practices for errors and exceptions.

**Would you be able to work on it and provide a pull request ?**

- [x] Yes


Pull request description:
feat: Add ability to display stacktraces from unhandled exceptions
If the -v / --verbose flag is set and an unhandled error is thrown, the stacktract of that error
will be rendered.  Fixed some bugs related to some unhandled exceptions not being handled correctly.
Add .idea to .gitignore.

Fixes #166

Patch:
diff --git a/.gitignore b/.gitignore
index 13b9db6..89cb36e 100644
--- a/.gitignore
+++ b/.gitignore
@@ -7,3 +7,4 @@ coverage
 docs
 docs-src
 local_tests
+.idea
diff --git a/index.d.ts b/index.d.ts
index 7558baa..ddb9a05 100644
--- a/index.d.ts
+++ b/index.d.ts
@@ -27,7 +27,7 @@ declare class Caporal {
 
     help(helpText: string, helpOptions?: helpOptions): Caporal;
 
-    command(synospis: string, description: string): Command;
+    command(synopsis: string, description: string): Command;
 
     action(cb: ActionCallback): Caporal;
 
diff --git a/lib/command.js b/lib/command.js
index 9b4e084..c40326c 100644
--- a/lib/command.js
+++ b/lib/command.js
@@ -479,13 +479,17 @@ class Command extends GetterSetter {
         this._program
       ));
     }
-    const actionResults = this._action.apply(this, [args, options, this._logger]);
-    const response = Promise.resolve(actionResults);
-    return response
-      .catch(err => {
-        err = err instanceof Error ? err : new Error(err);
-        throw this._program.fatalError(err);
-      })
+    try {
+      const actionResults = this._action.apply(this, [args, options, this._logger]);
+      return Promise.resolve(actionResults)
+        .catch(err => {
+          err = err instanceof Error ? err : new Error(err);
+          throw this._program.fatalError(err);
+        })
+    } catch (err) {
+      const e = err instanceof Error ? err : new Error(err);
+      throw this._program.fatalError(e);
+    }
   }
 
   /**
diff --git a/lib/program.js b/lib/program.js
index f8386c1..fbbee64 100644
--- a/lib/program.js
+++ b/lib/program.js
@@ -87,7 +87,11 @@ class Program extends GetterSetter {
    * @private
    */
   fatalError(errObj) {
-    this.logger().error("\n" + errObj.message);
+    if(this.verbose) {
+      this.logger().error("\n" + errObj.stack);
+    } else {
+      this.logger().error("\n" + errObj.message);
+    }
     process.exit(2);
   }
 
@@ -158,6 +162,7 @@ class Program extends GetterSetter {
     // verbose mode
     } else if (options.v || options.verbose) {
       this._changeLogLevel('debug');
+      this.verbose = true;
     }
 
     let validated;


Test patch:
diff --git a/tests/fatal-error.js b/tests/fatal-error.js
index 6050019..c207d29 100644
--- a/tests/fatal-error.js
+++ b/tests/fatal-error.js
@@ -1,14 +1,18 @@
 "use strict";
 
-/* global Program, logger, should, sinon */
+/* global Program, logger, should, sinon, makeArgv */
 
-const program = new Program();
+let program;
 
-program.
-  logger(logger)
-  .version('1.0.0');
+describe("program.fatalError()", () => {
 
-describe("program.fataError()", () => {
+  beforeEach(function () {
+    program = new Program();
+
+    program
+      .logger(logger)
+      .version('1.0.0');
+  });
 
   it(`should call logger.error() and exit(2)`, () => {
     const error = sinon.stub(logger, 'error').withArgs("\nfoo");
@@ -20,7 +24,87 @@ describe("program.fataError()", () => {
     should(exit.callCount).eql(1);
   });
 
-  after(function () {
+  it(`should call logger.error() and exit(2) - verbose`, () => {
+    const error = sinon.stub(logger, 'error').withArgs(sinon.match('Error: foo\n    at '));
+    const exit = sinon.stub(process, 'exit').withArgs(2);
+
+    program
+      .command('foo', 'Fooooo')
+      .action(() => { throw new Error('foo'); });
+
+    should(program.parse.bind(program, makeArgv(['foo', '-v']))).throw();
+
+    should(error.callCount).eql(1);
+    should(exit.callCount).eql(1);
+  });
+
+  it(`should call logger.error() and exit(2) - normal`, () => {
+    const error = sinon.stub(logger, 'error').withArgs("\nfoo");
+    const exit = sinon.stub(process, 'exit').withArgs(2);
+
+    program
+      .command('foo', 'Fooooo')
+      .action(() => { throw new Error('foo'); });
+
+    should(program.parse.bind(program, makeArgv(['foo']))).throw();
+
+    should(error.callCount).eql(1);
+    should(exit.callCount).eql(1);
+  });
+
+  it(`should call logger.error() and exit(2) - async`, (done) => {
+    const error = sinon.stub(logger, 'error').withArgs("\nfoo");
+    const exit = sinon.stub(process, 'exit').withArgs(2);
+
+    program
+      .command('foo', 'Fooooo')
+      .action(() => {
+        return new Promise((resolve, reject) => {
+          reject(new Error('foo'));
+        });
+      });
+
+    program.parse(makeArgv(['foo'])).catch(() => {
+      should(error.callCount).eql(1);
+      should(exit.callCount).eql(1);
+      done();
+    });
+  });
+
+  it(`should call logger.error() and exit(2) - throw non-exception sync`, () => {
+    const error = sinon.stub(logger, 'error').withArgs("\nfoo");
+    const exit = sinon.stub(process, 'exit').withArgs(2);
+
+    program
+      .command('foo', 'Fooooo')
+      .action(() => { throw 'foo'; });
+
+    should(program.parse.bind(program, makeArgv(['foo']))).throw();
+
+    should(error.callCount).eql(1);
+    should(exit.callCount).eql(1);
+  });
+
+  it(`should call logger.error() and exit(2) - throw non-exception async`, (done) => {
+    const error = sinon.stub(logger, 'error').withArgs("\nfoo");
+    const exit = sinon.stub(process, 'exit').withArgs(2);
+
+    program
+      .command('foo', 'Fooooo')
+      .action(() => {
+        return new Promise((resolve, reject) => {
+          reject('foo');
+        });
+      });
+
+    program.parse(makeArgv(['foo'])).catch(() => {
+      should(error.callCount).eql(1);
+      should(exit.callCount).eql(1);
+      done();
+    });
+  });
+
+  afterEach(function () {
     logger.error.restore();
     process.exit.restore();
   })