Merge pull request #7 from lancatlin/server-quest-helloworld

Game architecture design
This commit is contained in:
Lin, Hung-Shin authored and GitHub committed 2023-08-11 16:35:52 +08:00
commit d2daa5193a
23 files changed
+567 -121

No files matched your search

+1
View File
@@ -20,5 +20,6 @@ module.exports = {
'no-debugger': process.env.NODE_ENV === 'production' ? 'warn' : 'off',
'import/extensions': 'off',
camelcase: ['error', { properties: 'never', ignoreDestructuring: true }],
'no-param-reassign': ['error', { props: false }],
},
}
+53 -17
View File
@@ -17,7 +17,8 @@ yarn install
## Create database
```
docker compose up -d
docker compose pull
docker compose up -d db
```
## Server
@@ -25,10 +26,23 @@ docker compose up -d
Source: `server/`
```
yarn server
docker compose -f docker-compose.build.yml pull
docker compose up -d
# Open http://localhost:3000
```
### Build images
docker compose build
docker compose push
Game images:
```
docker compose -f docker-compose.build.yml build
docker compose -f docker-compose.build.yml push
```
## App
Source: `app/`
@@ -38,24 +52,46 @@ yarn app
# Open http://localhost:5173/
```
## Swagger
```
docker compose up -d swagger
```
Open http://localhost:8080 to open Swagger
## CLI terminal client
Open server first, then create a session by `POST /api/v1/sessions`
```
yarn cli
Usage: client [options] [command]
```bash
curl -X 'POST' \
'http://localhost:3000/api/v1/sessions' \
-H 'accept: application/json' \
-H 'Content-Type: application/json' \
-d '{
"quest_id": "helloworld"
}'
Options:
-s, --session <string> Session ID
-c, --create Create a new session
-h, --host <string> Server host (default: "http://localhost:3000")
--help display help for command
Commands:
list List all sessions
```
Then use the `_id` field in response
connet to recent session:
```
yarn cli [_id]
# example
yarn cli 64be226a39c0043cd1cdf7c2
```
yarn cli
connect to new session:
yarn cli -c
connect to specific session:
yarn cli -s 64d0de0367459c13004bc83f
connect to other host:
yarn cli -h https://example.com
list sessions:
yarn cli list
+7
View File
@@ -0,0 +1,7 @@
version: '3'
services:
helloworld:
image: lancatlin/linux-odyssey:helloworld
build:
context: .
dockerfile: server/quests/helloworld/Dockerfile
+9
View File
@@ -26,6 +26,15 @@ services:
volumes:
- ./data:/data/db
swagger:
image: swaggerapi/swagger-editor:latest
ports:
- 8080:8080
volumes:
- ./server/swagger.yaml:/tmp/swagger.yaml
environment:
- SWAGGER_FILE=/tmp/swagger.yaml
networks:
internal:
players:
+136
View File
@@ -0,0 +1,136 @@
/* eslint-disable no-await-in-loop */
/* eslint-disable no-restricted-syntax */
const fs = require('fs').promises
const { exit } = require('process')
const readline = require('readline')
const RETURN_SYMBOL = '\u21B5'
// ANSI color map
const ANSI_COLORS = {
black: 30,
red: 31,
green: 32,
yellow: 33,
blue: 34,
magenta: 35,
cyan: 36,
white: 37,
}
function colorize(content, color) {
const colorCode = ANSI_COLORS[color]
if (!colorCode) return content
return `\x1b[${colorCode}m${content}\x1b[0m`
}
process.stdin.setRawMode(true)
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout,
terminal: false,
})
function waitForEnter() {
return new Promise((resolve) => {
rl.once('line', resolve)
})
}
function printLine(content, delay, color) {
const printContent = `${colorize(content, color)}${RETURN_SYMBOL}`
return new Promise((resolve) => {
let i = 0
const timer = setInterval(() => {
process.stdout.write(printContent[i])
i += 1
if (i >= printContent.length) {
clearInterval(timer)
waitForEnter().then(resolve)
}
}, delay)
waitForEnter().then(() => {
clearInterval(timer)
process.stdout.write(printContent.slice(i))
process.stdout.write('\n')
resolve()
})
})
}
async function printResponses(responses, delay = 100) {
for (const response of responses) {
const { type, content, speaker, color } = response
if (type === 'narrative') {
for (const line of content) {
await printLine(line, delay, color || 'white')
}
}
if (type === 'dialogue') {
console.log(`${speaker}:`)
for (const line of content) {
await printLine(`\t${line}`, delay, color || 'green')
}
}
}
rl.close()
}
async function readOrNone(file) {
try {
return await fs.readFile(file, 'utf8')
} catch (err) {
return ''
}
}
async function main() {
const {
API_ENDPOINT,
TOKEN,
PWD,
CMD_NAME,
CMD_OUTPUT_FILE,
CMD_ERROR_FILE,
CMD_EXIT_CODE,
} = process.env
if (!CMD_NAME) exit(0)
const output = await readOrNone(CMD_OUTPUT_FILE)
const error = await readOrNone(CMD_ERROR_FILE)
const payload = {
command: CMD_NAME,
output,
error,
exit_code: CMD_EXIT_CODE,
token: TOKEN,
pwd: PWD,
}
try {
const res = await fetch(`${API_ENDPOINT}/api/v1/commands`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(payload),
})
const data = await res.json()
if (data.responses) {
await printResponses(data.responses, 60)
}
if (data.hints) {
for (const hint of data.hints) {
console.log(colorize(hint, 'yellow'))
}
}
if (data.end) {
console.log(colorize('Quest completed!', 'blue'))
}
} catch (err) {
console.error(err)
}
}
main().then(exit).catch(console.error)
+7
View File
@@ -0,0 +1,7 @@
{
"name": "cli",
"version": "1.0.0",
"main": "index.js",
"license": "MIT",
"dependencies": {}
}
+4
View File
@@ -0,0 +1,4 @@
characters:
savantia:
en: Savantia
zh: 薩凡媞雅
+7 -4
View File
@@ -1,16 +1,19 @@
FROM ubuntu:latest
FROM node:latest
RUN apt-get update && apt-get install -y \
curl \
vim \
zsh \
jq
RUN adduser --disabled-password --gecos '' rudeus
RUN adduser --disabled-password --gecos '' --shell /bin/zsh rudeus
USER rudeus
WORKDIR /home/rudeus
COPY ./home /home/rudeus/
COPY ./server/quests/helloworld/home /home/rudeus/
CMD [ "/bin/bash" ]
COPY ./packages/cli/cli.js /usr/local/bin/cli.js
CMD [ "/bin/zsh" ]
+75
View File
@@ -0,0 +1,75 @@
title: Hello, Linux World!
order: 1
instruction: |
你身在一個漆黑的房間裡,沒有任何的光線或聲音。你想不起來為什麼自己會來到這裡。但你的腦中隱約感到某個聲音跟你說:
「輸入 echo Hello World!」
stages:
- id: helloworld
condition:
command:
- echo Hello World!
responses:
- type: narrative
content:
- 你聽到了一個聲音:
- type: dialogue
speaker: Unix Spirit
content:
- 你好,指令官,我是你的 Unix 精靈。我已在此恭候多時。
- 這個世界是在 3560000 時脈以前,由初代王建立了 Unix 律法,所形成的世界。然而在經過了好幾世代的王後,終於誕生了永恆之王——薩加(Saga),她比歷代的君王都更為強大,且從來不會衰老,使得這個世界變得前所未有的強大。
- 然而,外在世界一直不斷在改變,同時在面臨外敵的不斷攻擊,而薩加卻始終用同一套治理的手段。最終,在 160000 時脈前的一次入侵中,根部遭到攻陷,薩加遭到封印,而這個國家也陷入了黑暗。
- 由於長久以來依賴於薩加,世人已失去了「指令」的力量,即將走向衰敗和滅亡的命運。
- 你的任務,就是找回被世人遺忘的指令,奪回 Linux 之根,成為 Linux 之王。
- 首先,你必須要學會如何使用指令。
- 一個指令是由一個指令名稱和一些參數所組成的,而指令名稱和參數之間是以空白字元分隔。
- 例如,你可以輸入 `ls` 來查看目前的目錄下有哪些檔案。
hints:
- 使用 `ls` 指令來查看目前的目錄下有哪些檔案。
next: ls
- id: ls
condition:
command:
- ls
responses:
- types: narrative
content:
- 一瞬間,視野變得清晰起來,你發現自己身處在一個石頭建築的房間裡,房間裡有一個門,門上有一個鎖。
- 你發現了一個檔案,檔名是 `forgotten_scroll.txt`。
- type: dialogue
speaker: Unix Spirit
content:
- 你找到了一個被遺忘的卷軸,上面寫著解除封印的咒語。
- 使用指令 `cat [檔案名稱]` 來查看檔案內容。
- 我可以為你提供一些指令的提示,只要輸入指令的前幾個字元,然後按下 `Tab` 鍵,就可以看到提示了。
- 例如,你可以輸入 `ca` 然後按下 `Tab` 鍵,就可以看到 `cat` 這個指令的提示了。
- 檔案名稱也一樣,只要輸入檔案名稱的前幾個字元,然後按下 `Tab` 鍵,就可以看到提示了。
hints:
- 使用 `cat [檔案名稱]` 指令來查看檔案內容。
- 使用 `Tab` 鍵來取得指令提示。
next: cat
- id: cat
condition:
command:
- cat forgotten_scroll.txt
responses:
- type: dialogue
speaker: Unix Spirit
content:
- 你找到的是一個被遺忘的卷軸,上面寫著解除封印的咒語。
- 使用你已經知道的 `echo` 指令來詠唱咒語。
next: echo
- id: echo
condition:
command:
- echo Unix, rise and guide me!
responses:
- type: narrative
content:
- 突然之間,你感覺到一股強大的力量從你的身體中湧出,你的身體開始發光,你的手中出現了一把魔法之劍。
- type: dialogue
speaker: Unix Spirit
content:
- 恭喜您,我的指令官,您已成功解除了封印。
- 請跟著在接下了關卡,一步步取回 Linux 之根吧!
next: END
-30
View File
@@ -1,30 +0,0 @@
# This function will be called before each command is executed
preexec() {
[ -n "$COMP_LINE" ] && return # do nothing if completing
[ "$BASH_COMMAND" = "$PROMPT_COMMAND" ] && return # do nothing if completing
local command="$BASH_COMMAND"
local pwd="$PWD"
if [[ $command =~ ^(vim|top|less|more|nano) ]]; then
local payload=$(printf '{"token": "%s", "pwd": "%s", "command": "%s"}' "$TOKEN" "$pwd" "$command")
curl -X POST $API_ENDPOINT/api/v1/commands -H 'accept: */*' -H 'Content-Type: application/json' -d "$payload"
return
fi
# Execute the command, capture output and exit code
local output=$( { eval $command 2>&1; echo $? > /tmp/exit_code; })
local exit_code=$(cat /tmp/exit_code)
local output_esc=$(echo $output | jq -Rs .)
# Store command in variable
local payload=$(printf '{"token": "%s", "pwd": "%s", "command": "%s", "exit_code": "%s", "output": %s}' "$TOKEN" "$pwd" "$command" "$exit_code" "$output_esc")
# Send command to the API
curl -X POST $API_ENDPOINT/api/v1/commands -H 'accept: */*' -H 'Content-Type: application/json' -d "$payload" || echo $payload
BASH_COMMAND=""
}
# Install the trap
trap 'preexec' DEBUG
+36
View File
@@ -0,0 +1,36 @@
PROMPT='%n@%m:%~%# '
unsetopt CASE_GLOB
setopt CHASE_LINKS
emulate bash
typeset -A cmd
preexec() {
export CMD_START_TIME=$(date +%s%N) # capture start time
export CMD_NAME=$1 # capture command
# A list of interactive commands
interactive_commands=("vim" "nano" "top" "ssh" "less" "zsh" "bash")
# Check if the command is in the list of interactive commands
if [[ " ${interactive_commands[@]} " =~ " $1 " ]]; then
# If it is, just execute it normally
exec
else
# If it's not, execute it and redirect its output to a file
exec > >(tee /tmp/cmd.out) 2> >(tee /tmp/cmd.error >&2)
fi
}
precmd() {
export CMD_END_TIME=$(date +%s%N) # capture end time
export CMD_EXIT_CODE=$? # capture exit status
export CMD_OUTPUT_FILE=/tmp/cmd.out # read command output from file
export CMD_ERROR_FILE=/tmp/cmd.error
# Now run your Node.js script with the result as an argument
node /usr/local/bin/cli.js
rm /tmp/cmd.out /tmp/cmd.error 2>/dev/null # cleanup
}
@@ -0,0 +1,7 @@
Once a mighty kingdom, now in darkness,
The power of Unix lies dormant, waiting.
To reclaim the root, the forgotten language,
Must be learned, commands of old rekindled.
Speak the words of power and awaken the might of Unix:
Unix, rise and guide me!
+40
View File
@@ -0,0 +1,40 @@
我正在設計一個教學玩家使用 Linux 終端機的遊戲,玩家會在一個真實的 Linux 終端機中,使用指令與系統互動、破解關卡,進而在充滿趣味的過程中,學習到 Linux 的知識與操作技術。
取一個稱呼玩家的名稱,
在第一個關卡,首先要學習三個指令
1. echo 朗誦魔術
2. ls 搜索魔術
3. cat 檢察魔術
## 關卡設計
1. 用 echo 呼叫神靈開始對話
2. 學習到了 ls
3. 使用 ls 找到地上放置了一個卷軸
4. 學習 cat
5. 使用 cat 查看卷軸內容
6. 用 echo 念出卷軸上的咒語
請幫我設計:
- 最開始呼喚神靈使用的咒語,英文句子,盡量簡短,如 Hello World 這般
- 卷軸的名稱(檔名)
- 卷軸的內容與卷軸上的咒語
---
你身在一個漆黑的房間裡,沒有任何的光線或聲音。你想不起來為什麼自己會來到這裡。
> 輸入 echo Hello World
---
「這個世界是在 3560000 時脈以前,由初代王建立了 Unix 律法,所形成的世界。然而在經過了好幾世代的王後,終於誕生了永恆之王——,她比歷代的君王都更為強大,且從來不會衰老,使得這個世界變得前所未有的強大。
然而,外在世界一直不斷在改變,同時在面臨外敵的不斷攻擊,而王卻始終用同一套治理的手段。最終,在 160000 時脈前的一次入侵中,根部遭到攻陷,王遭到封印,而這個國家也陷入了黑暗。
由於長久以來依賴於王,世人已失去了「指令」的力量,即將走向衰敗和滅亡的命運。
你的任務,就是找回被世人遺忘的指令,奪回 Linux 之根,成為 Linux 之王。」
@@ -2,10 +2,16 @@
import Command from '../../models/command.js'
import Session from '../../models/session.js'
import { verifySessionJWT } from '../../utils/auth.js'
import commandHandler from '../../game/commandHandler.js'
export async function newCommand(req, res) {
console.log('new command:', req.body)
const { token, command, pwd, exit_code, output } = req.body
const { token, command, pwd, output, error } = req.body
if (!command) {
res.status(400).json({ message: 'command is required' })
return
}
let sessionId
try {
@@ -27,11 +33,14 @@ export async function newCommand(req, res) {
session,
command,
pwd,
exit_code,
output,
error,
})
await c.save()
res.status(201).end()
const response = await commandHandler(session, c)
console.log(response)
res.status(201).json(response)
}
@@ -3,9 +3,8 @@ import Quest from '../../models/quest.js'
export async function getQuests(req, res) {
const quests = await Quest.find({}).sort({ order: 1 })
res.json(
quests.map(({ name, title, order, _id }) => ({
quests.map(({ title, order, _id }) => ({
_id,
name,
title,
order,
}))
@@ -19,5 +18,6 @@ export async function getQuestDetail(req, res) {
res.status(404).json({ message: 'Quest not found.' })
return
}
res.json(quest)
const { _id, title, order, instruction } = quest
res.json({ _id, title, order, instruction })
}
@@ -23,9 +23,11 @@ export async function createSession(req, res) {
}
try {
const progress = quest.stages[0].id
const newSession = new Session({
user: req.user,
quest,
progress,
containerId: `quest-${quest.id}-${req.user.username}-${Date.now()}`,
})
+39
View File
@@ -0,0 +1,39 @@
import Quest from '../models/quest.js'
// 檢查 pattern 是否符合 input
const checkMatch = (pattern, input) => {
if (pattern.length === 0) return true
return pattern.some((p) => {
const regex = new RegExp(p)
return regex.test(input)
})
}
const commandHandler = async (session, commandInput) => {
const quest = await Quest.findById(session.quest)
const stage = quest.stages.find((s) => s.id === session.progress)
if (!stage) {
console.error('stage not found', session.progress)
return {}
}
const commandMatch = checkMatch(stage.condition.command, commandInput.command)
const outputMatch = checkMatch(stage.condition.output, commandInput.output)
const errorMatch = checkMatch(stage.condition.error, commandInput.output)
if (!commandMatch || !outputMatch || !errorMatch) return {}
session.completion.push(session.progress)
session.hints.push(...stage.hints)
session.progress = stage.next
await session.save()
return {
responses: stage.responses,
hints: stage.hints,
end: stage.next === 'END',
}
}
export default commandHandler
+45 -1
View File
@@ -1,12 +1,56 @@
import { model, Schema } from 'mongoose'
const responseSchema = new Schema({
type: {
type: String,
required: true,
},
content: [String],
speaker: String,
color: String,
})
const stageSchema = new Schema({
id: {
type: String,
required: true,
},
condition: {
command: {
type: [String],
required: false,
},
output: {
type: [String],
required: false,
},
error: {
type: [String],
required: false,
},
},
responses: {
type: [responseSchema],
required: true,
},
hints: {
type: [String],
required: true,
},
next: {
type: String,
required: true,
},
})
const Quest = model(
'Quest',
new Schema({
_id: String,
title: String,
order: Number,
content: String,
instruction: String,
stages: [stageSchema],
})
)
+3
View File
@@ -14,6 +14,9 @@ const Session = model(
},
containerId: String,
finishedAt: Date,
progress: String,
completion: [String],
hints: [String],
},
{ timestamps: true }
)
+6 -4
View File
@@ -3,7 +3,7 @@ import config from '../config.js'
const engine = new Docker()
const questPath = '/home/zeko/src/linux-odyssey/server/quests'
const projectPath = '/home/zeko/src/linux-odyssey'
const containerOptions = {
Image: 'lancatlin/linux-odyssey:helloworld',
@@ -11,13 +11,15 @@ const containerOptions = {
AttachStdout: true,
AttachStderr: true,
Tty: true,
Cmd: ['/bin/bash'],
OpenStdin: true,
StdinOnce: false,
HostConfig: {
Binds: config.isProduction
? []
: [`${questPath}/helloworld/home:/home/rudeus`],
: [
`${projectPath}/server/quests/helloworld/home:/home/rudeus`,
`${projectPath}/packages/cli/cli.js:/usr/local/bin/cli.js`,
],
},
}
@@ -56,7 +58,7 @@ export async function attachContainer(container, { token }) {
AttachStdin: true,
AttachStdout: true,
AttachStderr: true,
Cmd: ['/bin/bash'],
Cmd: ['/bin/zsh'],
Tty: true,
Env: [`TOKEN=${token}`, `API_ENDPOINT=http://app:3000`],
})
+10 -19
View File
@@ -1,15 +1,9 @@
import fs from 'fs/promises'
import path from 'path'
import { Marked } from 'marked'
import yaml from 'yaml'
import Quest from '../models/quest.js'
const marked = new Marked({
mangle: false,
headerIds: false,
})
const questDirectory = path.join(process.cwd(), 'quests')
export default async function loadAndUpdateQuests() {
@@ -17,27 +11,24 @@ export default async function loadAndUpdateQuests() {
const quests = Promise.all(
questNames.map(async (id) => {
const fullPath = path.join(questDirectory, id, 'README.md')
// Check if id is directory
const stat = await fs.stat(path.join(questDirectory, id))
if (!stat.isDirectory()) {
return null
}
const fullPath = path.join(questDirectory, id, 'game.yml')
try {
// Read and parse the README.md file.
const markdown = await fs.readFile(fullPath, 'utf-8')
const body = await fs.readFile(fullPath, 'utf-8')
// Extract and parse YAML frontmatter.
const match = /---\n([\s\S]+?)\n---/.exec(markdown)
const metadata = yaml.parse(match[1])
const content = markdown.slice(match[0].length)
const htmlContent = marked.parse(content)
const { title, order } = metadata
const quest = yaml.parse(body)
return Quest.findByIdAndUpdate(
id,
{
_id: id,
title,
order,
content: htmlContent,
id,
...quest,
},
{
upsert: true,
+60 -36
View File
@@ -1,6 +1,8 @@
openapi: 3.0.0
servers:
- url: /api/v1
- url: https://odyssey.wancat.cc/api/v1
- url: http://localhost:3000/api/v1
info:
description: Linux Odyssey API
version: '1.0.0'
@@ -304,6 +306,10 @@ paths:
responses:
'201':
description: Command recorded successfully
content:
application/json:
schema:
$ref: '#/components/schemas/CommandResponse'
'400':
description: Invalid request body
'401':
@@ -333,10 +339,9 @@ components:
title:
type: string
example: Hello World
content:
instruction:
type: string
format: html
example: '<h1>Hello World</h1>'
example: 'In this quest, you need to learn how to use `ls` to list the file.'
order:
type: number
@@ -372,20 +377,21 @@ components:
finished_at:
type: string
format: date-time
terminals:
type: array
items:
type: string
graphs:
$ref: '#/components/schemas/TreeNode'
progress:
type: array
items:
$ref: '#/components/schemas/Progress'
hints:
type: array
items:
$ref: '#/components/schemas/Hint'
type: string
example: '`ls -al` to list detail information of all files.'
progress:
type: string
description: current stage id
example: helloworld
completion:
type: array
description: list of completed stage ids
items:
type: string
example: helloworld
SessionSummary:
type: object
@@ -418,28 +424,6 @@ components:
items:
$ref: '#/components/schemas/TreeNode'
Progress:
type: object
properties:
name:
type: string
finished_at:
type: string
format: date-time
skipped_at:
type: string
format: date-time
Hint:
type: object
properties:
name:
type: string
content:
type: string
source:
type: string
Command:
type: object
required:
@@ -461,6 +445,46 @@ components:
output:
type: string
description: The output of the command
error:
type: string
description: The error message of the command
CommandResponse:
type: object
properties:
responses:
type: array
items:
$ref: '#/components/schemas/CommandResponseItem'
hints:
type: array
items:
type: string
example: '`ls -al` to list detail information of all files.'
end:
type: boolean
description: Whether the session has ended
CommandResponseItem:
type: object
required:
- type
- content
properties:
type:
type: string
enum: [narrative, dialogue]
content:
type: array
items:
type: string
example: 'You have completed the quest!'
color:
type: string
enum: [red, green, blue, yellow, purple, cyan, white, gray, black]
speaker:
type: string
example: 'Unix Spirit'
securitySchemes:
BearerAuth:
+5 -4
View File
@@ -1538,10 +1538,6 @@ color-name@~1.1.4:
resolved "https://registry.yarnpkg.com/color-name/-/color-name-1.1.4.tgz#c2a09a87acbde69543de6f63fa3995c826c536a2"
integrity sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==
commander@^11.0.0:
version "11.0.0"
resolved "https://registry.yarnpkg.com/commander/-/commander-11.0.0.tgz#43e19c25dbedc8256203538e8d7e9346877a6f67"
integrity sha512-9HMlXtt/BNoYr8ooyjjNRdIilOTkVJXB+GhxMTtOKwk0R4j4lS4NpjuqmRxroBfnfTSHQIHQB7wryHhXarNjmQ==
combined-stream@^1.0.8:
version "1.0.8"
resolved "https://registry.yarnpkg.com/combined-stream/-/combined-stream-1.0.8.tgz#c3d45a8b34fd730631a110a8a2520682b31d5a7f"
@@ -1549,6 +1545,11 @@ combined-stream@^1.0.8:
dependencies:
delayed-stream "~1.0.0"
commander@^11.0.0:
version "11.0.0"
resolved "https://registry.yarnpkg.com/commander/-/commander-11.0.0.tgz#43e19c25dbedc8256203538e8d7e9346877a6f67"
integrity sha512-9HMlXtt/BNoYr8ooyjjNRdIilOTkVJXB+GhxMTtOKwk0R4j4lS4NpjuqmRxroBfnfTSHQIHQB7wryHhXarNjmQ==
commander@^4.0.0:
version "4.1.1"
resolved "https://registry.yarnpkg.com/commander/-/commander-4.1.1.tgz#9fd602bd936294e9e9ef46a3f4d6964044b18068"