const {exec} = require('child_process'); const {resolve, basename} = require('path'); const {writeFileSync} = require('fs'); const {app, dialog} = require('electron'); const {sync: mkdirpSync} = require('mkdirp'); const Config = require('electron-config'); const ms = require('ms'); const shellEnv = require('shell-env'); const config = require('./config'); const notify = require('./notify'); // local storage const cache = new Config(); // modules path const path = resolve(config.getConfigDir(), '.hyper_plugins'); const localPath = resolve(path, 'local'); const availableExtensions = new Set([ 'onApp', 'onWindow', 'onRendererWindow', 'onUnload', 'middleware', 'reduceUI', 'reduceSessions', 'reduceTermGroups', 'decorateMenu', 'decorateTerm', 'decorateHyper', 'decorateHyperTerm', // for backwards compatibility with hyperterm 'decorateHeader', 'decorateTerms', 'decorateTab', 'decorateNotification', 'decorateNotifications', 'decorateTabs', 'decorateConfig', 'decorateEnv', 'decorateTermGroup', 'decorateSplitPane', 'getTermProps', 'getTabProps', 'getTabsProps', 'getTermGroupProps', 'mapHyperTermState', 'mapTermsState', 'mapHeaderState', 'mapNotificationsState', 'mapHyperTermDispatch', 'mapTermsDispatch', 'mapHeaderDispatch', 'mapNotificationsDispatch' ]); // init plugin directories if not present mkdirpSync(path); mkdirpSync(localPath); // caches let plugins = config.getPlugins(); let paths = getPaths(plugins); let id = getId(plugins); let modules = requirePlugins(); function getId(plugins_) { return JSON.stringify(plugins_); } const watchers = []; // we listen on configuration updates to trigger // plugin installation config.subscribe(() => { const plugins_ = config.getPlugins(); if (plugins !== plugins_) { const id_ = getId(plugins_); if (id !== id_) { id = id_; plugins = plugins_; updatePlugins(); } } }); let updating = false; function updatePlugins({force = false} = {}) { if (updating) { return notify('Plugin update in progress'); } updating = true; syncPackageJSON(); const id_ = id; install(err => { updating = false; if (err) { console.error(err.stack); if (/not a recognized/.test(err.message) || /command not found/.test(err.message)) { notify( 'Error updating plugins.', 'We could not find the `npm` command. Make sure it\'s in $PATH' ); } else { notify( 'Error updating plugins.', 'Check `~/.hyper_plugins/npm-debug.log` for more information.' ); } } else { // flag successful plugin update cache.set('hyper.plugins', id_); // cache paths paths = getPaths(plugins); // clear require cache clearCache(); // cache modules modules = requirePlugins(); const loaded = modules.length; const total = paths.plugins.length + paths.localPlugins.length; const pluginVersions = JSON.stringify(getPluginVersions()); const changed = cache.get('hyper.plugin-versions') !== pluginVersions && loaded === total; cache.set('hyper.plugin-versions', pluginVersions); // notify watchers if (force || changed) { if (changed) { notify( 'Plugins Updated', 'Restart the app or hot-reload with "View" > "Reload" to enjoy the updates!' ); } else { notify( 'Plugins Updated', 'No changes!' ); } watchers.forEach(fn => fn(err, {force})); } } }); } function getPluginVersions() { const paths_ = paths.plugins.concat(paths.localPlugins); return paths_.map(path => { let version = null; try { // eslint-disable-next-line import/no-dynamic-require version = require(resolve(path, 'package.json')).version; } catch (err) { } return [ basename(path), version ]; }); } function clearCache() { // trigger unload hooks modules.forEach(mod => { if (mod.onUnload) { mod.onUnload(app); } }); // clear require cache for (const entry in require.cache) { if (entry.indexOf(path) === 0 || entry.indexOf(localPath) === 0) { delete require.cache[entry]; } } } exports.updatePlugins = updatePlugins; // we schedule the initial plugins update // a bit after the user launches the terminal // to prevent slowness if (cache.get('hyper.plugins') !== id || process.env.HYPER_FORCE_UPDATE) { // install immediately if the user changed plugins console.log('plugins have changed / not init, scheduling plugins installation'); setTimeout(() => { updatePlugins(); }, 5000); } // otherwise update plugins every 5 hours setInterval(updatePlugins, ms('5h')); function syncPackageJSON() { const dependencies = toDependencies(plugins); const pkg = { name: 'hyper-plugins', description: 'Auto-generated from `~/.hyper.js`!', private: true, version: '0.0.1', repository: 'zeit/hyper', license: 'MIT', homepage: 'https://hyper.is', dependencies }; const file = resolve(path, 'package.json'); try { writeFileSync(file, JSON.stringify(pkg, null, 2)); } catch (err) { alert(`An error occurred writing to ${file}`); } } function alert(message) { dialog.showMessageBox({ message, buttons: ['Ok'] }); } function toDependencies(plugins) { const obj = {}; plugins.plugins.forEach(plugin => { const regex = /.(@|#)/; const match = regex.exec(plugin); if (match) { const index = match.index + 1; const pieces = []; pieces[0] = plugin.substring(0, index); pieces[1] = plugin.substring(index + 1, plugin.length); obj[pieces[0]] = pieces[1]; } else { obj[plugin] = 'latest'; } }); return obj; } function install(fn) { const {shell: cfgShell, npmRegistry} = exports.getDecoratedConfig(); const shell = cfgShell && cfgShell !== '' ? cfgShell : undefined; shellEnv(shell).then(env => { if (npmRegistry) { env.NPM_CONFIG_REGISTRY = npmRegistry; } /* eslint-disable camelcase */ env.npm_config_runtime = 'electron'; env.npm_config_target = process.versions.electron; env.npm_config_disturl = 'https://atom.io/download/atom-shell'; /* eslint-enable camelcase */ // Shell-specific installation commands const installCommands = { fish: 'npm prune; and npm install --production', posix: 'npm prune && npm install --production' }; // determine the shell we're running in const whichShell = (typeof cfgShell === 'string' && cfgShell.match(/fish/)) ? 'fish' : 'posix'; const execOptions = { cwd: path, env }; // https://nodejs.org/api/child_process.html#child_process_child_process_exec_command_options_callback // node.js requires command line parsing should be compatible with cmd.exe on Windows, should able to accept `/d /s /c` // but most custom shell doesn't. Instead, falls back to default shell if (process.platform !== 'win32') { execOptions.shell = shell; } // Use the install command that is appropriate for our shell exec(installCommands[whichShell], execOptions, err => { if (err) { return fn(err); } fn(null); }); }).catch(fn); } exports.subscribe = function (fn) { watchers.push(fn); return () => { watchers.splice(watchers.indexOf(fn), 1); }; }; function getPaths() { return { plugins: plugins.plugins.map(name => { return resolve(path, 'node_modules', name.split('#')[0]); }), localPlugins: plugins.localPlugins.map(name => { return resolve(localPath, name); }) }; } // expose to renderer exports.getPaths = getPaths; // get paths from renderer exports.getBasePaths = function () { return {path, localPath}; }; function requirePlugins() { const {plugins, localPlugins} = paths; const load = path => { let mod; try { // eslint-disable-next-line import/no-dynamic-require mod = require(path); const exposed = mod && Object.keys(mod).some(key => availableExtensions.has(key)); if (!exposed) { notify('Plugin error!', `Plugin "${basename(path)}" does not expose any ` + 'Hyper extension API methods'); return; } // populate the name for internal errors here mod._name = basename(path); return mod; } catch (err) { console.error(err); notify('Plugin error!', `Plugin "${basename(path)}" failed to load (${err.message})`); } }; return plugins.map(load) .concat(localPlugins.map(load)) .filter(v => Boolean(v)); } exports.onApp = function (app) { modules.forEach(plugin => { if (plugin.onApp) { plugin.onApp(app); } }); }; exports.onWindow = function (win) { modules.forEach(plugin => { if (plugin.onWindow) { plugin.onWindow(win); } }); }; // decorates the base object by calling plugin[key] // for all the available plugins function decorateObject(base, key) { let decorated = base; modules.forEach(plugin => { if (plugin[key]) { const res = plugin[key](decorated); if (res && typeof res === 'object') { decorated = res; } else { notify('Plugin error!', `"${plugin._name}": invalid return type for \`${key}\``); } } }); return decorated; } exports.decorateMenu = function (tpl) { return decorateObject(tpl, 'decorateMenu'); }; exports.getDecoratedEnv = function (baseEnv) { return decorateObject(baseEnv, 'decorateEnv'); }; exports.getDecoratedConfig = function () { const baseConfig = config.getConfig(); return decorateObject(baseConfig, 'decorateConfig'); }; exports.getDecoratedBrowserOptions = function (defaults) { return decorateObject(defaults, 'decorateBrowserOptions'); }; exports._toDependencies = toDependencies;