ls
,
- cd
, pwd
and bash filename tab-completion
-* GitHub Console - Extend the Quake Console to talk to GitHub's REST API to navigate repositories, their branches and file system
-
-## Articles
-* CLI all the things: Introducing Josh.js Article about the origins of Josh.js with an example console for wordpress sites.
-
-## License
-josh.js is licensed under the Apache 2.0 License
-
-## Status
-
-* code is ready for experimental use
- * Tested under Chrome, Firefox, Safari and IE9
- * API may not yet be stable
-* needs minified versions of complete toolkit and just readline.js
-* needs code documentation and documentation site
-* would like to add AMD support
-* base shell UI should get some basic behaviors
- * `more`-like handling for output that exceeds the shell viewport size
- * resizing and close chrome
-* Readline has not been tested with non-ascii.
-
-## Usage
-
-Until documentation is written, refer to `index.html` and `example.js` ([Annotated Source](http://sdether.github.com/josh.js/docs/example.html)) for a sample implementation of a shell with path completion.
-
-## Components
-***josh*** is built from 5 components and can be used in part or in full.
-
-### readline.js
-
-`readline.js` has no dependencies on any outside libraries, although it requires either `history.js` and `killring.js` or objects implementing the same calls.
-
-It implements key trapping to bring [GNU Readline](http://cnswww.cns.cwru.edu/php/chet/readline/readline.html) like line editing to the browser. It can be used by itself to bring readline support to custom data entry fields or in conjunction with `shell.js` to create a full console.
-
-#### Line Editing
-In the below `C-x` refers to the `Ctrl-x` keystroke, while `M-x` refers to the `Meta-x` keystroke which is mapped to `Alt`, `⌘` and `Left Windows`.
-
-C-b
or Left Arrow
M-b
or Right Arrow
C-f
M-f
C-a
or Home
C-e
or End
Backspace
C-d
or Delete
C-k
M-Backspace
M-d
C-y
M-y
C-r
C-p
or Up Arrow
C-n
or Down Arrow
Page Up
Page Down
C-l
Tab
Esc
in reverse searchC-c
onCancel
handlerC-d
on empty lineonCancel
handler example.js | |
---|---|
/*------------------------------------------------------------------------*
+ * Copyright 2013 Arne F. Claassen
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ *-------------------------------------------------------------------------*/
+(function(root, $, _) {
+ Josh.Example = (function(root, $, _) { | |
Enable console debugging, when Josh.Debug is set and there is a console object on the document root. | var _console = (Josh.Debug && root.console) ? root.console : {
+ log: function() {
+ }
+ }; |
Setup of Shell | |
build the fake directory structure used to illustrate path commands and completions. | var treeroot = buildTree(); |
Create | var history = Josh.History();
+ var killring = new Josh.KillRing(); |
Create the | var readline = new Josh.ReadLine({history: history, killring: killring, console: _console }); |
Finally, create the | var shell = Josh.Shell({readline: readline, history: history, console: _console}); |
Create killring command | |
Setup the | var killringItemTemplate = _.template("<div><% _.each(items, function(item, i) { %><div><%- i %> <%- item %></div><% }); %></div>") |
Create a the command | shell.setCommandHandler("killring", { |
We don't implement any completion for the | exec: function(cmd, args, callback) { |
| if(args[0] == "-c") {
+ killring.clear(); |
The callback of an | callback();
+ return;
+ } |
Return the output of feeding all items from the killring into our template. | callback(killringItemTemplate({items: killring.items()}));
+ }
+ }); |
Setup PathHandler | |
| var pathhandler = new Josh.PathHandler(shell, {console: _console}); |
+
+where name is the The pathhandler expects to be initialized with the current directory, i.e. a path node. | pathhandler.current = treeroot; |
| pathhandler.getNode = function(path, callback) {
+ if(!path) {
+ return callback(pathhandler.current);
+ }
+ var parts = _.filter(path.split('/'), function(x) {
+ return x;
+ });
+ var start = ((path || '')[0] == '/') ? treeroot : pathhandler.current;
+ _console.log('start: ' + start.path + ', parts: ' + JSON.stringify(parts));
+ return findNode(start, parts, callback);
+ }; |
| pathhandler.getChildNodes = function(node, callback) {
+ _console.log("children for " + node.name);
+ callback(node.childnodes);
+ }; |
| function findNode(current, parts, callback) {
+ if(!parts || parts.length == 0) {
+ return callback(current);
+ }
+ if(parts[0] == ".") {
+
+ } else if(parts[0] == "..") {
+ current = current.parent;
+ } else {
+ current = _.first(_.filter(current.childnodes, function(node) {
+ return node.name == parts[0];
+ }));
+ }
+ if(!current) {
+ return callback();
+ }
+ return findNode(current, _.rest(parts), callback);
+ } |
Setup Document Behavior | |
Activation and display behavior happens at document ready time. | $(document).ready(function() { |
The default name for the div the shell uses as its container is | var $consolePanel = $('#shell-panel'); |
We use jquery-ui's | $consolePanel.resizable({ handles: "s"}); |
activate the shell | shell.activate();
+ }); |
We attach the various objects we've created here to | Josh.Instance = {
+ Tree: treeroot,
+ Shell: shell,
+ PathHandler: pathhandler,
+ KillRing: killring
+ }; |
This code builds our fake directory structure. Since most real applications of | function buildTree() {
+ var fs = {
+ bin: {},
+ boot: {},
+ dev: {},
+ etc: {
+ default: {},
+ 'rc.d': {},
+ sysconfig: {},
+ x11: {}
+ },
+ home: {
+ bob: {
+ video: {
+ 'firefly.m4v': {}
+ },
+ videos: {
+ 'Arrested Development': {
+ 's1e1.m4v': {}
+ },
+ 'Better Off Ted': {
+ 's1e1.m4v': {}
+ }
+ }
+ },
+ jane: {}
+ },
+ lib: {},
+ 'lost+found': {},
+ misc: {},
+ mnt: {
+ cdrom: {},
+ sysimage: {}
+ },
+ net: {},
+ opt: {},
+ proc: {},
+ root: {},
+ sbin: {},
+ usr: {
+ x11: {},
+ bin: {},
+ include: {},
+ lib: {},
+ local: {},
+ man: {},
+ sbin: {},
+ share: {
+ doc: {}
+ },
+ src: {}
+ },
+ var: {
+ lib: {},
+ lock: {},
+ run: {},
+ log: {
+ httpd: {
+ access_log: {},
+ error_log: {}
+ },
+ 'boot.log': {},
+ cron: {},
+ messages: {}
+ }
+ }
+ };
+
+ function build(parent, node) {
+ parent.childnodes = _.map(_.pairs(node), function(pair) {
+ var child = {
+ name: pair[0],
+ path: parent.path + "/" + pair[0],
+ parent: parent
+ };
+ build(child, pair[1]);
+ return child;
+ });
+ parent.children = _.keys(node);
+ return parent;
+ }
+ var tree = build({name: "", path: ""}, fs);
+ tree.path = '/';
+ return tree;
+ }
+ })(root, $, _);
+})(this, $, _);
+
+ |
githubconsole.js | |
---|---|
/*------------------------------------------------------------------------*
+ * Copyright 2013 Arne F. Claassen
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ *-------------------------------------------------------------------------*/
+(function(root, $, _) {
+ Josh.GitHubConsole = (function(root, $, _) { | |
Enable console debugging, when Josh.Debug is set and there is a console object on the document root. | var _console = (Josh.Debug && root.console) ? root.console : {
+ log: function() {
+ }
+ }; |
Console State+ +
| var _self = {
+ shell: Josh.Shell({console: _console}),
+ api: "http://josh.claassen.net/github/"
+ }; |
| _self.pathhandler = new Josh.PathHandler(_self.shell, {console: _console}); |
Custom Templates+ +
| |
templates.prompt | |
Override of the default prompt to provide a multi-line prompt of the current user, repo and path and branch. | _self.shell.templates.prompt = _.template("<em>[<%= self.user.login %>/<%= self.repo.name %>]</em></br>(<%=self.branch%>) <strong><%= node.path %> $</strong>"); |
templates.ls | |
Override of the pathhandler ls template to create a multi-column listing. | _self.shell.templates.ls = _.template("<ul class='widelist'><% _.each(nodes, function(node) { %><li><%- node.name %></li><% }); %></ul><div class='clear'/>"); |
templates.not_found | |
Override of the pathhandler not_found template, since we will throw not_found if you try to access a valid file. This is done for the simplicity of the tutorial. | _self.shell.templates.not_found = _.template("<div><%=cmd%>: <%=path%>: No such directory</div>"); |
templates.rateLimitTemplate | |
Since GitHub rate limits un-authenticated use rather drastically, we render the current rate limit status in the shell so that it is clear that extended experimenting requires authentication. | _self.shell.templates.rateLimitTemplate = _.template("<%=remaining%>/<%=limit%><% if(!authenticated) {%> <a href='http://josh.claassen.net/github/authenticate'>Authenticate with Github to increase your Rate Limit.</a><%}%>"); |
templates.user | |
Render basic information (including gravatar) whenever we switch users or enter | _self.shell.templates.user = _.template("<div class='userinfo'>" +
+ "<img src='<%=user.avatar_url%>' style='float:right;'/>" +
+ "<table>" +
+ "<tr><td><strong>Id:</strong></td><td><%=user.id %></td></tr>" +
+ "<tr><td><strong>Name:</strong></td><td><%=user.login %></td></tr>" +
+ "<tr><td><strong>Location:</strong></td><td><%=user.location %></td></tr>" +
+ "</table>" +
+ "</div>"
+ ); |
templates.user_error | |
Generic error in case setting the user fails. | _self.shell.templates.user_error = _.template("Unable to set user '<%=name%>': <%=msg%>"); |
templates.repos | |
Just like | _self.shell.templates.repos = _.template("<ul class='widelist'><% _.each(repos, function(repo) { %><li><%- repo.name %></li><% }); %></ul><div class='clear'/>"); |
template.repo | |
Whenever we change repositories or | _self.shell.templates.repo = _.template("<div><div><strong>Name: </strong><%=repo.full_name%></div><div><strong>Description: </strong><%=repo.description %></div></div>"); |
template.reponotfound | |
Error message in case someone tries to switch to an invalid repo. | _self.shell.templates.repo_not_found = _.template("<div>repo: <%=repo%>: No such repo for user '<%= user %>'</div>"); |
templates.repo_error | |
Generic error message in case setting the repo fails. | _self.shell.templates.repo_error = _.template("Unable to switch to repository '<%=name%>': <%=msg%>"); |
templates.branches | |
Again, like | _self.shell.templates.branches = _.template("webfont.woff<ul class='widelist'><% _.each(branches, function(branch) { %><li><%- branch.name %></li><% }); %></ul><div class='clear'/>"); |
templates.branch_error | |
Generic error message in case setting the current branch fails. | _self.shell.templates.branch_error = _.template("Unable to switch to branch '<%=name%>': <%=msg%>"); |
templates.branches_error | |
Generic error in case fetching the list of branches fails. | _self.shell.templates.branches_error = _.template("Unable to load branch list: <%=msg%>"); |
Adding Commands to the Console | |
| |
user [ username ] | |
The | _self.shell.setCommandHandler("user", { |
| exec: function(cmd, args, callback) { |
Given no arguments, it renders information about the current user, using the data fetched at user initialization. | if(!args || args.length == 0) {
+ return callback(_self.shell.templates.user({user: _self.user}));
+ }
+ var username = args[0]; |
Given an argument (assumed to be a username), it calls | return setUser(username, null,
+ function(msg) {
+ return callback(_self.shell.templates.user_error({name: username, msg: msg}));
+ },
+ function(user) {
+ return callback(_self.shell.templates.user({user: user}));
+ }
+ );
+ } |
| }); |
| |
repo [ -l | reponame ] | |
The | _self.shell.setCommandHandler("repo", { |
| exec: function(cmd, args, callback) { |
Given no arguments, it renders information about the current repo. | if(!args || args.length == 0) {
+ return callback(_self.shell.templates.repo({repo: _self.repo}));
+ }
+ var name = args[0]; |
Given the argument | if(name === '-l') {
+ return callback(_self.shell.templates.repos({repos: _self.repos}));
+ } |
Otherwise, the argument is assumed to a repo name, which | var repo = getRepo(name, _self.repos); |
If there is no matching repo, it renders an error. | if(!repo) {
+ return callback(_self.shell.templates.repo_error({name: name, msg: 'no such repo'}));
+ } |
Given a valid repo, | return setRepo(repo,
+ function(msg) {
+ return callback(_self.shell.templates.repo_error({name: name, msg: msg}));
+ },
+ function(repo) {
+ if(!repo) {
+ return callback(_self.shell.templates.repo_not_found({repo: name, user: _self.user.login}));
+ }
+ return callback(_self.shell.templates.repo({repo: _self.repo}));
+ }
+ );
+ }, |
| completion: function(cmd, arg, line, callback) {
+ callback(_self.shell.bestMatch(arg, _.map(_self.repos, function(repo) {
+ return repo.name;
+ })));
+ }
+ }); |
| |
branch [ -l | branchname ] | |
The | _self.shell.setCommandHandler("branch", { |
| exec: function(cmd, args, callback) { |
Given no arguments, it simply returns the current branch, which will be rendered by the shell. | if(!args || args.length == 0) {
+ return callback(_self.branch);
+ }
+ var branch = args[0]; |
Given the argument | if(branch === '-l') {
+ return ensureBranches(
+ function(msg) {
+ callback(_self.shell.templates.branches_error({msg: msg}));
+ },
+ function() {
+ return callback(_self.shell.templates.branches({branches: _self.branches}));
+ }
+ );
+ } |
Owherwise, the current branch is switched by fetching the root directory for the new branch, and on success,
+setting | return getDir(_self.repo.full_name, branch, "/", function(node) {
+ if(!node) {
+ callback(_self.shell.templates.branch_error({name: branch, msg: "unable to load root directory for branch"}));
+ }
+ _self.branch = branch;
+ _self.pathhandler.current = node;
+ _self.root = node;
+ callback();
+ });
+ }, |
| completion: function(cmd, arg, line, callback) {
+ return ensureBranches(
+ function() {
+ callback();
+ },
+ function() {
+ callback(_self.shell.bestMatch(arg, _.map(_self.branches, function(branch) {
+ return branch.name;
+ })));
+ }
+ );
+ }
+ }); |
| |
This attaches a custom prompt render to the shell. | _self.shell.onNewPrompt(function(callback) {
+ callback(_self.shell.templates.prompt({self: _self, node: _self.pathhandler.current}));
+ }); |
Wiring up PathHandler | |
| |
getNode | |
| _self.pathhandler.getNode = function(path, callback) {
+ _console.log("looking for node at: " + path);
+ if(!path) {
+ return callback(_self.pathhandler.current);
+ } |
| buildAbsolutePath(path, _self.pathhandler.current, function(absPath) {
+ _console.log("path to fetch: " + absPath);
+ return getDir(_self.repo.full_name, _self.branch, absPath, callback);
+ });
+ }; |
| |
getChildNodes | |
| _self.pathhandler.getChildNodes = function(node, callback) { |
If the given node is a file node, no further work is required. | if(node.isfile) {
+ _console.log("it's a file, no children");
+ return callback();
+ } |
Otherwise, if the child nodes have already been initialized, which is done lazily, return them. | if(node.children) {
+ _console.log("got children, let's turn them into nodes");
+ return callback(makeNodes(node.children));
+ } |
Finally, use | _console.log("no children, fetch them");
+ return getDir(_self.repo.full_name, _self.branch, node.path, function(detailNode) {
+ node.children = detailNode.children;
+ callback(makeNodes(node.children));
+ });
+ }; |
Supporting Functions | |
| |
get | |
This function is responsible for all API requests, given a partial API path, | function get(resource, args, callback) {
+ var url = _self.api + resource;
+ if(args) {
+ url += "?" + _.map(args,function(v, k) {
+ return k + "=" + v;
+ }).join("&");
+ }
+ _console.log("fetching: " + url);
+ var request = {
+ url: url,
+ dataType: 'json',
+ xhrFields: {
+ withCredentials: true
+ }
+ };
+ $.ajax(request).done(function(response,status,xhr) { |
Every response from the API includes rate limiting headers, as well as an indicator injected by the API proxy +whether the request was done with authentication. Both are used to display request rate information and a +link to authenticate, if required. | var ratelimit = {
+ remaining: parseInt(xhr.getResponseHeader("X-RateLimit-Remaining")),
+ limit: parseInt(xhr.getResponseHeader("X-RateLimit-Limit")),
+ authenticated: xhr.getResponseHeader('Authenticated') === 'true'
+ };
+ $('#ratelimit').html(_self.shell.templates.rateLimitTemplate(ratelimit));
+ if(ratelimit.remaining == 0) {
+ alert("Whoops, you've hit the github rate limit. You'll need to authenticate to continue");
+ _self.shell.deactivate();
+ return null;
+ } |
For simplicity, this tutorial trivially deals with request failures by just returning null from this function +via the callback. | if(status !== 'success') {
+ return callback();
+ }
+ return callback(response);
+ })
+ } |
| |
ensureBranches | |
This function lazily fetches the branches for the current repo from the API. | function ensureBranches(err, callback) {
+ get("repos/" + _self.repo.full_name + "/branches", null, function(branches) {
+ if(!branches) {
+ return err("api request failed to return branch list");
+ }
+ _self.branches = branches;
+ return callback();
+ });
+ } |
| |
setUser | |
This function fetches the specified user and initializes a repository to the provided value (which may be null).
+one fetched by | function setUser(user_name, repo_name, err, callback) {
+ if(_self.user && _self.user.login === user_name) {
+ return callback(_self.user);
+ }
+ return get("users/" + user_name, null, function(user) {
+ if(!user) {
+ return err("no such user");
+ }
+ return initializeRepos(user, repo_name, err, function(repo) {
+ _self.user = user;
+ return callback(_self.user);
+ });
+ });
+ } |
| |
initalizeRepos | |
This function first fetches all repos for the given user from the API and then sets the current repo to the provided +value (which may be null). | function initializeRepos(user, repo_name, err, callback) {
+ return getRepos(user.login, function(repos) {
+ var repo = getRepo(repo_name, repos);
+ if(!repo) {
+ return err("user has no repositories");
+ }
+ return setRepo(repo, err, function(repo) {
+ _self.repos = repos;
+ return callback(repo);
+ });
+ });
+ } |
| |
getDir | |
This function function fetches the directory listing for a path on a given repo and branch. | function getDir(repo_full_name, branch, path, callback) { |
Although paths in the internal representation may have a trailing | if(path && path.length > 1 && path[path.length - 1] === '/') {
+ path = path.substr(0, path.length - 1);
+ }
+ get("repos/" + repo_full_name + "/contents" + path, {ref: branch}, function(data) { |
The API call may return either an array, indicating that the path was a directory, or an object. Since only +are stored as pathnodes, retrieving anything but an array returns null via the callback. | if(Object.prototype.toString.call(data) !== '[object Array]') {
+ _console.log("path '" + path + "' was a file");
+ return callback();
+ } |
Given a directory listing, i.e. array, the current directory node is created and the API return value captured +as children so that they can later be transformed into child pathnodes, if required. | var node = {
+ name: _.last(_.filter(path.split("/"), function(x) {
+ return x;
+ })) || "",
+ path: path,
+ children: data
+ };
+ _console.log("got node at: " + node.path);
+ return callback(node);
+ });
+ } |
| |
getRepos | |
This function fetches all repositories for a given user. | function getRepos(userLogin, callback) {
+ return get("users/" + userLogin + "/repos", null, function(data) {
+ callback(data);
+ });
+ } |
| |
getRepo | |
This function tries to match a repository from the given list of known repositories. Should | function getRepo(repo_name, repos) {
+ if(!repos || repos.length == 0) {
+ return null;
+ }
+ var repo;
+ if(repo_name) {
+ repo = _.find(repos, function(repo) {
+ return repo.name === repo_name;
+ });
+ if(!repo) {
+ return callback();
+ }
+ } else {
+ repo = repos[0];
+ }
+ return repo;
+ } |
| |
setRepo | |
This function fetches the root directory for the specified repository and initializes the current repository +state. | function setRepo(repo, err, callback) {
+ return getDir(repo.full_name, repo.default_branch, "/", function(node) {
+ if(!node) {
+ return err("could not initialize root directory of repository '" + repo.full_name + "'");
+ }
+ _console.log("setting repo to '" + repo.name + "'");
+ _self.repo = repo;
+ _self.branch = repo.default_branch;
+ _self.pathhandler.current = node;
+ _self.root = node;
+ return callback(repo);
+ });
+ } |
| |
buildAbsolutePath | |
This function resolves a path to an absolute path given a current node. | function buildAbsolutePath(path, current, callback) {
+ _console.log("resolving path: "+path);
+ var parts = path.split("/"); |
If the first part of the path is | if(parts[0] === '..' ) {
+ var parentParts = _.filter(current.path.split("/"), function(x) {
+ return x;
+ });
+ path = "/" + parentParts.slice(0, parentParts.length - 1).join('/') + "/" + parts.slice(1).join("/");
+ return buildAbsolutePath(path, _self.root, callback);
+ } |
If the first parht of the path is either a | if(parts[0] === '.' || parts[0] !== '') {
+ path = current.path+"/"+path;
+ return buildAbsolutePath(path, _self.root, callback);
+ } |
At this point the path looks absolute, but all | var resolved = [];
+ _.each(parts, function(x) {
+ if(x === '.') {
+ return;
+ }
+ if(x === '..') {
+ resolved.pop();
+ } else {
+ resolved.push(x);
+ }
+ });
+ return callback(resolved.join('/'));
+ } |
| |
makeNodes | |
This method builds child pathnodes from the directory information returned by getDir. | function makeNodes(children) {
+ return _.map(children, function(node) {
+ return {
+ name: node.name,
+ path: "/" + node.path,
+ isFile: node.type === 'file'
+ };
+ });
+ } |
UI setup and initialization | |
| |
initializationError | |
This function is a lazy way with giving up if some request failed during intialization, forcing the user +to reload to retry. | function initializationError(context, msg) {
+ _console.log("[" + context + "] failed to initialize: " + msg);
+ alert("unable to initialize shell. Encountered a problem talking to github api. Try reloading the page");
+ } |
| |
intializeUI | |
After a current user and repo have been set, this function initializes the UI state to allow the shell to be +shown and hidden. | function initializeUI() {
+ _console.log("activating");
+ var $consolePanel = $('#shell-container');
+ $consolePanel.resizable({ handles: "s"});
+ $(document).keypress(function(event) {
+ if(_self.shell.isActive()) {
+ return;
+ }
+ if(event.keyCode == 126) {
+ event.preventDefault();
+ activateAndShow();
+ }
+ });
+ function activateAndShow() {
+ _self.shell.activate();
+ $consolePanel.slideDown();
+ $consolePanel.focus();
+ }
+
+ function hideAndDeactivate() {
+ _self.shell.deactivate();
+ $consolePanel.slideUp();
+ $consolePanel.blur();
+ }
+
+ _self.shell.onEOT(hideAndDeactivate);
+ _self.shell.onCancel(hideAndDeactivate);
+ } |
| |
On document ready, the default user and repo are loaded from the API before the UI can complete initialization. | $(document).ready(function() {
+ setUser("sdether", "josh.js",
+ function(msg) {
+ initializationError("default", msg);
+ },
+ initializeUI
+ );
+ });
+ })(root, $, _);
+})(this, $, _);
+
+ |
help
or hit TAB
for a list of commands. Press
+ Ctrl-C
to hide the console.
+ This tutorial expands on the
+ Quake Console tutorial by using the GitHub REST API instead of a faked filesystem. The purpose of the tutorial is to show how to wire up Josh.PathHandler
and custom commands to a remote API.
+
Type ~
to activate the shell we will be discussed belowl.
You can explore the current repository's file system via the standard ls
and
+ cd
commands as well as take advantage of path TAB
completion. Additional commands are:
+
user
- show the current user's infouser username
- change the user to explorerepo -l
- list the current user's repositoriesrepo repository_name
- change the repository to explore (supports
+ TAB
completion)
+ branch -l
- list the current repository's branchesbranch branch_name
- change the branch to explore (supports TAB
completion)
+ You will be limited to 60 requests/hour by the API (where each console command may use multiple requests). If you + authenticate via GitHub, you will have a more flexible 5000 requests/hour to play with. +
+ +The approach of this tutorial is to walk through the pieces required to wire up
+ Josh.Shell
to a remote REST API via asynchronous calls to produce an interactive command line interface by explaining the flow. While some code will be shown inline, the primary code reference is the
+ annotated source code with links to specific, mentioned functions throughout the tutorial.
+
The console is designed to always be in the context of a repository, so that there is no state in which commands like
+ ls
are not available. It initializes with the
+ sdether/josh.js
repository and after this the user can switch users, repositories and branches, while always staying in the context of some repository. That means that at any point in time, we will have a current user object, a list of all that user's repositories, and a current directory on the current branch as state. Changing users picks a default repository and branch to keep that state populated. Branches and current directory information are loaded on demand.
+
The available state looks like this:
+{
+ api: "http://josh.claassen.net/github/", // the proxy we use for the GitHub API
+ shell: $instance_of_Josh.Shell,
+ pathhandler: $instance_of_Josh.PathHandler,
+ user: $current_user_object,
+ repo: $current_repository,
+ repos: $list_of_user_repos,
+ branch: $current_branch,
+ branches: $lazy_initialized_list_of_branches_for_current_repo
+}
+
+ GitHub provides a
+ REST API, giving access to most of its data and functionality. We're just going to worry about read capability around repositories. Each repo is basically a file system which plays nicely into
+ Josh.PathHandler
's area of applicability.
The user object comes from:
+GET /users/:user
+ We never fetch an individual repository, instead opting to fetch all at user initialization via:
+GET /users/:user/repos
+ We do the same for branches, fetching all, lazily, once we need to auto-complete or list them:
+GET /repos/:owner/:repo/branches
+ Finally, we fetch the current directory via
+GET /repos/:owner/:repo/contents/:path
+ This is done for the root during repo initialization, and for path completion, cd
, ls
, etc. on demand.
The API returns + json, which is perfect for us as well, but it is rather drastically rate limited without authentication. I.e. without authentication you will be limited to 60 requests per hour, while with authentication the limit is 5000 per hour. For this reason, we proxy all github calls through a simple node.js application that can handle oauth to optionally authenticate the use of this console. This application is outside the scope of the tutorial, but the code can be found on the josh.js + github-authentication-backend branch. +
+ +In order for us to show the console, we have to have initialized a user, a repository and retrieved it's root directory. This is done after
+ document.ready
$(document).ready(function() {
+ setUser("sdether", "josh.js",
+ function(msg) {
+ initializationError("default", msg);
+ },
+ initializeUI
+ );
+ });
+
+ We call
+ setUser(user_name, repo_name, err, callback)
for
+ sdether and
+ josh.js, before setting the authenticated user as the current user and initializing the UI of the shell.
+
function setUser(user_name, repo_name, err, callback) {
+ if(_self.user && _self.user.login === user_name) {
+ return callback(_self.user);
+ }
+ return get("users/" + user_name, null, function(user) {
+ if(!user) {
+ return err("no such user");
+ }
+ return initializeRepos(user, repo_name, err, function(repo) {
+ _self.user = user;
+ return callback(_self.user);
+ });
+ });
+}
+ This function follows the pattern of providing both an
+ error and
+ success callback, since once the shell is initialized we need to make sure that any action we take on its behalf does result in its callback being called with some value, lest the shell stop functioning. Unlike previous tutorials, we're now doing network requests and those will fail sooner or later. For this reason we need to make sure we always have a quick
+ err callback to stop the current operation and call the callback provided by
+ Josh.Shell
on command execution. We also need to make sure that we do not mutate the application state until we are done with all operations that can fail, so that the worst case is us reporting to the shell that the operation failed while our current, known good state is preserved.
+
All API access goes through a helper function
+ get(resource, args, callback)
, which is responsible for constructing the
+ json call and inspecting the response. For simplicity, all error conditions just result in
+ callback being called with null instead of a
+ json payload.
function get(resource, args, callback) {
+ var url = _self.api + resource;
+ if(args) {
+ url += "?" + _.map(args,function(v, k) { return k + "=" + v; }).join("&");
+ }
+ var request = {
+ url: url,
+ dataType: 'json',
+ xhrFields: {
+ withCredentials: true
+ }
+ };
+ $.ajax(request).done(function(response, status, xhr) {
+
+ // Every response from the API includes rate limiting headers, as well as an
+ // indicator injected by the API proxy whether the request was done with
+ // authentication. Both are used to display request rate information and a
+ // link to authenticate, if required.
+ var ratelimit = {
+ remaining: parseInt(xhr.getResponseHeader("X-RateLimit-Remaining")),
+ limit: parseInt(xhr.getResponseHeader("X-RateLimit-Limit")),
+ authenticated: xhr.getResponseHeader('Authenticated') === 'true'
+ };
+ $('#ratelimit').html(_self.shell.templates.rateLimitTemplate(ratelimit));
+ if(ratelimit.remaining == 0) {
+ alert("Whoops, you've hit the github rate limit. You'll need to authenticate to continue");
+ _self.shell.deactivate();
+ return null;
+ }
+
+ // For simplicity, this tutorial trivially deals with request failures by
+ //just returning null from this function via the callback.
+ if(status !== 'success') {
+ return callback();
+ }
+ return callback(response);
+ })
+}
+ Most of this function is actually devoted to CORS and rate limiting handling, which is unique to us calling the GitHub API via a proxy located on another server. When dealing with your own API, calls will likely just be $.getJSON()
+
+ Also for simplicity, any initialization failures, just bail out via
+ initializationError()
. Once we have a user, we can call
+ initializeRepos(user, repo_name, err, callback)
.
+
Commands are added via Josh.Shell.SetCommandHandler(cmd,handler)
where
+ handler is an object with two properties, exec and
+ completion.
Josh.Shell.SetCommandHandler($cmd, {
+ exec: function(cmd, args, callback) {
+ ...
+ },
+ completion: function(cmd, arg, line, callback) {
+ ...
+ });
+ }
+});
+ Unlike the callback pattern we used for
+ setUser
, Josh functions do not have a separate error handler. Since Josh interacts with the UI, it has no concept of failure -- it has to execute the callback to continue executing. It is up to the caller to deal with errors and transform them into the appropriate UI response. But it still gives us the flexibility to undertake asynchronous actions, such as calling a remote API and complete the function execution upon asynchronous callback from the remote call.
+
The user
command does not have
+ TAB
completion, since doing efficient tab completion against the full set of GitHub users is beyond this tutorial. Instead it expects a valid username for
+ setUser(user_name, repo_name, err, callback)
and renders the user template with the new current user.
+
If called without a username, we simply render the user template with the current user.
+ +The
+ repo
command can show information about the current repository, change the current repository or list all repositories belonging to the user. It also provides
+ TAB
completion of partial repository names against the repositories of the current user.
Given no argument, we simply render the repository template with the current repository.
+ +If the argument is + -l, we render the repository list template with the repositories we fetched on user initialization.
+ +Finally, the argument is used to try and look up the repository from the known repositories list. If that succeeds, we call
+ setRepo(repo, err, callback)
, which fetches the root directory to initialize the current node of
+ Josh.PathHandler
before changing the current repository to the one specified. Upon switching we again render the repository template with the now current repository.
+
The completion handler for the command simply calls
+ Josh.Shell.bestMatch
with the partial argument and a list of all repository names.
+ bestMatch
takes care of creating the completion object with the appropriate argument completion and list of possible choices.
+
The branch command either displays the current branch name, changes the current branch or list all branches for the current repository. It also provides
+ TAB
completion of partial branch names against the lazy initialized list of all branches for the current repository.
Given no argument, the command simply prints the current branch name. The + -l argument renders a list of all known branches for the current repository, while an actual branchname as argument will cause the console to change its current branch. +
+ +Showing the list of branches uses
+ ensureBranches(err, callback)
to lazily initialize the list of branches from the API.
+
The completion handler for the command calls Josh.Shell.bestMatch
-- just like repo
completion -- with the partial argument and the list of all branches.
+
PathHandler
provides unix filepath handling. This works by abstracting the filesystem into two operations, getNode(path, callback)
and getChildNodes(node, callback)
. The former returns a pathnode given a path string while the latter returns pathnodes for all children of a given node. With these two all tree operations including TAB
completion can be accomplished by PathHandler
.
A pathnode is an opaque object in which we can track any node state we want but has to have two properties:
+{
+ name: 'localname',
+ path: '/full/path/to/localname'
+}
+ getNode
is responsible for fetching the appropriate directory from the API either by relative or absolute path. A path is considered relative if it lacks a leading /
. PathHandler
tracks the current directory/file node in PathHandler.current
which is used to convert a relative path to an absolutish path. Since we also support the standard file system .
and ..
symbols and the github API does not, we then need to take the absolutish path and resolve these symbols before passing the resulting absolute path to getDir(repo_full_name, branch, path, callback)
.
It is the job of getDir
to fetch a directory node via GET /repos/:owner/:repo/contents/:path
. This API call returns either an array of file objects for a directory or a file object in case the path points directly at a file. We only care about directories for completion and cd
, ls
, etc. so we ignore file results and build a pathnode for the directory like this:
var node = {
+ name: _.last(_.filter(path.split("/"), function(x) { return x; })) || "",
+ path: path,
+ children: data
+};
+ where name is set to the last segment in the path and children stores the actual API results (which are lazily converted to childNodes for completion by function makeNodes(children)
The use of the github console is fairly limited, but it illustrates that wiring commands and file system behavior to a remote API is fairly simple. While we chose to create a custom vocabulary, we could have just as easily proxied calls to mimic git itself. Either way, Josh.js
is an easy way to add a Command Line interface for your existing API or for an API custom tailored to your CLI, allowing you to create powerful admin tools without providing access to the servers themselves.
This tutorial shows how easy it is to create the below shell window with a custom prompt and a new command
+ hello
.
+ +
help
or hit TAB
for a list of commands.The
+ Josh.Shell
uses local storage to store a history of the commands that you have typed. By default this is keyed with
+ josh.history. That history is available to all instances of the shell on your site. For this tutorial, we want to make sure we have our own copy, so we don't get commands from other tutorials and examples, so we need to create a history object with its own key:
+
var history = new Josh.History({ key: 'helloworld.history'});
+ Now we can create a Shell instance with that history:
+var shell = Josh.Shell({history: history});
+ Now the shell exists but has not yet been activated.
+ +Note on how the shell attaches to its UI elements: By default
+ Josh.Shell
expects to find a div#shell-panel
that contains a
+ div#shell-view
. The former is the physical container providing the dimensions of the shell, while the latter is a div the shell will continue to append to and scroll to mimic a screen. If you want to use other div IDs (because you have multiple shells on one page), you can provide
+ shell-panel-id
and shell-view-id
in the constructor.
The default prompt for Josh is + jsh$. Let's create a prompt instead that keeps track of how many times it has been shown: +
+var promptCounter = 0;
+shell.onNewPrompt(function(callback) {
+ promptCounter++;
+ callback("[" + promptCounter + "] $ ");
+});
+
+ onNewPrompt
is called every time Josh needs to re-render the prompt. This happens usually a command is executed, but can also happen on tab completion, when a list of possible completions is shown.
+ onNewPrompt
expects a function that accepts a callback as its only argument. Josh will not continue until the callback has been called with an html string to display. This allows the prompt to rendered as part of an asynchronous action. For our example, we just increment the promptCounter and send back a simple string with the counter.
+
Josh implements just three commands out of the box:
+help
- show a list of known commandshistory
- show the commands previously enteredclear
- clear the console (i.e. remove all children from div#shell-view
Let's add a new command called hello with tab completion:
+shell.setCommandHandler("hello", {
+ exec: function(cmd, args, callback) {
+ var arg = args[0] || '';
+ var response = "who is this " + arg + " you are talking to?";
+ if(arg === 'josh') {
+ response = 'pleased to meet you.';
+ } else if(arg === 'world') {
+ response = 'world says hi.'
+ } else if(!arg) {
+ response = 'who are you saying hello to?';
+ }
+ callback(response);
+ },
+ completion: function(cmd, arg, line, callback) {
+ callback(shell.bestMatch(arg, ['world', 'josh']))
+ }
+});
+ To add a command, simply call shell.setCommandHandler
and provide it at least an
+ exec
handler, and optionally a completion
handler.
+ exec
expects a function that takes the name of the called command, an array of whitespace separated arguments to the command and a callback that MUST be called with an html string to output to the console. For our toy command we implement a command named
+ hello
which understands arguments josh and
+ world and has alternate outputs for no arguments and unknown arguments.
+ completion
expects a function that takes the current command, the current argument being completed, the complete line (since the cursor may not be at the tail) and a callback that MUST be called either with a completion data structure. The format of this data structure is:
+
{
+ completion: {string to append to current argument},
+ suggestions: [{array of possible completions},...]
+}
+ Here are some expected completions:
+hello <TAB>
=> {completion:null,suggestions:['world', 'josh']}
hello wo<TAB>
=> {completion:'rld',suggestions:['world']}
hello x<TAB>
=> {completion:'',suggestions:[]}
To simplify this process of finding the partial strings and possible completions, Shell offers a method
+ bestMatch
which expects as input the partial to match (our arg to the completion handler) and a list of all possible completions and it will narrow down what to append to the partial and what suggestions to show.
+
Now that we've added our custom behavior to
+ Josh.Shell
, all we have to do is activate the shell to render the prompt and start capturing all keystrokes via readline (i.e. if you want the shell to only capture keys while the shell has focus, it is up to you to write focus and blur code to activate and deactivate the shell.)
+
shell.activate();
+ And that's all there is to getting a custom Bash-like shell in your web page.
+Press ~ to activate console.
+ + +Toolkit for building a bash-like shell in the browser, including full readline support
+- Cmd1: + Javascript Online SHell provides a toolkit for building bash-like command line consoles for web pages. It consists of the following components:
-- Cmd2: +
readline.js
- full readline support for ctrl sequences, tab, history, etc.
+ shell.js
- visual presentation of the shell and command handling
+ pathhandler.js
- provide cd, ls, pwd and path completion toolkit
+ history.js
- localStorage backed command history
+ killring.js
- killring for kill & yank handling in readline
+ help
or hit TAB
for a list of commands.
+ ls
,
+ cd
, pwd
and bash filename tab-completion
+ josh.js is licensed under the Apache 2.0 License
+ +Until documentation is written, refer to index.html
and
+ the annotated version of
+ example.js
for a sample implementation of a shell with path completion.
josh is built from 5 components and can be used in part or in full.
+ +readline.js
has no dependencies on any outside libraries, although it requires either
+ history.js
and killring.js
or objects implementing the same calls.
It implements key trapping to bring
+ GNU Readline like line editing to the browser. It can be used by itself to bring readline support to custom data entry fields or in conjunction with
+ shell.js
to create a full console.
In the below C-x
refers to the Ctrl-x
keystroke, while
+ M-x
refers to the Meta-x
keystroke which is mapped to Alt
, ⌘
and
+ Left Windows
.
C-b
or Left Arrow
+ M-b
or Right Arrow
+ C-f
M-f
C-a
or Home
+ C-e
or End
+ Backspace
C-d
or Delete
+ C-k
M-Backspace
M-d
C-y
M-y
C-r
C-p
or Up Arrow
+ C-n
or Down Arrow
+ Page Up
Page Down
C-l
Tab
Esc
in reverse search
+ C-c
onCancel
handlerC-d
on empty line
+ onCancel
handlershell.js
has external dependencies of jQuery,
+ Underscore and internal dependencies of readline.js
and
+ history.js
.
It provides a simple console UI, using a panel for the console viewport and an auto-scrolling + view inside the panel. It uses Underscore templates for generating the view html, although any template generator can be substituted as long as it can be expressed in the form of a function that takes a JSON object of arguments and returns an html string.
- - var history = new Josh.History(); - var killring = new Josh.KillRing(); - var cmd1 = new Josh.Input({id: "cmd1"}); - var cmd2 = new Josh.Input({id: "cmd2"}); - \ No newline at end of file diff --git a/js/example.js b/javascripts/example.js similarity index 86% rename from js/example.js rename to javascripts/example.js index f8d2cc8..e0293f3 100644 --- a/js/example.js +++ b/javascripts/example.js @@ -1,5 +1,5 @@ /*------------------------------------------------------------------------* - * Copyright 2013-2014 Arne F. Claassen + * Copyright 2013 Arne F. Claassen * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -45,7 +45,7 @@ // ------------------------- // Setup the `Underscore` template for displaying items in the `KillRing`. - var killringItemTemplate = _.template("Id: | <%=user.id %> |
Name: | <%=user.login %> |
Location: | <%=user.location %> |
pathhandler.js
is a mix in to easily add the cd
, ls
and
+ pwd
commands as well as path completion. It has
+ Underscore as its dependency for templating, however since all
+ templates it uses are exposed, they could easily be replaced with a different function that accepts an argument object returns html.
+
By implementing the functions getNode
and
+ getChildNodes
, this library adds path traversal, discovery and completion just like a bash shell.
+
PathHandler
deals with files/directories a path node with at least the following two fields:
{ + name: 'localname', + path: '/full/path/to/localname' +}+
where name
is the name of the node and
+ path
is the absolute path to the node. PathHandler gets path nodes as the callback argument for
+ getNode
and does not modify it itself, so any additional state required can be attached to the node and be relied on as being part of the node when it is provided to
+ getChildNodes
or a template.
PathHandler
is used to add standard unix directory handling commands and path completion to Josh.Shell
.
Prints the current directory, as defined by pathhandler.current
.
Prints out the listing of all childNodes of the node represented by path
. If path is not specified, pathhandler.current
is used instead.
Changes pathhandler.current
to the node found at path
, if one could be found. If path is not specified, pathhandler.current
is used instead, resulting in a no-op.
Each template is expected to a function that takes a data object and returns html. The default templates are built with
+ _.template
but any templating system can be used as long as follows the same behavior.
Called when getNode
returns null as its callback argument.
Data:
+cmd
- command that resulted in a miss on path lookuppath
- the path that did not match a nodeCalled to generate output for a successful ls
cmd.
Data:
+nodes
- array of path nodesCalled to generate output for pwd
cmd.
Data:
+node
- the current
nodeCalled to generate the new prompt after any cmd attempt (including after completions).
+ +Data:
+node
- the current
nodeContains the current directory. It has to be initialized before activating the shell, since on activation, getPrompt
will be called by the shell and requires current to be set to display the prompt. Could be changed manually, but generally is changed as a result of calling cd
.
Contains the path completion handler used by PathHandler. Exposed so that other commands added that work on paths, can use it as their completion
handler. It is used for the ls
and cd
commands. The only assumption it makes about paths is that they are separated by /
.
Path completion is done by first finding the nearest node, i.e. if a trailing slash is found, it calls getNode
with the path, otherwise it will call getNode
and upon receiving null, will take the subpath to the nearest slash and call getNode
again. If a node is found in either scenario, it then calls getChildnodes
to get all all children, i.e. the possible completions and in turn call shell.bestMatch
with any partial path after the slash and the list of possible child node name
s.
Contains a wrapper around pathCompletionHandler
that replaces the default completion handler of the shell, so that a completion event without a known command can determine whether to complete as a command name or a path.
Contains the templates described above.
+ +Create a new path handler and attach it to a shell instance.
+ +This method is called with a path
and a callback
expecting a path node returned if the path is valid. The default implementation always calls callback
with null. This is where custom path resolution logic goes and where path nodes are constructed.
This method is called by the pathCompletionHandler
after resolving a path to a node via getNode
and expects its callback to be called with an array of pathnodes.
This method is called each time the prompt needs to be re-rendered, which in turn calls templates.prompt
with pathhandler,current
. Could be replaced for custom prompt behavior beyond altering the template.