From 988b0ace8c56471f92a76a8ac070d64678814dfe Mon Sep 17 00:00:00 2001 From: Pig Fang Date: Sun, 5 Apr 2020 10:15:00 +0800 Subject: [PATCH] add commands for managing plugin --- package.json | 4 +- resources/assets/src/scripts/cli.tsx | 15 +++- .../assets/src/scripts/cli/AptCommand.ts | 33 +++++++++ .../assets/src/scripts/cli/DnfCommand.ts | 33 +++++++++ .../assets/src/scripts/cli/PacmanCommand.ts | 41 +++++++++++ resources/assets/src/scripts/cli/Spinner.ts | 26 +++++++ .../assets/src/scripts/cli/configureStdio.ts | 27 +++++++- .../assets/src/scripts/cli/pluginManager.ts | 43 ++++++++++++ resources/assets/src/scripts/cli/readline.ts | 9 +++ .../tests/scripts/cli/AptCommand.test.ts | 68 +++++++++++++++++++ .../tests/scripts/cli/DnfCommand.test.ts | 68 +++++++++++++++++++ .../tests/scripts/cli/PacmanCommand.test.ts | 64 +++++++++++++++++ .../assets/tests/scripts/cli/Spinner.test.ts | 14 ++++ webpack.config.js | 1 + yarn.lock | 22 ++++-- 15 files changed, 459 insertions(+), 9 deletions(-) create mode 100644 resources/assets/src/scripts/cli/AptCommand.ts create mode 100644 resources/assets/src/scripts/cli/DnfCommand.ts create mode 100644 resources/assets/src/scripts/cli/PacmanCommand.ts create mode 100644 resources/assets/src/scripts/cli/Spinner.ts create mode 100644 resources/assets/src/scripts/cli/pluginManager.ts create mode 100644 resources/assets/src/scripts/cli/readline.ts create mode 100644 resources/assets/tests/scripts/cli/AptCommand.test.ts create mode 100644 resources/assets/tests/scripts/cli/DnfCommand.test.ts create mode 100644 resources/assets/tests/scripts/cli/PacmanCommand.test.ts create mode 100644 resources/assets/tests/scripts/cli/Spinner.test.ts diff --git a/package.json b/package.json index 5e608c48..eff85ead 100644 --- a/package.json +++ b/package.json @@ -21,9 +21,11 @@ "@hot-loader/react-dom": "^16.11.0", "@tweenjs/tween.js": "^18.4.2", "admin-lte": "^3.0.1", - "blessing-skin-shell": "^0.2.0", + "blessing-skin-shell": "^0.3.1", + "cli-spinners": "^2.2.0", "commander": "^5.0.0", "echarts": "^4.6.0", + "enquirer": "^2.3.4", "immer": "^6.0.2", "jquery": "^3.4.1", "lodash.debounce": "^4.0.8", diff --git a/resources/assets/src/scripts/cli.tsx b/resources/assets/src/scripts/cli.tsx index 7631f965..94342f5b 100644 --- a/resources/assets/src/scripts/cli.tsx +++ b/resources/assets/src/scripts/cli.tsx @@ -5,7 +5,11 @@ import { FitAddon } from 'xterm-addon-fit' import { Shell } from 'blessing-skin-shell' import 'xterm/css/xterm.css' import Draggable from 'react-draggable' +import * as event from './event' +import AptCommand from './cli/AptCommand' import ClosetCommand from './cli/ClosetCommand' +import DnfCommand from './cli/DnfCommand' +import PacmanCommand from './cli/PacmanCommand' import RmCommand from './cli/RmCommand' import styles from '@/styles/terminal.module.scss' @@ -31,14 +35,21 @@ const TerminalWindow: React.FC<{ onClose(): void }> = (props) => { fitAddon.fit() const shell = new Shell(terminal) + shell.addExternal(AptCommand.name, AptCommand) shell.addExternal('closet', ClosetCommand) + shell.addExternal(DnfCommand.name, DnfCommand) + shell.addExternal(PacmanCommand.name, PacmanCommand) shell.addExternal('rm', RmCommand) - const unbind = terminal.onData((e) => shell.input(e)) + const unbindData = terminal.onData((e) => shell.input(e)) + const unbindKey = terminal.onKey((e) => + event.emit('terminalKeyPress', e.key), + ) launched = true return () => { - unbind.dispose() + unbindData.dispose() + unbindKey.dispose() shell.free() fitAddon.dispose() terminal.dispose() diff --git a/resources/assets/src/scripts/cli/AptCommand.ts b/resources/assets/src/scripts/cli/AptCommand.ts new file mode 100644 index 00000000..85f7d2ab --- /dev/null +++ b/resources/assets/src/scripts/cli/AptCommand.ts @@ -0,0 +1,33 @@ +import type { Stdio } from 'blessing-skin-shell' +import { Command } from 'commander' +import { hackStdout, overrideExit } from './configureStdio' +import { install, remove } from './pluginManager' + +export default async function apt(stdio: Stdio, args: string[]) { + const program = new Command() + + /* istanbul ignore next */ + if (process.env.NODE_ENV !== 'test') { + process.stdout = hackStdout(stdio) + overrideExit(program, stdio) + } + + program.name(apt.name) + + program + .command('install ') + .description('install a new plugin') + .action((plugin: string) => install(plugin, stdio)) + + program + .command('upgrade ') + .description('upgrade an existed plugin') + .action((plugin: string) => install(plugin, stdio)) + + program + .command('remove ') + .description('remove a plugin') + .action((plugin: string) => remove(plugin, stdio)) + + await program.parseAsync(args, { from: 'user' }) +} diff --git a/resources/assets/src/scripts/cli/DnfCommand.ts b/resources/assets/src/scripts/cli/DnfCommand.ts new file mode 100644 index 00000000..82a30bee --- /dev/null +++ b/resources/assets/src/scripts/cli/DnfCommand.ts @@ -0,0 +1,33 @@ +import type { Stdio } from 'blessing-skin-shell' +import { Command } from 'commander' +import { hackStdout, overrideExit } from './configureStdio' +import { install, remove } from './pluginManager' + +export default async function dnf(stdio: Stdio, args: string[]) { + const program = new Command() + + /* istanbul ignore next */ + if (process.env.NODE_ENV !== 'test') { + process.stdout = hackStdout(stdio) + overrideExit(program, stdio) + } + + program.name(dnf.name) + + program + .command('install ') + .description('install a new plugin') + .action((plugin: string) => install(plugin, stdio)) + + program + .command('upgrade ') + .description('upgrade an existed plugin') + .action((plugin: string) => install(plugin, stdio)) + + program + .command('remove ') + .description('remove a plugin') + .action((plugin: string) => remove(plugin, stdio)) + + await program.parseAsync(args, { from: 'user' }) +} diff --git a/resources/assets/src/scripts/cli/PacmanCommand.ts b/resources/assets/src/scripts/cli/PacmanCommand.ts new file mode 100644 index 00000000..8746a432 --- /dev/null +++ b/resources/assets/src/scripts/cli/PacmanCommand.ts @@ -0,0 +1,41 @@ +import type { Stdio } from 'blessing-skin-shell' +import { Command } from 'commander' +import { hackStdout, overrideExit } from './configureStdio' +import { install, remove } from './pluginManager' + +type Options = { + sync?: string + remove?: string +} + +export default async function pacman(stdio: Stdio, args: string[]) { + if (args.length === 0) { + stdio.println('error: no operation specified (use -h for help)') + return + } + + const program = new Command() + + /* istanbul ignore next */ + if (process.env.NODE_ENV !== 'test') { + process.stdout = hackStdout(stdio) + overrideExit(program, stdio) + } + + program.name(pacman.name) + + program + .option('-S, --sync ') + .description('install or upgrade a plugin') + program.option('-R, --remove ').description('remove a plugin') + + program.parse(args, { from: 'user' }) + + const opts: Options = program.opts() + /* istanbul ignore else */ + if (opts.sync) { + await install(opts.sync, stdio) + } else if (opts.remove) { + await remove(opts.remove, stdio) + } +} diff --git a/resources/assets/src/scripts/cli/Spinner.ts b/resources/assets/src/scripts/cli/Spinner.ts new file mode 100644 index 00000000..b3b46f16 --- /dev/null +++ b/resources/assets/src/scripts/cli/Spinner.ts @@ -0,0 +1,26 @@ +import type { Stdio } from 'blessing-skin-shell' +import { dots } from 'cli-spinners' + +export class Spinner { + private timerId: number = 0 + private index: number = 0 + + constructor(private stdio: Stdio) {} + + start(message = '') { + this.timerId = window.setInterval(() => { + this.index += 1 + this.index %= dots.frames.length + + this.stdio.reset() + this.stdio.print(`${dots.frames[this.index]} ${message}`) + }, dots.interval) + } + + stop(message = '') { + clearInterval(this.timerId) + this.stdio.reset() + this.stdio.println(message) + this.stdio.print('\u001B[?25h') + } +} diff --git a/resources/assets/src/scripts/cli/configureStdio.ts b/resources/assets/src/scripts/cli/configureStdio.ts index 0a56bb29..e1d2076c 100644 --- a/resources/assets/src/scripts/cli/configureStdio.ts +++ b/resources/assets/src/scripts/cli/configureStdio.ts @@ -1,11 +1,36 @@ import { Stdio } from 'blessing-skin-shell' import type Commander from 'commander' +import * as event from '../event' + +/* istanbul ignore next */ +export function hackStdin() { + if (process.env.NODE_ENV === 'test') { + return process.stdin + } + + // @ts-ignore + return { + on(eventName: string, handler: (key: string) => void) { + if (eventName === 'keypress') { + this._off = event.on('terminalKeyPress', (key: string) => { + handler(key) + }) + } + }, + isTTY: true, + setRawMode() {}, + removeListener() { + this._off() + }, + } as NodeJS.ReadStream & { _off(): void } +} /* istanbul ignore next */ export function hackStdout(stdio: Stdio) { return { write(msg: string) { stdio.print(msg.replace(/\n/g, '\r\n')) + return true }, } as NodeJS.WriteStream } @@ -14,7 +39,7 @@ export function hackStdout(stdio: Stdio) { export function overrideExit(program: Commander.Command, stdio: Stdio) { Error.captureStackTrace = () => {} - return program.exitOverride(error => { + return program.exitOverride((error) => { if (!error.message.startsWith('(')) { stdio.print(error.message.replace(/\n/g, '\r\n')) } diff --git a/resources/assets/src/scripts/cli/pluginManager.ts b/resources/assets/src/scripts/cli/pluginManager.ts new file mode 100644 index 00000000..1f2e7f8e --- /dev/null +++ b/resources/assets/src/scripts/cli/pluginManager.ts @@ -0,0 +1,43 @@ +import type { Stdio } from 'blessing-skin-shell' +import { prompt } from 'enquirer' +import * as fetch from '../net' +import { hackStdout, hackStdin } from './configureStdio' +import { Spinner } from './Spinner' + +export async function install(plugin: string, stdio: Stdio) { + const spinner = new Spinner(stdio) + spinner.start('Installing plugin...') + + const { message, data } = await fetch.post< + fetch.ResponseBody<{ reason?: string[] } | undefined> + >('/admin/plugins/market/download', { name: plugin }) + + spinner.stop(` ${message}`) + const reasons = data?.reason + if (reasons) { + stdio.println(reasons.map((reason) => `- ${reason}`).join('\r\n')) + } +} + +export async function remove(plugin: string, stdio: Stdio) { + const { confirm }: { confirm: boolean } = await prompt({ + name: 'confirm', + type: 'confirm', + message: `Are you sure to remove plugin "${plugin}"?`, + stdin: hackStdin(), + stdout: hackStdout(stdio), + }) + + if (!confirm) { + return + } + + const spinner = new Spinner(stdio) + spinner.start('Uninstalling plugin...') + + const { message } = await fetch.post( + '/admin/plugins/manage', + { action: 'delete', name: plugin }, + ) + spinner.stop(` ${message}`) +} diff --git a/resources/assets/src/scripts/cli/readline.ts b/resources/assets/src/scripts/cli/readline.ts new file mode 100644 index 00000000..c8dbfa05 --- /dev/null +++ b/resources/assets/src/scripts/cli/readline.ts @@ -0,0 +1,9 @@ +export function emitKeypressEvents() {} + +export function createInterface() { + return { + pause() {}, + resume() {}, + close() {}, + } +} diff --git a/resources/assets/tests/scripts/cli/AptCommand.test.ts b/resources/assets/tests/scripts/cli/AptCommand.test.ts new file mode 100644 index 00000000..69d3eba7 --- /dev/null +++ b/resources/assets/tests/scripts/cli/AptCommand.test.ts @@ -0,0 +1,68 @@ +import * as fetch from '@/scripts/net' +import apt from '@/scripts/cli/AptCommand' +import { Stdio } from './stdio' + +jest.mock('@/scripts/net') + +describe('install plugin', () => { + it('succeeded', async () => { + fetch.post.mockResolvedValue({ code: 0, message: 'ok' }) + const stdio = new Stdio() + + await apt(stdio, ['install', 'test']) + expect(fetch.post).toBeCalledWith('/admin/plugins/market/download', { + name: 'test', + }) + expect(stdio.getStdout()).toInclude('ok') + }) + + it('failed with reasons', async () => { + fetch.post.mockResolvedValue({ + code: 1, + message: 'failed', + data: { reason: ['unresolved'] }, + }) + const stdio = new Stdio() + + await apt(stdio, ['install', 'test']) + expect(fetch.post).toBeCalledWith('/admin/plugins/market/download', { + name: 'test', + }) + expect(stdio.getStdout()).toInclude('failed') + expect(stdio.getStdout()).toInclude('- unresolved') + }) + + it('use `upgrade` command', async () => { + fetch.post.mockResolvedValue({ code: 0, message: 'ok' }) + const stdio = new Stdio() + + await apt(stdio, ['upgrade', 'test']) + expect(fetch.post).toBeCalledWith('/admin/plugins/market/download', { + name: 'test', + }) + expect(stdio.getStdout()).toInclude('ok') + }) +}) + +describe('remove plugin', () => { + it('cancelled', async () => { + const stdio = new Stdio() + + process.nextTick(() => process.stdin.emit('keypress', 'n')) + await apt(stdio, ['remove', 'test']) + expect(fetch.post).not.toBeCalled() + }) + + it('succeeded', async () => { + fetch.post.mockResolvedValue({ code: 0, message: 'ok' }) + const stdio = new Stdio() + + process.nextTick(() => process.stdin.emit('keypress', 'y')) + await apt(stdio, ['remove', 'test']) + expect(fetch.post).toBeCalledWith('/admin/plugins/manage', { + action: 'delete', + name: 'test', + }) + expect(stdio.getStdout()).toInclude('ok') + }) +}) diff --git a/resources/assets/tests/scripts/cli/DnfCommand.test.ts b/resources/assets/tests/scripts/cli/DnfCommand.test.ts new file mode 100644 index 00000000..ab75c7cf --- /dev/null +++ b/resources/assets/tests/scripts/cli/DnfCommand.test.ts @@ -0,0 +1,68 @@ +import * as fetch from '@/scripts/net' +import dnf from '@/scripts/cli/DnfCommand' +import { Stdio } from './stdio' + +jest.mock('@/scripts/net') + +describe('install plugin', () => { + it('succeeded', async () => { + fetch.post.mockResolvedValue({ code: 0, message: 'ok' }) + const stdio = new Stdio() + + await dnf(stdio, ['install', 'test']) + expect(fetch.post).toBeCalledWith('/admin/plugins/market/download', { + name: 'test', + }) + expect(stdio.getStdout()).toInclude('ok') + }) + + it('failed with reasons', async () => { + fetch.post.mockResolvedValue({ + code: 1, + message: 'failed', + data: { reason: ['unresolved'] }, + }) + const stdio = new Stdio() + + await dnf(stdio, ['install', 'test']) + expect(fetch.post).toBeCalledWith('/admin/plugins/market/download', { + name: 'test', + }) + expect(stdio.getStdout()).toInclude('failed') + expect(stdio.getStdout()).toInclude('- unresolved') + }) + + it('use `upgrade` command', async () => { + fetch.post.mockResolvedValue({ code: 0, message: 'ok' }) + const stdio = new Stdio() + + await dnf(stdio, ['upgrade', 'test']) + expect(fetch.post).toBeCalledWith('/admin/plugins/market/download', { + name: 'test', + }) + expect(stdio.getStdout()).toInclude('ok') + }) +}) + +describe('remove plugin', () => { + it('cancelled', async () => { + const stdio = new Stdio() + + process.nextTick(() => process.stdin.emit('keypress', 'n')) + await dnf(stdio, ['remove', 'test']) + expect(fetch.post).not.toBeCalled() + }) + + it('succeeded', async () => { + fetch.post.mockResolvedValue({ code: 0, message: 'ok' }) + const stdio = new Stdio() + + process.nextTick(() => process.stdin.emit('keypress', 'y')) + await dnf(stdio, ['remove', 'test']) + expect(fetch.post).toBeCalledWith('/admin/plugins/manage', { + action: 'delete', + name: 'test', + }) + expect(stdio.getStdout()).toInclude('ok') + }) +}) diff --git a/resources/assets/tests/scripts/cli/PacmanCommand.test.ts b/resources/assets/tests/scripts/cli/PacmanCommand.test.ts new file mode 100644 index 00000000..362c022c --- /dev/null +++ b/resources/assets/tests/scripts/cli/PacmanCommand.test.ts @@ -0,0 +1,64 @@ +import * as fetch from '@/scripts/net' +import pacman from '@/scripts/cli/PacmanCommand' +import { Stdio } from './stdio' + +jest.mock('@/scripts/net') + +test('no arguments', async () => { + const stdio = new Stdio() + await pacman(stdio, []) + expect(stdio.getStdout()).toInclude('help') + expect(fetch.post).not.toBeCalled() +}) + +describe('install plugin', () => { + it('succeeded', async () => { + fetch.post.mockResolvedValue({ code: 0, message: 'ok' }) + const stdio = new Stdio() + + await pacman(stdio, ['-S', 'test']) + expect(fetch.post).toBeCalledWith('/admin/plugins/market/download', { + name: 'test', + }) + expect(stdio.getStdout()).toInclude('ok') + }) + + it('failed with reasons', async () => { + fetch.post.mockResolvedValue({ + code: 1, + message: 'failed', + data: { reason: ['unresolved'] }, + }) + const stdio = new Stdio() + + await pacman(stdio, ['-S', 'test']) + expect(fetch.post).toBeCalledWith('/admin/plugins/market/download', { + name: 'test', + }) + expect(stdio.getStdout()).toInclude('failed') + expect(stdio.getStdout()).toInclude('- unresolved') + }) +}) + +describe('remove plugin', () => { + it('cancelled', async () => { + const stdio = new Stdio() + + process.nextTick(() => process.stdin.emit('keypress', 'n')) + await pacman(stdio, ['-R', 'test']) + expect(fetch.post).not.toBeCalled() + }) + + it('succeeded', async () => { + fetch.post.mockResolvedValue({ code: 0, message: 'ok' }) + const stdio = new Stdio() + + process.nextTick(() => process.stdin.emit('keypress', 'y')) + await pacman(stdio, ['-R', 'test']) + expect(fetch.post).toBeCalledWith('/admin/plugins/manage', { + action: 'delete', + name: 'test', + }) + expect(stdio.getStdout()).toInclude('ok') + }) +}) diff --git a/resources/assets/tests/scripts/cli/Spinner.test.ts b/resources/assets/tests/scripts/cli/Spinner.test.ts new file mode 100644 index 00000000..95624b7f --- /dev/null +++ b/resources/assets/tests/scripts/cli/Spinner.test.ts @@ -0,0 +1,14 @@ +import { Spinner } from '@/scripts/cli/Spinner' +import { Stdio } from './stdio' + +test('run', () => { + const stdio = new Stdio() + const spinner = new Spinner(stdio) + + spinner.start() + + jest.runTimersToTime(500) + expect(stdio.getStdout().length).toBeGreaterThan(0) + + spinner.stop() +}) diff --git a/webpack.config.js b/webpack.config.js index 142266e4..4e482932 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -134,6 +134,7 @@ const config = { alias: { 'react-dom': '@hot-loader/react-dom', '@': path.resolve(__dirname, 'resources/assets/src'), + 'readline': '@/scripts/cli/readline.ts', }, }, externals: devMode diff --git a/yarn.lock b/yarn.lock index 24e9f3d0..0d2d5b43 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2072,7 +2072,7 @@ amdefine@>=0.0.4: version "1.0.1" resolved "https://registry.npmjs.org/amdefine/-/amdefine-1.0.1.tgz#4a5282ac164729e93619bcfd3ad151f817ce91f5" -ansi-colors@^3.0.0: +ansi-colors@^3.0.0, ansi-colors@^3.2.1: version "3.2.4" resolved "https://registry.npmjs.org/ansi-colors/-/ansi-colors-3.2.4.tgz#e3a3da4bfbae6c86a9c285625de124a234026fbf" integrity sha512-hHUXGagefjN2iRrID63xckIvotOXOojhQKWIPUZ4mNUZ9nLZW+7FMNoE1lOkEhNWYsx/7ysGIuJYCiMAA9FnrA== @@ -2443,10 +2443,10 @@ binary-extensions@^2.0.0: resolved "https://registry.yarnpkg.com/binary-extensions/-/binary-extensions-2.0.0.tgz#23c0df14f6a88077f5f986c0d167ec03c3d5537c" integrity sha512-Phlt0plgpIIBOGTT/ehfFnbNlfsDEiqmzE2KRXoX1bLIlir4X/MR+zSyBEkL05ffWgnRSf/DXv+WrUAVr93/ow== -blessing-skin-shell@^0.2.0: - version "0.2.0" - resolved "https://registry.yarnpkg.com/blessing-skin-shell/-/blessing-skin-shell-0.2.0.tgz#2454e305c69de134164491d2715a34d599c0d8a1" - integrity sha512-bwVHWep3jlPwgY7rAmr+KZJr1RdfVS9OTQB25avRW4EzONflQRiRgU76T8X/DeWa3PItEEGf3qNUJJuRhN5orw== +blessing-skin-shell@^0.3.1: + version "0.3.1" + resolved "https://registry.yarnpkg.com/blessing-skin-shell/-/blessing-skin-shell-0.3.1.tgz#154b1d3d38f7ba137a66795450f8e7d3b0b6eca9" + integrity sha512-CaVdi3waNXIUKdpQgppY+LeOAuQB6uakHdA7bhMalw9zTz+/VTyuDtnPnkSjpFLDsbBaWL4vSqcgui+pPYZDzQ== bluebird@^3.1.1, bluebird@^3.5.5: version "3.5.5" @@ -2999,6 +2999,11 @@ cli-cursor@^3.1.0: dependencies: restore-cursor "^3.1.0" +cli-spinners@^2.2.0: + version "2.2.0" + resolved "https://registry.yarnpkg.com/cli-spinners/-/cli-spinners-2.2.0.tgz#e8b988d9206c692302d8ee834e7a85c0144d8f77" + integrity sha512-tgU3fKwzYjiLEQgPMD9Jt+JjHVL9kW93FiIMX/l7rivvOD4/LL0Mf7gda3+4U2KJBloybwgj5KEoQgGRioMiKQ== + cli-width@^2.0.0: version "2.2.0" resolved "https://registry.npmjs.org/cli-width/-/cli-width-2.2.0.tgz#ff19ede8a9a5e579324147b0c11f0fbcbabed639" @@ -4253,6 +4258,13 @@ enhanced-resolve@^4.0.0: memory-fs "^0.5.0" tapable "^1.0.0" +enquirer@^2.3.4: + version "2.3.4" + resolved "https://registry.yarnpkg.com/enquirer/-/enquirer-2.3.4.tgz#c608f2e1134c7f68c1c9ee056de13f9b31076de9" + integrity sha512-pkYrrDZumL2VS6VBGDhqbajCM2xpkUNLuKfGPjfKaSIBKYopQbqEFyrOkRMIb2HDR/rO1kGhEt/5twBwtzKBXw== + dependencies: + ansi-colors "^3.2.1" + entities@^2.0.0: version "2.0.0" resolved "https://registry.npmjs.org/entities/-/entities-2.0.0.tgz#68d6084cab1b079767540d80e56a39b423e4abf4"