├── .gitignore ├── LICENSE ├── README.md ├── package.json └── src ├── core ├── config.js ├── context.js ├── exec.js └── repl.js └── index.js /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules 3 | package-lock.json 4 | yarn.lock 5 | yarn-error.log 6 | .vscode -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Rikumi Yu 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # fresh-shell 2 | 3 | 基于 Node.js 的 JavaScript「壳中壳」 4 | 5 | ![image](https://user-images.githubusercontent.com/5051300/76057979-0e7d4a00-5fb6-11ea-9db8-188b083578e4.png) 6 | 7 | ## 介绍 8 | 9 | Shell 是 Unix 世界中一个不可或缺的工具,但它的语法也许不那么优雅、难以学习,阻止了你很多突发奇想,让很多需要高生产力的临时需求不得不变成写一个冗长的 Node 脚本再去调试运行。 10 | 11 | 例如,你如何批量重命名一大堆文件?如何把一个文件夹里所有的 png 文件都调用一次 `tinypng` CLI,并用得到的压缩图片文件替换原文件?对于没有系统学习过 Shell 语法的开发者,这些任务都是艰巨的,写一个这样的脚本往往要用到 Node.js,同时一些细小的任务还是需要用到 Shell。 12 | 13 | 人们曾经想过改进 Shell 的语法,但事实证明,这是一个大工程,而且是一个社会工程。我们常用的 Zsh 对 Bash 兼容性很好,但它语法也就跟 Bash 一样复杂;假如我们下决心用上稍微优雅一些的 Fish,如何把别人留在开发文档里的 Bash 复杂命令改成 Fish?如何去说服你的同事跟你一起用 Fish 来避免分享高级用法的过程中出现语法冲突? 14 | 15 | 有开发者想到了将 Shell 语法与脚本语言融合,[Xonsh](https://xon.sh/) 就是这样一个产物。但经过长期使用,我们会发现 Xonsh 并没有实现全部的 Shell 语法,很多语法都是缺失的,这些问题的根源在于 Xonsh 试图把两个语法糅合在一起,而不是区分它们的上下文。 16 | 17 | Fresh 是一个构造极简的 JavaScript Unix Shell。它能在**不破坏语法**的前提下,让 JavaScript 语法和**任意一种标准 Shell** 的语法融合,并同时满足你基于 Shell 语法的日常使用和基于 JavaScript 的 Hacking。 18 | 19 | 为什么不破坏语法?因为 Fresh 区分上下文,使用自动补全的 Tag Function 来处理 Shell 语法。 20 | 21 | ## 安装和使用 22 | 23 | 安装 Fresh 之前,请确保你的 Node.JS 已安装并位于 `/usr/local/bin/node` 下。 24 | 25 | ```sh 26 | rikumi $ npm i -g fresh-shell 27 | rikumi $ fresh 28 | 29 | rikumi ƒ`| 30 | ``` 31 | 32 | 安装后,你可以将 fresh 的入口程序(通常为 `/usr/local/bin/fresh`)设置为终端和 VS Code 的默认 Shell。 33 | 34 | Fresh 中必备的两个字符是 ƒ\`,我们可以称之为「软提示符」(位于标准输入流而非输出流中、可以删除和修改的提示符)。软提示符的存在让我们可以直接键入 Shell 命令,而不需要多余的语法。 35 | 36 | 输入任何你需要的 Shell 命令(如 `ls`),回车之后,Fresh 会自动补全末尾的反引号 \`,因此会产生 Tag Function Call 语法:ƒ\``ls`\`,其中函数 ƒ 的含义为**同步执行 Shell**,因此 `ls` 命令会被执行。这就是 Fresh 的基本工作原理。 37 | 38 | ## 如何输入 ƒ` 39 | 40 | 使用 option+F 可以输入 ƒ;\`(反引号)是半角状态下 Tab 上方的按键。 41 | 42 | ## 交互执行和隐藏执行 43 | 44 | ƒ 函数(`exec` 函数)有两种模式:交互执行和隐藏执行。 45 | 46 | 当输入的命令行是单一的 ƒ 调用时,会进入交互执行状态,输入输出以标准输入输出的形式提供;当 ƒ 调用是输入的表达式中的一部分,会进入隐藏执行状态,标准输出和标准错误输出不会直接上屏,而是作为 ƒ 函数的返回值提供。 47 | 48 | ƒ 函数的返回值是一个特殊字符串,字符串的值为子程序的标准输出(经过 UTF-8 解码得到的内容);该字符串上附加有 `status`、`stderr`、`error` 三个属性,分别表示状态码、标准错误输出(经过 UTF-8 解码得到的内容)和 JavaScript 错误对象。 49 | 50 | 下图中分别展示了命令 `git status` 的交互执行模式,以及隐藏执行模式下的 `status`、`stderr`、`error` 属性。 51 | 52 | ![image](https://user-images.githubusercontent.com/5051300/76059992-302d0000-5fbb-11ea-8c11-99db4e89bcaa.png) 53 | 54 | ## 内置指令 55 | 56 | Fresh 本身并不是 Unix Shell,而是 Unix Shell 的一层封装,所有的 Shell 命令都是在子进程中执行的。这也就意味着 Fresh 执行的 Shell 无法改变 Fresh 本身的状态。而一部分状态是需要跨命令保持的,其中就包括**工作目录(CWD)**、**环境变量**以及**别名** 57 | 58 | 如果我们用 Shell 模式执行 `cd`/`export`/`alias` 语句,它们退出后不会对 Fresh 本身产生任何影响;因此,Fresh 实现了 `cd`/`export`/`alias` **内置指令**,让单一的 `cd`/`export`/`alias` 语句能够工作。 59 | 60 | 注意,Fresh 只支持在单一语句中独立使用 `cd `、`export =`、`alias [=]` 的语法,并支持在其中进行简单的环境变量插值;复杂的用法将会以 Unix Shell 模式执行,导致它们不会对 Fresh 本身的状态产生影响。以下是一个对比简单 `cd`/`export` 语句和复杂 `cd`/`export` 语句的例子。 61 | 62 | ![image](https://user-images.githubusercontent.com/5051300/76062025-c3683480-5fbf-11ea-83c5-aa1d4e9e49e0.png) 63 | 64 | 可以看到,简单的 `cd`/`export`/`alias` 语句会被 Fresh 捕获,产生的效果会对后面的指令生效;复杂的 `cd`/`export`/`alias` 语句会采用 Shell 模式进行执行,因此不会对 Fresh 本身以及后面的指令产生影响。 65 | 66 | ## Node.js 环境与自动 require 67 | 68 | Fresh 的本质是一个 Node.js REPL(但并没有使用 Node.js REPL 库,而是用表现更稳定、可定制性更强的 Node.js Readline 库进行实现),其中只有 ƒ 函数是与 Shell 执行相关的;除 ƒ 函数之外,Fresh 也是一个完整的 Node.js 执行环境。 69 | 70 | 相比标准 Node REPL,Fresh 面向终端场景,加入了自动 `require` 的能力,即在全局命名空间下,找不到的对象,会自动尝试 `require`。同时,在配置文件中也可以增加新的自动导入函数。 71 | 72 | ![image](https://user-images.githubusercontent.com/5051300/76062665-17274d80-5fc1-11ea-9925-bc6d2f8459c3.png) 73 | 74 | ## 融合使用 75 | 76 | 借助上述特性,可以将 Shell 命令与 JavaScript 融合使用,Shell 中使用模板插值语法 `${}` 可以嵌入 JavaScript 表达式;JavaScript 中也可以使用 ƒ 函数嵌入 Shell 执行结果: 77 | 78 | ![image](https://user-images.githubusercontent.com/5051300/76063500-cd3f6700-5fc2-11ea-9ab9-f35371766d24.png) 79 | 80 | ## 配置文件 81 | 82 | Fresh 支持使用配置文件进行自定义,实现嵌套 Shell 和配置继承、提示符美化(如实现简易的 Powerline 风格)、定制 JavaScript 环境、定制 Tab 自动完成、定制颜色高亮等。 83 | 84 | 配置文件位于 `~/.freshrc.js`,会在 Fresh 启动时被执行。配置文件中可以对全局配置对象 `config` 进行修改。预设的 config 对象参见[这里](https://github.com/rikumi/fresh-shell/blob/master/src/core/config.js)。 85 | 86 | ### 定制动词 87 | 88 | 修改 `config.verb`,可以将默认的动词 ƒ 修改成其他[合法的 JavaScript 标识符](https://mothereff.in/js-variables)。 89 | 90 | ![image](https://user-images.githubusercontent.com/5051300/76219201-6c2cc300-6250-11ea-846e-25f8f9adbe91.png) 91 | 92 | ### 嵌套 Shell 与执行前指令 93 | 94 | Fresh 可以使用 Bash、Zsh、Fish、Xonsh 等任何支持 `-c` 参数的第三方 Shell 作为内嵌 Shell 工具,只需在配置文件中更改 `config.shell` 即可: 95 | 96 | ```js 97 | config.shell = '/bin/zsh'; 98 | ``` 99 | 100 | 被嵌套的 Shell 默认以 `--login`(Login + Interactive)方式执行,在该模式下,Shell 将会加载更多的默认环境变量,但不会自动加载配置文件。 101 | 102 | 如要修改被嵌套的 Shell 的附加执行参数(默认为 `['--login']`),可以在配置文件中操作 `config.shellArgs`。注意,`-c` 将会自动添加; 103 | 104 | 如需加载 `.bashrc`/`.zshrc` 等配置文件(不推荐),可以在 `config.shellCommandPrefix` 中设置需要添加到每次 Shell 被执行字符串之前的指令,例如 `config.shellCommandPrefix = 'source ~/.bashrc;'`,注意不要忘了以分号结尾。 105 | 106 | ### 配置文件中执行 Shell 语句 107 | 108 | 在配置文件中,可以像在 Fresh 内一样使用 ƒ 函数,但固定处于隐藏执行模式,如果要查看输出,需要配合 `console.log` 等方式。 109 | 110 | 请注意对 `config.shell` 的修改与执行 Shell 语句之间的先后顺序。 111 | 112 | ### 定制提示符(以 Powerline 风格为例) 113 | 114 | 为了方便自定义,配置对象中 `config.prompt` 函数被拆成三个函数:`config.prompt`、`config.git`、`config.cwd`(实际被 Fresh 调用的只有 `config.prompt` 函数),以便于分别改写提示符样式、Git 显示格式和 CWD 显示格式。 115 | 116 | 这里以 Powerline 风格提示符为例,展示如何改写提示符样式。 117 | 118 | ```js 119 | const chalk = require('chalk').default; 120 | 121 | config.prompt = (status = 0) => { 122 | const bgColor = status ? chalk.bgRed : chalk.bgBlue; 123 | const fgColor = status ? chalk.red : chalk.blue; 124 | const bgBlack = chalk.bgBlack; 125 | const fgBlack = chalk.black; 126 | const fgWhite = chalk.white; 127 | const git = config.git(); 128 | if (git) { 129 | return bgColor(' ' + fgBlack(config.cwd()) + ' ') + 130 | bgBlack(fgColor('') + fgWhite(config.git()) + ' ') + 131 | fgBlack('') + ' '; 132 | } else { 133 | return bgColor(' ' + fgBlack(config.cwd()) + ' ') + 134 | fgColor('') + ' '; 135 | } 136 | } 137 | ``` 138 | 139 | 效果如下: 140 | 141 | ![image](https://user-images.githubusercontent.com/5051300/76068492-35df1180-5fcc-11ea-9720-e9209b4f86f0.png) 142 | 143 | ### 定制 Tab 自动完成 144 | 145 | Fresh 默认配置中提供了路径自动完成和 Git 分支自动完成;你还可以修改 `config.complete` 函数来完善这一特性。自动完成函数的写法参见 [Readline 文档](https://nodejs.org/api/readline.html#readline_use_of_the_completer_function)。 146 | 147 | ### 定制 history 长度 148 | 149 | 键入过的最近 100 条语句会保存在 ~/.fresh_history 文件中,可以修改 `config.historySize` 来改变这个数字。 150 | 151 | ### 定制语法高亮 152 | 153 | 为了方便自定义,默认配置对象中将语法高亮分为四个函数:`config.colorizeToken`、`config.colorizeCode`、`config.colorizeCommand`、`config.colorizeOutput`,它们分别对应于对单个 JavaScript Token 的高亮、对一段代码的 Token 解析与高亮、对输入命令的高亮以及对 JavaScript 输出结果的高亮。实际被 Fresh 调用的只有 `config.colorizeCommand` 和 `config.colorizeOutput` 两个函数。 154 | 155 | ### 定制进程标题 156 | 157 | 改变 `config.makeTitle` 函数可以定制 Fresh 的进程标题,用于显示在 GUI 终端中。该函数接受零个或一个参数,接受零个参数时,需要返回 Fresh 处于空闲状态时的进程标题;接受一个参数时,该参数是要执行的命令行程序的 argv 列表,需要返回 Fresh 执行该子程序时的默认进程标题。 158 | 159 | ## 建议与贡献 160 | 161 | 欢迎对本项目提出 Issue 或 Pull Requests。需要注意的是,Fresh 作为一个外壳程序,对于功能上的要求会进行一定的取舍,在保持实现简单的前提下合理迭代。 162 | 163 | 因为 Fresh 的最大意义是用 < 500 行代码实现 JavaScript 与其他 Shell 的融合,而非做一个完美无缺的 Shell 本身。 164 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "fresh-shell", 3 | "version": "3.2.0", 4 | "description": "The fully customizable JavaScript shell, for everyday use.", 5 | "main": "index.js", 6 | "bin": { 7 | "fresh": "src/index.js" 8 | }, 9 | "repository": "https://github.com/rikumi/fresh-shell", 10 | "author": "rikumi", 11 | "license": "MIT", 12 | "scripts": {}, 13 | "dependencies": { 14 | "chalk": "^2.4.1", 15 | "expand-home-dir": "^0.0.3", 16 | "git-state": "^4.1.0", 17 | "glob": "^7.1.6", 18 | "historic-readline": "^1.0.8", 19 | "import-cwd": "^2.1.0", 20 | "js-tokens": "^4.0.0", 21 | "lib-pathcomplete": "^0.0.1", 22 | "list-git-branches": "^1.0.0", 23 | "moment": "^2.22.2", 24 | "shell-quote": "^1.7.2", 25 | "string-width": "^2.1.1" 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/core/config.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs'); 2 | const util = require('util'); 3 | const path = require('path'); 4 | 5 | const moment = require('moment'); 6 | const tokens = require('js-tokens'); 7 | const chalk = require('chalk').default; 8 | 9 | const expandHomeDir = require('expand-home-dir'); 10 | const { parse } = require('shell-quote'); 11 | 12 | const git = require('git-state'); 13 | const branch = require('list-git-branches'); 14 | const pathComplete = require('lib-pathcomplete'); 15 | 16 | // Change the options below by modifying the `config` global variable in ~/.freshrc.js. 17 | const config = { 18 | verb: 'ƒ', 19 | shell: '/bin/sh', 20 | shellArgs: ['-l'], // dash (default shell in Debian/Ubuntu) is not compatible with --login 21 | shellCommandPrefix: '', 22 | env: [require], 23 | alias: {}, 24 | historySize: 100, 25 | git() { 26 | let cwd = process.cwd(); 27 | if (!git.isGitSync(cwd)) return ''; 28 | let { branch, ahead, dirty, untracked } = git.checkSync(cwd); 29 | let str = ' ' + branch; 30 | if (untracked) str += '*'; 31 | if (dirty) str += '+'; 32 | if (ahead > 0) str += '↑'; 33 | if (ahead < 0) str += '↓'; 34 | return str; 35 | }, 36 | cwd() { 37 | return path.basename(process.cwd()) || '/'; 38 | }, 39 | prompt(status = 0) { 40 | return (status ? chalk.red : chalk.blue)(config.cwd() + chalk.gray(config.git()) + ' '); 41 | }, 42 | async complete(line, callback) { 43 | let last = line; 44 | if (/[^\\]\s$/.test(last)) { 45 | last = ''; 46 | } 47 | if (/`/.test(last)) { 48 | last = /[^`]*$/.exec(last)[0]; 49 | } 50 | last = parse(last, process.env).slice(-1)[0] || ''; 51 | last = expandHomeDir(last); 52 | 53 | pathComplete(last, (err, data, info) => { 54 | try { 55 | if (err) throw err; 56 | let paths = (data || []).map((file) => { 57 | if (fs.statSync(path.join(info.dir, file)).isDirectory()) file += '/'; 58 | return file.replace(/ /g, '\\ '); 59 | }); 60 | let branches = /[^\/]+$/.test(last) && ~line.indexOf('git ') && git.isGitSync(process.cwd()) ? 61 | branch.sync('.').filter((k) => k.indexOf(/[^\/]+$/.exec(last)[0]) === 0) : []; 62 | callback(null, [ 63 | paths.concat(branches).filter((k, i, a) => a.indexOf(k) === i), 64 | path.basename(last).replace(/ /g, '\\ ') + (/\/$/.test(last) ? '/' : '') 65 | ]); 66 | } catch (e) { 67 | callback(null, [[]]); 68 | } 69 | }) 70 | }, 71 | makeTitle(argv) { 72 | if (!argv) { 73 | return config.verb; 74 | } else { 75 | const [cmd, ...args] = argv; 76 | return config.verb + ' > ' + cmd + (args.length ? '…' : '') 77 | } 78 | }, 79 | colorizeToken(token) { 80 | return { 81 | string: chalk.green, 82 | comment: chalk.gray, 83 | regex: chalk.cyan, 84 | number: chalk.yellow, 85 | name: token.value === config.verb ? chalk.blue : token.value === 'null' || token.value === 'undefined' ? chalk.gray : chalk.reset, 86 | punctuator: chalk.reset, 87 | whitespace: chalk.reset, 88 | invalid: chalk.red 89 | }[token.type](token.value); 90 | }, 91 | colorizeCode(code) { 92 | if (!code) return ''; 93 | let result = ''; 94 | let match = tokens.default.exec(code); 95 | while (match) { 96 | let token = tokens.matchToToken(match); 97 | result += config.colorizeToken(token); 98 | match = tokens.default.exec(code); 99 | } 100 | return result; 101 | }, 102 | colorizeCommand(command) { 103 | return config.colorizeCode(command.trim()) + chalk.gray(' - ' + moment().format('H:mm:ss')); 104 | }, 105 | colorizeOutput(output) { 106 | return config.colorizeCode(util.inspect(output)); 107 | } 108 | }; 109 | 110 | module.exports = config; 111 | -------------------------------------------------------------------------------- /src/core/context.js: -------------------------------------------------------------------------------- 1 | const importCwd = require('import-cwd'); 2 | const config = require('./config'); 3 | const { exec } = require('./exec'); 4 | 5 | const context = new Proxy( 6 | Object.assign(global, { 7 | require: Object.assign(require, importCwd), 8 | config, 9 | [config.verb]: exec.bind(null, false), 10 | }), 11 | { 12 | get(target, key) { 13 | if (key in target) { 14 | return target[key]; 15 | } 16 | 17 | let result; 18 | if (typeof key === 'string' && key !== '') { 19 | config.env.find((env) => { 20 | try { 21 | return (result = env(key)); 22 | } catch (e) {} 23 | }); 24 | } 25 | return result; 26 | }, 27 | set(target, key, value) { 28 | target[key] = value; 29 | return true; 30 | } 31 | } 32 | ); 33 | 34 | context.global = context; 35 | module.exports = context; 36 | -------------------------------------------------------------------------------- /src/core/exec.js: -------------------------------------------------------------------------------- 1 | const cp = require('child_process'); 2 | const expand = require('expand-home-dir'); 3 | const { parse } = require('shell-quote'); 4 | const config = require('./config'); 5 | const chalk = require('chalk').default; 6 | 7 | const setTitle = (title) => { 8 | process.stdout.write( 9 | String.fromCharCode(27) + "]0;" + title + String.fromCharCode(7) 10 | ); 11 | } 12 | 13 | const exec = (interactive, template, ...interpolations) => { 14 | let command = String.raw(template, ...interpolations); 15 | 16 | if (!command) { 17 | return ''; 18 | } 19 | 20 | let argv = parse(command, process.env); 21 | let [cmd, ...args] = argv; 22 | 23 | if (config.alias[cmd]) { 24 | let replacement = config.alias[cmd]; 25 | command = command.replace(cmd, replacement); 26 | argv = parse(command, process.env); 27 | [cmd, ...args] = argv; 28 | } 29 | 30 | if (cmd === 'exit') { 31 | process.exit(); 32 | } else if (cmd === 'cd' && args.length <= 1) { 33 | process.chdir(expand(args[0] || '~')); 34 | return ''; 35 | } else if (cmd === 'export' && args.length === 1) { 36 | const [key, value] = args[0].split('='); 37 | process.env[key] = value; 38 | return ''; 39 | } else if (cmd === 'alias') { 40 | if (args.length === 0) { 41 | const alias = Object.keys(config.alias).map(k => `${k}='${config.alias[k]}'`).join('\n'); 42 | if (!interactive && alias) console.log(alias); 43 | return alias; 44 | } 45 | if (args.length === 1) { 46 | const [key, value] = args[0].split('='); 47 | config.alias[key] = value; 48 | return ''; 49 | } 50 | } 51 | 52 | if (argv.includes('exit')) { 53 | console.log(chalk.yellow('Warning: complex commands with `exit` will not affect fresh shell. This can be a no-op. Use simple `exit` command instead.')); 54 | } 55 | 56 | if (argv.includes('cd')) { 57 | console.log(chalk.yellow('Warning: complex commands with `cd` will not affect the working directory of fresh shell. This can be a no-op. Use simple `cd ` command instead.')); 58 | } 59 | 60 | if (argv.includes('export')) { 61 | console.log(chalk.yellow('Warning: complex commands with `export` will not affect the environment variables of fresh shell. This can be a no-op. Use simple `export =` command instead.')); 62 | } 63 | 64 | if (argv.includes('alias')) { 65 | console.log(chalk.yellow('Warning: complex commands with `alias` will not affect command aliases in fresh shell. This can be a no-op. Use simple `alias` command instead.')); 66 | } 67 | 68 | if (typeof config.shellCommandPrefix === 'string') { 69 | command = config.shellCommandPrefix + command; 70 | } 71 | 72 | try { 73 | setTitle(config.makeTitle(argv)); 74 | process.stdin.setRawMode(false); 75 | 76 | // Use sh to execute the command 77 | const { 78 | status, 79 | stdout, 80 | stderr, 81 | error = null 82 | } = cp.spawnSync(config.shell, [...config.shellArgs, '-c', command], { 83 | stdio: [ 84 | 'inherit', 85 | interactive ? 'inherit' : 'pipe', 86 | interactive ? 'inherit' : 'pipe' 87 | ] 88 | }); 89 | 90 | return Object.assign((stdout || '').toString().replace(/\n$/, ''), { 91 | status, 92 | stderr: (stderr || '').toString(), 93 | error: error, 94 | }); 95 | } finally { 96 | process.stdin.setRawMode(true); 97 | setTitle(config.makeTitle()); 98 | } 99 | }; 100 | 101 | module.exports = { 102 | setTitle, 103 | exec 104 | }; -------------------------------------------------------------------------------- /src/core/repl.js: -------------------------------------------------------------------------------- 1 | const os = require('os'); 2 | const path = require('path'); 3 | const readline = require('historic-readline'); 4 | const stringWidth = require('string-width'); 5 | 6 | const noop = k => k; 7 | 8 | module.exports = class Repl { 9 | constructor(options, readlineOptions) { 10 | this.interface = null; 11 | this.hardPrompt = ''; 12 | this.softPrompt = ''; 13 | this.lastHardPrompt = ''; 14 | this.lastSoftPrompt = ''; 15 | this.transformer = noop; 16 | this.formatter = noop; 17 | this.executor = noop; 18 | 19 | Object.assign(this, options); 20 | 21 | readline.createInterface({ 22 | input: process.stdin, 23 | output: process.stdout, 24 | path: path.join(os.homedir(), '.fresh_history'), 25 | ...readlineOptions, 26 | next: (rl) => { 27 | this.interface = rl; 28 | this.interface.on('line', (content) => this.handleReturn(content)); 29 | this.interface.on('SIGINT', () => this.handleSIGINT()); 30 | this.makeBothPrompts(); 31 | } 32 | }); 33 | } 34 | 35 | getLineCountFromText(text) { 36 | return (text + '|') 37 | .split('\n') 38 | .map(cmd => Math.ceil(Math.max(1, stringWidth(cmd)) / (process.stdout.columns || 60))) 39 | .reduce((a, b) => a + b, 0); 40 | } 41 | 42 | clearLines(lineCount) { 43 | while (lineCount--) { 44 | process.stdout.moveCursor(0, -1); 45 | process.stdout.clearLine(0); 46 | } 47 | } 48 | 49 | clearLinesForText(text) { 50 | this.clearLines(this.getLineCountFromText(text)); 51 | } 52 | 53 | makeHardPrompt(cached = false) { 54 | if (cached) { 55 | process.stdout.write(this.lastHardPrompt); 56 | } else { 57 | const prompt = typeof this.hardPrompt === 'function' ? this.hardPrompt() : this.hardPrompt; 58 | this.lastHardPrompt = prompt; 59 | this.interface.setPrompt(prompt); 60 | this.interface.prompt(); 61 | } 62 | } 63 | 64 | makeSoftPrompt(cached = false) { 65 | if (cached) { 66 | this.interface.write(this.lastSoftPrompt); 67 | } else { 68 | const prompt = typeof this.softPrompt === 'function' ? this.softPrompt() : this.softPrompt; 69 | this.lastSoftPrompt = prompt; 70 | this.interface.write(prompt); 71 | } 72 | } 73 | 74 | makeBothPrompts(cached = false) { 75 | this.makeHardPrompt(cached); 76 | this.makeSoftPrompt(cached); 77 | } 78 | 79 | async handleReturn(content) { 80 | const contentToClear = this.lastHardPrompt + content; 81 | this.clearLinesForText(contentToClear); 82 | this.makeHardPrompt(true); 83 | 84 | if (!content.trim() || content.trim() === this.lastSoftPrompt.trim()) { 85 | this.makeSoftPrompt(true); 86 | return; 87 | } 88 | 89 | content = this.transformer(content); 90 | const reformattedContent = this.formatter(content); 91 | process.stdout.write(reformattedContent + '\n'); 92 | 93 | await this.executor(content); 94 | 95 | this.makeBothPrompts(); 96 | } 97 | 98 | handleSIGINT() { 99 | this.interface.clearLine(); 100 | this.clearLines(process.stdout.rows); 101 | this.makeBothPrompts(true); 102 | } 103 | } -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | #!/usr/local/bin/node 2 | const vm = require('vm'); 3 | const fs = require('fs'); 4 | const os = require('os'); 5 | const path = require('path'); 6 | 7 | const Repl = require('./core/repl'); 8 | const config = require('./core/config'); 9 | const context = require('./core/context'); 10 | const { exec, setTitle } = require('./core/exec'); 11 | 12 | const sandbox = vm.createContext(context); 13 | 14 | process.on('unhandledRejection', (e) => { throw e }); 15 | process.on('uncaughtException', console.log); 16 | process.on('SIGINT', () => {}); 17 | 18 | try { 19 | vm.runInContext(fs.readFileSync(path.join(os.homedir(), '.freshrc.js')).toString(), context); 20 | } catch (e) { } 21 | 22 | setTitle(config.makeTitle()); 23 | 24 | const repl = new Repl({ 25 | hardPrompt: config.prompt, 26 | softPrompt: () => config.verb + '`', 27 | transformer: (text) => { 28 | if (new RegExp(config.verb + '`([^`\\\\]|\\\\.)+$').test(text)) { 29 | text += '`'; 30 | } 31 | return text; 32 | }, 33 | formatter: config.colorizeCommand, 34 | executor: (cmd) => { 35 | const isInteractive = new RegExp('^' + config.verb + '`([^`\\\\]|\\\\.)+`$').test(cmd); 36 | sandbox[config.verb] = exec.bind(null, isInteractive); 37 | try { 38 | let result = vm.runInContext(cmd, sandbox); 39 | if (isInteractive) { 40 | repl.hardPrompt = config.prompt.bind(null, result.status); 41 | } else { 42 | console.log('>', config.colorizeOutput(result)); 43 | } 44 | } catch (e) { 45 | console.error(e); 46 | } 47 | process.stdout.write('\n'); 48 | } 49 | }, { 50 | maxLength: config.historySize, 51 | completer: config.complete 52 | }); --------------------------------------------------------------------------------