add commands for managing plugin

This commit is contained in:
Pig Fang 2020-04-05 10:15:00 +08:00
parent afd7ea993b
commit 988b0ace8c
15 changed files with 459 additions and 9 deletions

View File

@ -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",

View File

@ -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()

View File

@ -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 <plugin>')
.description('install a new plugin')
.action((plugin: string) => install(plugin, stdio))
program
.command('upgrade <plugin>')
.description('upgrade an existed plugin')
.action((plugin: string) => install(plugin, stdio))
program
.command('remove <plugin>')
.description('remove a plugin')
.action((plugin: string) => remove(plugin, stdio))
await program.parseAsync(args, { from: 'user' })
}

View File

@ -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 <plugin>')
.description('install a new plugin')
.action((plugin: string) => install(plugin, stdio))
program
.command('upgrade <plugin>')
.description('upgrade an existed plugin')
.action((plugin: string) => install(plugin, stdio))
program
.command('remove <plugin>')
.description('remove a plugin')
.action((plugin: string) => remove(plugin, stdio))
await program.parseAsync(args, { from: 'user' })
}

View File

@ -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 <plugin>')
.description('install or upgrade a plugin')
program.option('-R, --remove <plugin>').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)
}
}

View File

@ -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')
}
}

View File

@ -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'))
}

View File

@ -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<fetch.ResponseBody>(
'/admin/plugins/manage',
{ action: 'delete', name: plugin },
)
spinner.stop(` ${message}`)
}

View File

@ -0,0 +1,9 @@
export function emitKeypressEvents() {}
export function createInterface() {
return {
pause() {},
resume() {},
close() {},
}
}

View File

@ -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')
})
})

View File

@ -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')
})
})

View File

@ -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')
})
})

View File

@ -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()
})

View File

@ -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

View File

@ -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"