Skip to content

08 How does terminal work

Austin edited this page Dec 16, 2017 · 2 revisions

terminal.js

terminal.js - Index

  1. Introduction
  2. class Base
  3. class LinuxSessionWrapper
  4. class MetaShell
  5. class Shell
  6. class Log
  7. class SpawnWrapper
  8. Summary

Introduction

This module was written and tested in JavaScript. If you prefer CoffeeScript, you can use coffee to translate the module. I used JavaScript because that is what I am comfortable with.

terminal is a module that I created for the atom-python-run module. Outside of this use, I see no real need or purpose for it. It determines the Operating System and sets a default value for the type of CLI, CLI execution option, and passes user defined arguments to be executed by the ChildProcess instance.

The whole point of this is to automate the process of creating the right type of ChildProcess for a given system. I wanted atom-python-run to be able to execute on any system I might use. This allows me to use this module on Windows, Mac OS X, and Linux based Operating Systems that support the Atom text editor.

atom-python-run executes your code in a Command Line Interface instance. Different Operating Systems use different CLI's. These CLI's might even require different commands. The code is executed and then paused by the cp module. terminal optionally logs stdout and stderr to the console.log.

I created classes based on functions that were grouped together by all working on the same object. Each object has a single function and outputs that object based on its input. The key to understanding how terminal works is to realize it has cascading side-effects. This will become clear later on. The easiest way to explain what terminal actually does is to just go through it and break it down.

-> goto index

class Base

This is a special class and contains meta data used for a generic property called object. It has a method set() and a method get(). The constructor uses Base's built-in set() method. The isEmpty() method is used to determine whether or not the object itself is empty. This will be our base, or root, class. All other classes inherent this class.

Parameters

  • base - the base object
  • source - a list of objects to be merged with base

Object

class Base {
    constructor(base, ...source){
        this.set(base, ...source)
    }

    get() {
        return this.object;
    }

    set(base, ...source) {
        if (null == base) {
            this.object = new Object();
        } else if (null == source) {
            this.object = Object.assign({}, base);
        } else {
            this.object = Object.assign(base, ...source);
        }
    }

    isEmpty() {
        for(var property in this.object) {
            if(this.object.hasOwnProperty(property)) {
                return false;
            }
        }
        return JSON.stringify(this.object) === JSON.stringify({});
    }
}

Usage

Now that we have our Base defined, we can test it out in node. Open a CLI instance, go to the working directory where terminal.js is located, and start nodejs.

$ pwd
/home/myUserName/.atom/packages/atom-python-run/lib
$ node
>
> terminal = require('./terminal')
{ Base: [Function: Base],
  LinuxSessionWrapper: [Function: LinuxSessionWrapper],
  MetaShell: [Function: MetaShell],
  Shell: [Function: Shell],
  Log: [Function: Log],
  SpawnWrapper: [Function: SpawnWrapper] }
>
> base = new terminal.Base()
Base { object: {} }

It is literally what it looks like. Instead of repeating my code every where, I encapsulated the common operations within this class. I needed to be able to set objects and retrieve objects. These objects are stored as the generic property named object.

> base.set({'shell':'gnome-terminal'})
undefined
> base.get()
{ shell: 'gnome-terminal' }
> base.object
{ shell: 'gnome-terminal' }

You can pass as many objects as necessary. It's important to note that if you modify a object, you need to copy it and then append a new object to it; Otherwise it will be lost.

> base.set({'shell':'gnome-terminal'})
undefined
> base.get()
{ shell: 'gnome-terminal' }
> base.set({'option': '-x'})
undefined
> base.get()
{ option: '-x' }

In order to preserve the object, I did this instead.

> base.set(base.get(), {'option':['-x']})
undefined
> base.get()
{ shell: 'gnome-terminal', option: [ '-x' ] }

Obviously, I could have done something simpler like this.

> base.object.call = 'python'
'python'
> base.object
{ shell: 'gnome-terminal', option: [ '-x' ], call: 'python' }

Summary

Regardless, in all instances, it's the same end result. I create a new Object instance and create a copy of any object passed to any Base instance. The Base instance is an abstract representation of what ever it may be. This allowed me to create objects on the fly in a more coherent and consistent manner.

I did this because I wanted each of the classes to have one object as its input and output. This allowed me to use arbitrary key/value pairs for each class which would ultimately only share the object passed between them.

Everything else is accessible of course; however, this is unnecessary since each object instance is taken care of by its constructor. All that's required it to retrieve the object and pass it to the next one without ever having to explicitly call it.

-> goto index

class LinuxSessionWrapper

This object determines the default shell, and the default shell option, by determining the users current desktop environment (or session) for Linux. This object applies to the Linux context alone.

Parameters

None

Object

class LinuxSessionWrapper extends Base {
    constructor() {
        super();
        let shell = this.setupDefaultShell();
        let option = this.setupDefaultOption(shell);
        this.set({
            'shell': shell,
            'option': option
        });
    };

    getSessionTerminal() {
        switch (process.env.GDMSESSION) {
            case 'ubuntu':
            case 'ubuntu-2d':
            case 'gnome':
            case 'gnome-shell':
            case 'gnome-classic':
            case 'gnome-fallback':
            case 'cinnamon':
                return "gnome-terminal";
            case 'xfce':
                return "xfce4-terminal";
            case 'kde-plasma':
                return "konsole";
            case 'Lubuntu':
                return "lxterminal";
            default:
                return null;
        }
    }

    getDesktopTerminal() {
        switch (process.env.XDG_CURRENT_DESKTOP) {
            case 'Unity':
            case 'GNOME':
            case 'X-Cinnamon':
                return "gnome-terminal";
            case 'XFCE':
                return "xfce4-terminal";
            case 'KDE':
                return "konsole";
            case 'LXDE':
                return "lxterminal";
            default:
                return "xterm";
        }
    }

    setupDefaultShell() {
        let shell = this.getSessionTerminal();

        if (null === shell) {
            shell = this.getDesktopTerminal();
        }

        return shell;
    }

    setupDefaultOption(shell) {
        // determine the shells default execution option
        switch (shell) {
            case 'gnome-terminal':
            case 'xfce4-terminal':
                return "-x";
            case 'konsole':
            case 'lxterminal':
            default:
                return "-e";
        }
    }
};

The environment variable GDMSESSION is checked first. If that does NOT yield a value, the environment variable XDG_CURRENT_DESKTOP is checked. If that fails as well, the default graphical shell, xterm, is used instead.

The value yielded from the shell is then used to determine the option. These values are then set() to its built-in object property for later access.

Usage

$ node
> 
> terminal = require('./terminal')
{ Base: [Function: Base],
  LinuxSessionWrapper: [Function: LinuxSessionWrapper],
  MetaShell: [Function: MetaShell],
  Shell: [Function: Shell],
  Log: [Function: Log],
  SpawnWrapper: [Function: SpawnWrapper] }
> 
> session = new terminal.LinuxSessionWrapper()
LinuxSessionWrapper { object: { shell: 'gnome-terminal', option: '-x' } }
> 
> session.object
{ shell: 'gnome-terminal', option: '-x' }

Summary

The only reason this object exists is to assist in determining the default shell and default execution option for that shell. There are no arguments passed to the constructor and the constructor takes care of the generic objects creation.

-> goto index

class MetaShell

This object is a blueprint for the Shell class. MetaShell is a special meta class that helps automate the construction of metadata used to create a terminal instance.

Parameters

  • base
  • source

Object

class MetaShell extends Base {
    constructor(base, ...source) {
        super();
        this.setBase(base, ...source);
        this.setPath(base);
        this.setCommand();
    }

    setBase(base, ...source) {
        this.set({
            'shell': base.shell,
            'option': base.option,
            'call': base.call
        }, ...source);
    }

    setPath(base) {
        function defaultPath() {
            if ('win32' === process.platform) {
                return `${process.env.USERPROFILE}\\.atom\\packages\\atom-python-run\\cp\\main.py`;
            }
            return `${process.env.HOME}/.atom/packages/atom-python-run/cp/main.py`;
        }
        this.set(this.get(), {
            'script': (function(path) {
                if (null == path) {
                    path = defaultPath();
                }
                return path;
            })(base.script)
        });
    }

    setCommand() {
        let object = this.get();
        this.set(object, {
            'command': function (args) {
                let path = `${object.call} ${object.script}`;
                for (let token of args) {
                    path += ` ${token}`;
                }
                return `tell app "Terminal" to do script "${path}"`
            }
        });
    }
}

Usage

$ node
> 
> terminal = require('./terminal')
{ Base: [Function: Base],
  LinuxSessionWrapper: [Function: LinuxSessionWrapper],
  MetaShell: [Function: MetaShell],
  Shell: [Function: Shell],
  Log: [Function: Log],
  SpawnWrapper: [Function: SpawnWrapper] }
> 
> session = new terminal.LinuxSessionWrapper()
LinuxSessionWrapper { object: { shell: 'gnome-terminal', option: '-x' } }
> 
> meta = new terminal.MetaShell(session.object, {'call': 'python'})
MetaShell {
  object: 
   { shell: 'gnome-terminal',
     option: '-x',
     call: 'python',
     script: '/home/th3ros/.atom/packages/atom-python-run/cp/main.py',
     command: [Function: command] } }

It's important to note that this meta.object is useless on it's own. It is literally meta data that is used only by the SpawnWrapper class. This is explained best while summing everything up later on.

While this object can be changed once instantiated by utilizing the setBase() method, it's typically best to pass any arguments to the constructor instead.

The MetaShell object requires at least 3 key/value pairs. The other 2 are optional.

All MetaShell objects have 5 properties:

  • shell The terminal instance to be created
  • option The terminal instance execution option
  • call A REPL or Interpreter that executes 'script'
  • script A program to execute given arguments within a 'shell'
  • command The commands to be executed by 'script' (Mac OS X Only)

script is created by either the user or by terminal. If a path is undefined, it defaults to using an expected absolute path. Otherwise is uses the user defined path instead.

command is constructed by an anonymous function that is only accessible through a MetaShell.command method call. This call will yield a string that can be passed to osascript. This is Mac OS X specific, but may work on another system that can execute osascript itself; I'm assuming since I haven't test this theory my self.

i.e. :

$ shell option call script

translates to

$ gnome-terminal -x python script.py

Summary

The meta.object is used to construct meta objects through the use of a Shell object. We will cover what the Shell object does in detail next.

-> goto index

class Shell

The Shell class creates a Shell.object according to its local environment. A path can be passed to the constructor, appropriate method according to the local environment, or by manually invoking the setUp() method which determines the local environment automatically.

Parameters

  • path A 'String' containing a path to a executable

Object

class Shell extends Base {
    constructor(path) {
        super();
        this.set(this.setUp(path));
    }

    win32(path) {
        return new MetaShell({
            'shell': process.env.COMSPEC,
            'option': ['/c', 'start'],
            'call': 'python',
            'script': path
        });
    }

    darwin(path) {
        return new MetaShell({
            'shell': 'osascript',
            'option': ['-e'],
            'call': 'python',
            'script': path
        });
    }

    linux(path) {
        let session = new LinuxSessionWrapper();
        return new MetaShell({
            'shell': session.get().shell,
            'option': [session.get().option],
            'call': 'python',
            'script': path
        });
    }

    setUp(path) {
        switch (process.platform) {
            case 'win32':
                return this.win32(path).object;
            case 'darwin':
                return this.darwin(path).object;
            case 'linux':
                return this.linux(path).object;
            default:
                return null;
        }
    }
}

Usage

> terminal = require('./terminal')
{ Base: [Function: Base],
  LinuxSessionWrapper: [Function: LinuxSessionWrapper],
  MetaShell: [Function: MetaShell],
  Shell: [Function: Shell],
  Log: [Function: Log],
  SpawnWrapper: [Function: SpawnWrapper] }
>
> shell = new terminal.Shell()
Shell {
  object: 
   { shell: 'gnome-terminal',
     option: [ '-x' ],
     call: 'python',
     script: '/home/th3ros/.atom/packages/atom-python-run/cp/main.py',
     command: [Function: command] } }

Notice here how we do NOT have to pass any parameters to the terminal.Shell call. Also note how the path parameter is optional as well. script has its own path value built-in. However, we can always change the path value.

> shell = new terminal.Shell('/some/path/to/some/executable')
Shell {
  object: 
   { shell: 'gnome-terminal',
     option: [ '-x' ],
     call: 'python',
     script: '/some/path/to/some/executable',
     command: [Function: command] } }

Summary

Shell is one of only 2 objects that are required. SpawnWrapper is the other required object. Shell creates a meta object that contains meta data to be passed to the child_process.spawn method call.

On it's own, it does nothing. However, it contains basic required information to spawn a CLI instance for any particular OS depending on the system it's executed in.

-> goto index

class Log

Automate the logging process while executing in Atom. Creates a Log.object to capture and print basic I/O operations to stdout.

Parameters

  • base
  • source

Object

class Log extends Base {
    constructor(base, ...source) {
        super();
        this.set(base, ...source)
    }

    metaData() {
        console.log(
            `platform: ${process.platform}\n` +
            `shell: ${this.object.meta.shell}\n` +
            `option: ${this.object.meta.option}\n` +
            `call: ${this.object.meta.call}\n` +
            `script: ${this.object.meta.script}\n` +
            `command: ${this.object.meta.command(this.get().args)}`
        );
    }

    params() {
        console.log(
            `options: ${JSON.stringify(this.object.options, null, 4)}\n` +
            `args: ${this.object.args}\n` +
            `cp: ${this.object.cp}`
        );
    }

    tty() {
        this.object.tty.stdout.on('data', (data) => {
            console.log(`stdout: ${data}`);
        });
        this.object.tty.stderr.on('data', (data) => {
            console.log(`stderr: ${data}`);
        });
        this.object.tty.on('close', (code) => {
            console.log(`child process exited with code ${code}`);
        });
    }

    call() {
        this.metaData();
        this.params();
        this.tty();
    }
}

Usage

A Log call will require an object during instantiation with the following format:

  • tty Requires a 'ChildProcess' instance
  • options The 'options' object passed to 'spawn()'
  • args The parameters to be executed by the cli instance
  • cp A 'Boolean' value that determines a cli instance call
  • meta The meta data to be passed to a cli instance call
> terminal = require('./terminal')
{ Base: [Function: Base],
  LinuxSessionWrapper: [Function: LinuxSessionWrapper],
  MetaShell: [Function: MetaShell],
  Shell: [Function: Shell],
  Log: [Function: Log],
  SpawnWrapper: [Function: SpawnWrapper] }
>
> shell = new terminal.Shell()
Shell {
  object: 
   { shell: 'gnome-terminal',
     option: [ '-x' ],
     call: 'python',
     script: '/home/th3ros/.atom/packages/atom-python-run/cp/main.py',
     command: [Function: command] } }
>
> log = new terminal.Log({'meta': shell.object}, {'args': ['python', '/path/to/hello.py']})
Log {
  object: 
   { meta: 
      { shell: 'gnome-terminal',
        option: [Object],
        call: 'python',
        script: '/home/th3ros/.atom/packages/atom-python-run/cp/main.py',
        command: [Function: command] },
     args: [ 'python', '/path/to/hello.py' ] } }
>
> log.metaData()
platform: linux
shell: gnome-terminal
option: -x
call: python
script: /home/th3ros/.atom/packages/atom-python-run/cp/main.py
command: tell app "Terminal" to do script "python /home/th3ros/.atom/packages/atom-python-run/cp/main.py python /path/to/hello.py"
undefined

The meta property should contain a Shell instance object because the metaData() method expects specific properties to be available. This object should be used ONLY after spawn has executed and a tty instance has been created.

It's important to note that at least one key/value pair is required to create a valid Log instance, but will ultimately fail once the call() method is executed.

Summary

This object is used by the SpawnWrapper object to log spawns execution to Atoms console.log.

-> goto index

class SpawnWrapper

SpawnWrapper class creates a child_process.spawn() wrapper object. A Shell.object is passed to SpawnWrapper during instantiation. The Shell.tty() method is used to create a ChildProcess object.

Parameters

  • base
  • source

Object

class SpawnWrapper extends Base {
    constructor(base, ...source) {
        super();
        this.set(base, ...source);
    }

    spawnWin32(model) {
        function cp(object, ...args) {
            if (model.cp) {
                return [...object.option, object.call, object.script, ...args];
            } else {
                return [...object.option, ...args];
            }
        }
        return spawn(
            this.object.shell,
            [...cp(this.object, ...model.args)],
            model.options
        )
    }

    spawnDarwin(model) {
        // darwin should never be modified or configured in any way. doing
        // so risks breaking the required tool chain.
        return spawn(
            this.object.shell,
            [ ...this.object.option,
                this.object.command(...model.args)
            ],
            model.options
        );
    }

    spawnWrapper(model) {
        switch (process.platform) {
            case 'linux':
            case 'win32':
                return this.spawnWin32(model);
            case 'darwin':
                return this.spawnDarwin(model);
            default:
                return null;
        }
    }

    optionWrapper(options) {
        function isEmpty(object) {
            for(var property in object) {
                if(object.hasOwnProperty(property)) {
                    return false;
                }
            }
            return JSON.stringify(object) === JSON.stringify({});
        }
        if (isEmpty(options)) {
            options = {
                'cwd': undefined,
                'detached': false
            };
        }
        return options;
    }

    tty(model) {
        model.options = this.optionWrapper(model.options);
        let tty = this.spawnWrapper(model);
        if (model.log) {
            new Log({
                'tty': tty,
                'args': model.args,
                'options': model.options,
                'cp': model.cp,
                'meta': this.object
            }).call();
        }
        return tty;
    }
}

Usage

> terminal = require('./terminal')
{ Base: [Function: Base],
  LinuxSessionWrapper: [Function: LinuxSessionWrapper],
  MetaShell: [Function: MetaShell],
  Shell: [Function: Shell],
  Log: [Function: Log],
  SpawnWrapper: [Function: SpawnWrapper] }
>
> shell = new terminal.Shell()
Shell {
  object: 
   { shell: 'gnome-terminal',
     option: [ '-x' ],
     call: 'python',
     script: '/home/th3ros/.atom/packages/atom-python-run/cp/main.py',
     command: [Function: command] } }
>
> spawn = new terminal.SpawnWrapper(shell.object)
SpawnWrapper {
  object: 
   { shell: 'gnome-terminal',
     option: [ '-x' ],
     call: 'python',
     script: '/home/th3ros/.atom/packages/atom-python-run/cp/main.py',
     command: [Function: command] } }

The tty() method takes a single object called model which is a pseudo representation of how the ChildProcess object should be created.

  • log A flag based 'Boolean' value
  • cp Ditto...
  • args An 'Array' of arguments to be executed by a cli instance
  • options The 'options' object passed to 'child_process.spawn()'
> model = {
...     'log': true,
...     'cp': true,
...     'options': {},
...     'args': [
...         'python',
...         `${process.env.HOME}/Documents/Python/hello.py`
...     ]
... }
{ log: true,
  cp: true,
  options: {},
  args: [ 'python', '/home/th3ros/Documents/Python/hello.py' ] }

The object passed to this wrapper is the Shell object. The instance of the Shell object is used to determine the call that is made to the child_process.spawn method. Once a wrapper instance has been instantiated, the only method that should be called is the tty() method. SpawnWrapper.tty() will return the active ChildProcess object after execution.

> spawn.tty(model)
platform: linux
shell: gnome-terminal
option: -x
call: python
script: /home/th3ros/.atom/packages/atom-python-run/cp/main.py
command: tell app "Terminal" to do script "python /home/th3ros/.atom/packages/atom-python-run/cp/main.py python /home/th3ros/Documents/Python/hello.py"
options: {
    "detached": false
}
args: python,/home/th3ros/Documents/Python/hello.py
cp: true
ChildProcess { ...lots of stuff here... }
child process exited with code 0

-> goto index

Summary

You only need the Shell and SpawnWrapper classes. Create a Shell object and pass Shell.object or Shell.get to the SpawnWrapper object.

> let shell = new Shell();

When shell is created, it automatically determines the OS and creates an object defining the parameters to pass to child_process.spawn in the background.

> let spawn = new(shell.object);

When spawn is created, it uses the shell.object to define the arguments that are passed to child_process.spawn when it is finally called. Before we call spawn.tty, we need to define a special object required by the method call.

> let object = {
    'log': true,
    'cp': true,
    'options': {},
    'args': [
        'python',
        `${process.env.HOME}/Documents/Python/hello.py`
    ]
};
  • log is a flag for toggling 'stdout' and 'stderr' to atoms 'console.log'.
  • cp is a flag for using the 'cp' python module to run scripts.
  • options is the object to be passed to 'child_process.spawn' parameter.
  • args is an array that contains the arguments to pass to the shell; or to the cp script.

Then call spawn.tty and pass the object defining the parameters to be passed to child_process.spawn.

> spawn.tty(object);

spawn.tty returns a tty object called ChildProcess.

-> goto index