@kui-shell/core
Version:
An Electron-based shell for cloud-native development
570 lines • 21.4 kB
JavaScript
/*
* Copyright 2017 The Kubernetes Authors
*
* 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.
*/
var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
return new (P || (P = Promise))(function (resolve, reject) {
function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
step((generator = generator.apply(thisArg, _arguments || [])).next());
});
};
import Debug from 'debug';
const debug = Debug('core/command-tree');
debug('loading');
import { ExecType } from '../models/command';
import eventChannelUnsafe from './events';
import { UsageError } from './usage-error';
import { oopsMessage } from './oops';
import { prescanModel } from '../plugins/plugins';
import { getModelInternal } from '../commands/tree';
import { registerTypeahead } from '../commands/typeahead';
import { Context, getCurrentContext } from '../commands/context';
debug('finished loading modules');
/**
* Plugin registry
*
*/
let resolver;
export const setPluginResolver = (_) => {
resolver = _;
};
/**
* Is route (/a/b/c) exactly the same as path (['a', 'b', 'c'])
*
* @param route is a registered command handler
* @param path is what the user typed
*
*/
const exactlyTheSameRoute = (route, path) => {
const routeAsPath = route.split('/').slice(1);
for (let idx = 0; idx < routeAsPath.length; idx++) {
if (routeAsPath[idx] !== path[idx]) {
return false;
}
}
// if we got to this point, then route is a prefix of path
if (routeAsPath.length !== path.length) {
return false;
}
else {
return true;
}
};
/**
* Navigate the given tree `model`, following the given `path` as [n1,n2,n3]
*
*/
const treeMatch = (model, path, readonly = false, hide = false, idxStart = 0, noWildcard = false) => {
let parent = model;
let cur;
for (let idx = idxStart; idx < path.length; idx++) {
cur = (parent.children && parent.children[path[idx]]);
if (!cur) {
// then we've reached the bottom of the tree
if (readonly) {
// we were instructed to give up
break;
}
else {
// otherwise, we were instructed to create a new leaf node
if (!parent.children) {
parent.children = {};
}
cur = parent.children[path[idx]] = {
$: undefined,
parent,
key: path[idx],
options: { hide },
route: `${parent.route === '/' ? '' : parent.route}/${path[idx]}`
};
}
}
else {
// console.log('found', path[idx])
}
parent = cur;
cur = (cur.children && cur.children[path[idx]]);
// console.log('match', idx, path[idx], cur)
}
if (!cur && !noWildcard) {
// prefix match, e.g. "cleanAll !!!" should match a /cleanAll listener, as we have an implicit suffix wildcard
// console.log('end of the line', parent)
cur = parent;
}
if (cur.options && cur.options.noArgs && !exactlyTheSameRoute(cur.route, path)) {
// if cur represents a command registration that has asserted
// it takes no extra arguments, we can fast-path this as a
// non-match, if cur's route doesn't contain the requested
// command path
}
else {
return cur;
}
};
const match = (path, readonly) => {
return treeMatch(getModelInternal().root, path, readonly);
};
class DefaultCommandOptions {
}
/**
* Register a command handler on the given route
*
*/
const _listen = (model, route, handler, options = new DefaultCommandOptions()) => {
if (resolver && resolver.isOverridden(route)) {
// then the overridden function was prematurely loaded; force a
// load of the overriding plugin
resolver.resolve(route, { tryCatchalls: false });
}
const path = route.split('/').splice(1);
const leaf = treeMatch(getModelInternal().root, path, false, options.hide);
if (leaf) {
const prevOptions = leaf.options;
if (options) {
leaf.options = options;
}
if (leaf.$) {
// then we're overriding an existing command
if (!leaf.options)
leaf.options = {};
if (prevOptions) {
Object.assign(leaf.options, prevOptions);
}
leaf.options.override = leaf.$;
}
leaf.$ = handler;
leaf.route = route;
// populate the typeahead trie
registerTypeahead(route);
return leaf;
}
};
const listen = (route, handler, options) => _listen(getModelInternal().root, route, handler, options);
/**
* Register a subtree in the command tree
*
*/
const _subtree = (route, options) => {
const myListen = options.listen || listen;
const path = route.split('/').splice(1);
const leaf = match(path, false /*, options */);
if (leaf) {
leaf.route = route;
if (options) {
leaf.options = options;
}
else {
leaf.options = {};
}
leaf.options.isDirectory = true;
const help = () => {
// the usage message
const usage = options.usage || (options.synonymFor && options.synonymFor.options && options.synonymFor.options.usage);
/* if (options.synonymFor) {
usageMessage.synonymFor = options.synonymFor
} */
throw new UsageError({ usage });
};
//
// listen on /kubectl and /kubectl/help to present usage information
//
const opts = {
noArgs: true,
subtreeHandler: true,
noAuthOk: true,
requiresFullyQualifiedRoute: true
};
myListen(route, help, Object.assign({}, options, opts));
myListen(`${route}/help`, help, Object.assign({}, options, opts));
return leaf;
}
};
/**
* Register a synonym of a subtree
*
*/
const _subtreeSynonym = (route, master, options = master.options) => {
if (route !== master.route) {
// <-- don't alias to yourself!
const mySubtree = _subtree(route, Object.assign({}, options, { synonymFor: master }));
// reverse mapping from master to synonym
if (!master.synonyms)
master.synonyms = {};
master.synonyms[mySubtree.route] = mySubtree;
}
};
/**
* Register a command handler on the given route, as a synonym of the given master handler
* master is the return value of `listen`
*
*/
const _synonym = (route, handler, master, options = master.options) => {
if (route !== master.route) {
// don't alias to yourself!
const node = listen(route, handler, Object.assign({}, options, { synonymFor: master }));
// reverse mapping from master to synonym
if (!master.synonyms)
master.synonyms = {};
master.synonyms[node.route] = node;
}
};
/**
* Oops, we couldn't resolve the given command. But maybe we found
* some partial matches that might be helpful to the user.
*
*/
const commandNotFoundMessage = 'Command not found';
const commandNotFoundMessageWithPartialMatches = 'The following commands are partial matches for your request.';
/**
* Help the user with some partial matches for a command not found
* condition. Here, we reuse the usage-error formatter, to present the
* user with a list of possible completions to their (mistyped or
* otherwise) command.
*
* We use the `available` list to present the list of available
* command completions to what they typed.
*
*/
const formatPartialMatches = (partialMatches, hide) => {
return new UsageError({
message: hide ? '' : commandNotFoundMessage,
usage: {
header: commandNotFoundMessageWithPartialMatches,
available: partialMatches.map(_ => _.usage)
}
}, { noBreadcrumb: true, noHide: true });
};
const suggestPartialMatches = (command, partialMatches, noThrow = false, hide = false) => {
const availablePartials = partialMatches && partialMatches.filter(_ => _.usage);
const anyPartials = availablePartials && availablePartials.length > 0;
const error = anyPartials
? formatPartialMatches(availablePartials, hide)
: new Error(`${commandNotFoundMessage}: ${command}`);
error.code = 404;
// to allow for programmatic use of the partial matches, e.g. for tab completion
if (anyPartials) {
error.partialMatches = availablePartials.map(_ => ({
command: _.route.split('/').slice(1).join(' '),
usage: _.usage
}));
}
else {
error.hide = hide;
}
if (noThrow) {
return error;
}
else {
throw error;
}
};
/**
*
* @return a command handler with success and failure event handlers
*
*/
const withEvents = (evaluator, leaf, partialMatches) => {
// let the world know we have resolved a command, and are about to evaluate it
const event = {
// context: currentContext()
// ANONYMIZE: namespace: namespace.current()
};
// if we have a command tree node, add some extra fields to the event
if (leaf) {
event.route = leaf.route; // e.g. "/git/status" from the bash-like plugin
event.plugin = leaf.options.plugin || 'builtin'; // e.g. "bash-like"
}
const handler = {
subtree: leaf,
route: leaf.route,
eval: evaluator,
options: leaf && leaf.options,
success: ({ tab, type, command, isDrilldown = false, parsedOptions }) => {
event.tab = tab;
event.execType = type;
event.command = command;
event.isDrilldown = isDrilldown;
// any command line options that the command has blessed to pass through to the event bus
if (parsedOptions && leaf.options && leaf.options.okOptions) {
const opts = leaf.options.okOptions.filter(_ => parsedOptions[_]);
if (opts) {
event.options = opts;
}
}
/* if (leaf && eventChannelUnsafe) {
eventChannelUnsafe.emit('/command/complete', event)
if (event.execType !== ExecType.Nested) {
eventChannelUnsafe.emit('/command/complete/fromuser', tab)
}
} */
},
error: (command, tab, execType, err) => {
event.tab = tab;
event.execType = execType;
event.command = command;
event.error = oopsMessage(err);
if (leaf && eventChannelUnsafe) {
eventChannelUnsafe.emit('/command/complete', event);
if (event.execType !== ExecType.Nested) {
eventChannelUnsafe.emit('/command/complete/fromuser', tab);
}
}
if (err && err.code === 127 && partialMatches) {
// command not found
const suggestions = suggestPartialMatches(command, partialMatches, true, err.hide); // true: don't throw an exception
return suggestions;
}
return err;
}
};
return handler;
};
/**
* Parse the given argv, and return an evaluator or throw an Error
*
*/
const _read = (model, argv, contextRetry, originalArgv, tryCatchalls) => __awaiter(void 0, void 0, void 0, function* () {
let leaf = treeMatch(getModelInternal().root, argv, true); // true means read-only, don't modify the context model please
let evaluator = (leaf && leaf.$);
if (!evaluator) {
//
// maybe the plugin that supports this route hasn't been
// loaded, yet; so: invoke the plugin resolver and retry
//
const route = `/${argv.join('/')}`;
yield resolver.resolve(route, { tryCatchalls: tryCatchalls && (!contextRetry || contextRetry.length === 0) });
leaf = treeMatch(getModelInternal().root, argv, true); // true means read-only, don't modify the context model please
evaluator = (leaf && leaf.$);
}
if (!evaluator) {
if (!contextRetry) {
return false;
}
else if (contextRetry.length === 0) {
return _read(getModelInternal().root, originalArgv, undefined, originalArgv, tryCatchalls);
}
else if (contextRetry.length > 0 &&
contextRetry[contextRetry.length - 1] !== originalArgv[originalArgv.length - 1]) {
// command not found so far, look further afield.
const maybeInContextRetry = _read(getModelInternal().root, contextRetry.concat(originalArgv), contextRetry.slice(0, contextRetry.length - 1), originalArgv, tryCatchalls);
if (maybeInContextRetry) {
return maybeInContextRetry;
}
// oof, fallback plan: look in the default context
const newContext = getCurrentContext()
.concat(originalArgv)
.filter((elt, idx, A) => elt !== A[idx - 1]);
const maybeInDefaultContext = _read(getModelInternal().root, newContext, contextRetry.slice(0, contextRetry.length - 1), originalArgv, tryCatchalls);
return maybeInDefaultContext;
}
else {
// if we get here, we can't find a matching command
return false;
}
}
else {
return withEvents(evaluator, leaf);
}
});
/** read, with retries based on the current context */
const internalRead = (model, argv, tryCatchalls) => {
if (argv[0] === 'kui')
argv.shift();
return _read(getModelInternal().root, argv, Context.current, argv, tryCatchalls);
};
/**
* We could not find a registered command handler for the given `argv`.
*
*/
const commandNotFound = (argv, partialMatches, execOptions, tryCatchalls = true) => {
// first, see if we have any catchall handlers; offer the argv, and
// choose the highest priority handler that accepts the argv
if (!execOptions || !execOptions.failWithUsage) {
const catchallHandler = tryCatchalls &&
getModelInternal()
.catchalls.filter(({ offer }) => offer(argv))
.sort(({ prio: prio1 }, { prio: prio2 }) => prio2 - prio1)[0];
if (catchallHandler) {
return withEvents(catchallHandler.eval, catchallHandler, partialMatches);
}
// otherwise, give up trying to find a registered command handler,
// and look for partial matches
eventChannelUnsafe.emit('/command/resolved', {
// ANONYMIZE: namespace: namespace.current(),
error: `${commandNotFoundMessage}: ${argv.join(' ')}`,
command: argv[0]
});
}
return suggestPartialMatches(argv.join(' '), partialMatches);
};
/**
* Find partial matches at head of the given subtree. We hope that the
* last part of the argv (represented here in `partial`) is a partial
* match for some command at this root.
*
* @return all such prefix matches
*
*/
const findPartialMatchesAt = (usage, partial) => {
const matches = [];
function maybeAdd(match) {
if (match.route.indexOf(partial) >= 0 && (!match.usage || (!match.usage.synonymFor && !match.usage.hide))) {
matches.push(match);
}
}
for (const route in usage) {
maybeAdd(usage[route]);
for (const cmd in usage[route].children) {
maybeAdd(usage[route].children[cmd]);
}
}
return matches;
};
/**
* Look up a command handler for the given `argv`. This is the main
* Read part of a REPL.
*
*/
export const read = (root, argv, noRetry = false, execOptions, tryCatchalls = true) => __awaiter(void 0, void 0, void 0, function* () {
let cmd;
if (!noRetry) {
yield resolver.resolve(`/${argv.join('/')}`, { tryCatchalls: false });
cmd = yield internalRead(root, argv, tryCatchalls);
}
if (!cmd && /\.?\//.test(argv[0])) {
// Look for slashes instead of spaces. e.g. if the user types
// /a/b/c, and there is a registered command route /a/b/c,
// ... then we should resolve that command as a kui command
const { cwd } = yield import('../util/home');
const exe = argv[0].replace(/^\.\//, cwd() + '/');
const argv2 = exe.split(/\//).slice(1);
yield resolver.resolve(exe, { tryCatchalls: false });
cmd = yield internalRead(root, argv2, tryCatchalls);
}
if (!cmd) {
// command not found, but maybe we can find partial matches
// that might be helpful?
let matches;
if (argv.length === 1) {
matches = yield findPartialMatchesAt(prescanModel().usage, argv[0]);
}
else {
const allButLast = argv.slice(0, argv.length - 1);
const last = argv[argv.length - 1];
const parent = yield internalRead(root, allButLast, tryCatchalls);
if (parent) {
matches = yield findPartialMatchesAt(prescanModel().usage, last);
}
}
// found some partial matches?
if (matches && matches.length > 0) {
// debug('found partial matches', matches)
}
else {
matches = undefined;
}
return commandNotFound(argv, matches, execOptions, tryCatchalls);
}
else {
return cmd;
}
});
/**
* The model we present to plugins, a `CommandRegistrar`
*
*/
export class ImplForPlugins {
// eslint-disable-next-line no-useless-constructor
constructor(plugin) {
this.plugin = plugin;
}
catchall(offer, handler, prio = 0, options = new DefaultCommandOptions()) {
return getModelInternal().catchalls.push({
route: '*',
offer,
eval: handler,
prio,
plugin: this.plugin,
options,
$: undefined,
key: undefined,
parent: undefined
});
}
listen(route, handler, options) {
return listen(route, handler, Object.assign({}, options, { plugin: this.plugin }));
}
synonym(route, handler, master, options) {
return _synonym(route, handler, master, options && Object.assign({}, options, { plugin: this.plugin }));
}
subtree(route, options) {
return _subtree(route, options);
}
subtreeSynonym(route, master, options = master.options) {
return _subtreeSynonym(route, master, options);
}
override(route, fromPlugin, overrideHandler, options) {
return __awaiter(this, void 0, void 0, function* () {
let currentHandler = (yield this.find(route, fromPlugin)).$;
if (!currentHandler) {
if (resolver) {
yield resolver.resolveOne(fromPlugin);
}
currentHandler = (yield this.find(route, fromPlugin)).$;
if (!currentHandler) {
throw new Error(`Cannot find desired command handler for ${route} from plugin ${fromPlugin}`);
}
}
const handler = (args) => overrideHandler(args, currentHandler);
return this.listen(route, handler, options);
});
}
find(route, fromPlugin, noOverride = true) {
return __awaiter(this, void 0, void 0, function* () {
const cmd = match(route.split('/').slice(1), true);
if (!cmd || cmd.route !== route || (!noOverride && resolver && resolver.isOverridden(cmd.route))) {
if (resolver) {
if (fromPlugin) {
yield resolver.resolveOne(fromPlugin);
}
else {
yield resolver.resolve(route, { tryCatchalls: false });
}
}
return match(route.split('/').slice(1), true);
}
else {
return cmd;
}
});
}
}
/**
* Create a `CommandRegistrar` facade, for use by plugins in
* registering commands.
*
* This method is named "proxy" because it mostly delegates to the
* underlying implementation, with extra help in:
*
* - remembering from which plugin calls to listen emanate.
* - consolidating the tree model across separately-installed @kui-shell/core
*
*/
export function proxy(plugin) {
return new ImplForPlugins(plugin);
}
//# sourceMappingURL=command-tree.js.map