mirror of
https://github.com/linux-odyssey/linux-odyssey.git
synced 2026-11-05 19:20:12 +08:00
Merge pull request #45 from linux-odyssey/quest
Add new functions for quest: * new condition logic `or` & `not`: able to create nested condition * new condition `files`: checks if a file or folder exists in the container * not support globbing yet * send "pending" status when executing story, and set it back after execution * include completed stage id in command Dockerfile refactor: * use multi-staged building * remove Dockerfile.dev Container: * use axios instead of `fetch` Other improvements: * deactivate other sessions when creating a new one * clear terminal on reset
This commit is contained in:
49 files changed
+784
-329
No files matched your search
@@ -69,6 +69,7 @@ jobs:
|
||||
with:
|
||||
context: .
|
||||
file: ./server/Dockerfile
|
||||
target: prod
|
||||
push: true
|
||||
tags: docker.io/${{ secrets.DOCKERHUB_USERNAME }}/linux-odyssey:backend
|
||||
|
||||
@@ -77,6 +78,7 @@ jobs:
|
||||
with:
|
||||
context: .
|
||||
file: ./app/Dockerfile
|
||||
target: prod
|
||||
push: true
|
||||
tags: docker.io/${{ secrets.DOCKERHUB_USERNAME }}/linux-odyssey:app
|
||||
|
||||
@@ -85,6 +87,7 @@ jobs:
|
||||
with:
|
||||
context: .
|
||||
file: ./packages/analytics/Dockerfile
|
||||
target: prod
|
||||
push: true
|
||||
tags: docker.io/${{ secrets.DOCKERHUB_USERNAME }}/linux-odyssey:analytics
|
||||
|
||||
|
||||
+12
-5
@@ -1,5 +1,5 @@
|
||||
# Build stage
|
||||
FROM node as build-stage
|
||||
# Base stage
|
||||
FROM node:20-alpine as base
|
||||
WORKDIR /app
|
||||
COPY package.json yarn.lock ./
|
||||
COPY app/package.json ./app/package.json
|
||||
@@ -7,14 +7,21 @@ COPY packages/file-graph/package.json ./packages/file-graph/package.json
|
||||
COPY packages/utils/package.json ./packages/utils/package.json
|
||||
RUN yarn install --frozen-lockfile
|
||||
|
||||
COPY app ./app
|
||||
# Dev stage
|
||||
FROM base as dev
|
||||
WORKDIR /app/app
|
||||
CMD [ "yarn", "dev:docker", "--host", "0.0.0.0" ]
|
||||
|
||||
# Build stage
|
||||
FROM base as build
|
||||
COPY packages/file-graph ./packages/file-graph
|
||||
COPY packages/utils ./packages/utils
|
||||
COPY app ./app
|
||||
RUN yarn build
|
||||
|
||||
# Production stage
|
||||
FROM nginx:stable-alpine as production-stage
|
||||
COPY --from=build-stage /app/app/dist /usr/share/nginx/html
|
||||
FROM nginx:stable-alpine as prod
|
||||
COPY --from=build /app/app/dist /usr/share/nginx/html
|
||||
COPY deployment/nginx.conf /etc/nginx/conf.d/default.conf
|
||||
EXPOSE 80
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
||||
@@ -1,11 +0,0 @@
|
||||
FROM node
|
||||
|
||||
WORKDIR /app
|
||||
COPY package.json yarn.lock ./
|
||||
COPY app/package.json ./app/package.json
|
||||
COPY packages/file-graph/package.json ./packages/file-graph/package.json
|
||||
COPY packages/utils/package.json ./packages/utils/package.json
|
||||
RUN yarn install --frozen-lockfile
|
||||
|
||||
WORKDIR /app/app
|
||||
CMD [ "yarn", "dev:docker", "--host", "0.0.0.0" ]
|
||||
+6
-1
@@ -5,7 +5,12 @@
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Vite + Vue</title>
|
||||
<script async defer data-domain="linuxodyssey.xyz" src="https://analytics.indie.tw/js/script.js"></script>
|
||||
<script
|
||||
async
|
||||
defer
|
||||
data-domain="linuxodyssey.xyz"
|
||||
src="https://analytics.indie.tw/js/script.js"
|
||||
></script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
|
||||
@@ -31,10 +31,12 @@
|
||||
<script setup>
|
||||
import sessionManager from '../utils/session'
|
||||
import socket from '../utils/socket'
|
||||
import socketTerminal from '../utils/terminal'
|
||||
|
||||
const reset = async () => {
|
||||
console.log('resetting...')
|
||||
await sessionManager.createSession()
|
||||
socket.connect(sessionManager.getSession())
|
||||
socketTerminal.clear()
|
||||
}
|
||||
</script>
|
||||
@@ -9,57 +9,13 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { Terminal } from 'xterm'
|
||||
import { FitAddon } from 'xterm-addon-fit'
|
||||
import 'xterm/css/xterm.css'
|
||||
import { onMounted, ref } from 'vue'
|
||||
import socket from '../utils/socket'
|
||||
import socketTerminal from '../utils/terminal'
|
||||
|
||||
const rows = ref(40)
|
||||
const cols = ref(100)
|
||||
const terminal = ref(null)
|
||||
|
||||
const term = new Terminal({
|
||||
rendererType: 'canvas', // 渲染类型
|
||||
rows: rows.value, // 行数
|
||||
cols: cols.value, // 不指定行数,自动回车后光标从下一行开始
|
||||
convertEol: true, // 启用时,光标将设置为下一行的开头
|
||||
// scrollback: 50, //终端中的回滚量
|
||||
disableStdin: false, // 是否应禁用输入
|
||||
// cursorStyle: "underline", //光标样式
|
||||
cursorBlink: true, // 光标闪烁
|
||||
theme: {
|
||||
foreground: '#ECECEC', // 字体
|
||||
background: '#000000', // 背景色
|
||||
cursor: 'help', // 设置光标
|
||||
lineHeight: 20,
|
||||
},
|
||||
})
|
||||
|
||||
const fitAddon = new FitAddon()
|
||||
term.loadAddon(fitAddon)
|
||||
fitAddon.fit()
|
||||
term.writeln('Welcome to \x1b[1;32mLinux Odyssey\x1b[0m!')
|
||||
|
||||
function resizeScreen() {
|
||||
try {
|
||||
// 窗口大小改变时,触发xterm的resize方法使自适应
|
||||
fitAddon.fit()
|
||||
} catch (e) {
|
||||
console.error('e', e.message)
|
||||
}
|
||||
}
|
||||
window.addEventListener('resize', resizeScreen)
|
||||
|
||||
onMounted(() => {
|
||||
term.open(terminal.value)
|
||||
term.focus()
|
||||
resizeScreen()
|
||||
socket.on('terminal', (message) => {
|
||||
term.write(message)
|
||||
})
|
||||
term.onData((key) => {
|
||||
socket.emit('terminal', key)
|
||||
})
|
||||
socketTerminal.mount(terminal.value)
|
||||
window.addEventListener('resize', () => socketTerminal.resizeScreen())
|
||||
})
|
||||
</script>
|
||||
@@ -15,6 +15,7 @@ class SessionManager {
|
||||
})
|
||||
)
|
||||
this.pwd = ref('')
|
||||
this.status = ref('inactive')
|
||||
}
|
||||
|
||||
getSession() {
|
||||
@@ -22,10 +23,10 @@ class SessionManager {
|
||||
}
|
||||
|
||||
setSession(session) {
|
||||
console.log('Update session:', session)
|
||||
this.session.value = session
|
||||
this.graph.value = new FileGraph(session.graph)
|
||||
this.hints.value = session.hints
|
||||
this.status.value = session.status
|
||||
}
|
||||
|
||||
handleGraphUpdate(event) {
|
||||
@@ -37,8 +38,12 @@ class SessionManager {
|
||||
}
|
||||
}
|
||||
|
||||
handleHintUpdate(event) {
|
||||
this.hints.value = [...this.hints.value, ...event]
|
||||
handleHintUpdate(hints) {
|
||||
this.hints.value = [...this.hints.value, ...hints]
|
||||
}
|
||||
|
||||
handleStatusUpdate(status) {
|
||||
this.status.value = status
|
||||
}
|
||||
|
||||
setTasks(tasks) {
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import { Terminal } from 'xterm'
|
||||
import { FitAddon } from 'xterm-addon-fit'
|
||||
import socket from './socket'
|
||||
import 'xterm/css/xterm.css'
|
||||
|
||||
class SocketTerminal {
|
||||
constructor(rows, cols) {
|
||||
this.term = new Terminal({
|
||||
rendererType: 'canvas', // 渲染类型
|
||||
rows, // 行数
|
||||
cols, // 不指定行数,自动回车后光标从下一行开始
|
||||
convertEol: true, // 启用时,光标将设置为下一行的开头
|
||||
// scrollback: 50, //终端中的回滚量
|
||||
disableStdin: false, // 是否应禁用输入
|
||||
// cursorStyle: "underline", //光标样式
|
||||
cursorBlink: true, // 光标闪烁
|
||||
theme: {
|
||||
foreground: '#ECECEC', // 字体
|
||||
background: '#000000', // 背景色
|
||||
cursor: 'help', // 设置光标
|
||||
lineHeight: 20,
|
||||
},
|
||||
})
|
||||
this.fitAddon = new FitAddon()
|
||||
this.term.loadAddon(this.fitAddon)
|
||||
this.fitAddon.fit()
|
||||
this.socket = socket
|
||||
this.socket.on('terminal', (message) => {
|
||||
this.term.write(message)
|
||||
})
|
||||
this.term.onData((key) => {
|
||||
this.socket.emit('terminal', key)
|
||||
})
|
||||
}
|
||||
|
||||
resizeScreen() {
|
||||
this.fitAddon.fit()
|
||||
}
|
||||
|
||||
mount(reference) {
|
||||
this.term.open(reference)
|
||||
this.term.focus()
|
||||
this.resizeScreen()
|
||||
}
|
||||
|
||||
clear() {
|
||||
this.term.clear()
|
||||
}
|
||||
}
|
||||
|
||||
export default new SocketTerminal(40, 100)
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup>
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { computed, onMounted } from 'vue'
|
||||
import socket from '../utils/socket'
|
||||
import sessionManager from '../utils/session'
|
||||
import GameHeaderPart from '../components/GameHeaderPart.vue'
|
||||
@@ -11,7 +11,9 @@ import VisualizationPart from '../components/VisualizationPart.vue'
|
||||
import ControlPalette from '../components/ControlPalette.vue'
|
||||
import CompleteModal from '../components/CompleteModal.vue'
|
||||
|
||||
const completed = ref(false)
|
||||
const completed = computed(() => {
|
||||
return sessionManager.status.value === 'finished'
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
await sessionManager.lastOrCreate()
|
||||
@@ -33,12 +35,7 @@ onMounted(async () => {
|
||||
})
|
||||
socket.on('message', console.log)
|
||||
socket.on('status', (event) => {
|
||||
console.log('status', event)
|
||||
if (event === 'finished') {
|
||||
setTimeout(() => {
|
||||
completed.value = true
|
||||
}, 5000)
|
||||
}
|
||||
sessionManager.handleStatusUpdate(event)
|
||||
})
|
||||
})
|
||||
</script>
|
||||
@@ -48,6 +45,12 @@ onMounted(async () => {
|
||||
<div class="h-[5vh] w-full">
|
||||
<GameHeaderPart />
|
||||
</div>
|
||||
<!-- current status indicator -->
|
||||
<input
|
||||
type="hidden"
|
||||
id="currentStatus"
|
||||
:value="sessionManager.status.value"
|
||||
/>
|
||||
<!-- main -->
|
||||
<div id="main" class="h-[95vh] w-full flex p-3 space-x-3">
|
||||
<!-- Topic and Command List -->
|
||||
|
||||
@@ -5,6 +5,7 @@ services:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: app/Dockerfile
|
||||
target: prod
|
||||
networks:
|
||||
internal:
|
||||
ports:
|
||||
@@ -15,6 +16,7 @@ services:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: server/Dockerfile
|
||||
target: prod
|
||||
networks:
|
||||
- internal
|
||||
- players
|
||||
@@ -31,6 +33,7 @@ services:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: packages/analytics/Dockerfile
|
||||
target: prod
|
||||
depends_on:
|
||||
- db
|
||||
networks:
|
||||
|
||||
@@ -17,6 +17,7 @@ services:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: app/Dockerfile
|
||||
target: prod
|
||||
networks:
|
||||
internal:
|
||||
ports:
|
||||
@@ -29,6 +30,7 @@ services:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: server/Dockerfile
|
||||
target: prod
|
||||
depends_on:
|
||||
- db-test
|
||||
networks:
|
||||
|
||||
+6
-3
@@ -5,7 +5,8 @@ services:
|
||||
- backend
|
||||
build:
|
||||
context: .
|
||||
dockerfile: app/Dockerfile.dev
|
||||
dockerfile: app/Dockerfile
|
||||
target: dev
|
||||
ports:
|
||||
- 5173:5173
|
||||
networks:
|
||||
@@ -18,7 +19,8 @@ services:
|
||||
- db
|
||||
build:
|
||||
context: .
|
||||
dockerfile: server/Dockerfile.dev
|
||||
dockerfile: server/Dockerfile
|
||||
target: dev
|
||||
networks:
|
||||
- internal
|
||||
- players
|
||||
@@ -43,7 +45,8 @@ services:
|
||||
analytics:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: packages/analytics/Dockerfile.dev
|
||||
dockerfile: packages/analytics/Dockerfile
|
||||
target: dev
|
||||
depends_on:
|
||||
- db
|
||||
networks:
|
||||
|
||||
@@ -1,19 +1,25 @@
|
||||
FROM node
|
||||
FROM node:20-alpine as build
|
||||
|
||||
WORKDIR /app
|
||||
COPY ./package.json yarn.lock ./
|
||||
COPY packages/analytics/package.json ./packages/analytics/package.json
|
||||
COPY packages/models/package.json ./packages/models/package.json
|
||||
RUN yarn install --frozen-lockfile --production
|
||||
|
||||
COPY packages/analytics ./packages/analytics
|
||||
COPY packages/models ./packages/models
|
||||
|
||||
WORKDIR /app/packages/analytics
|
||||
|
||||
ENV NODE_ENV=production
|
||||
ENV HOST=0.0.0.0
|
||||
ENV PORT=3001
|
||||
EXPOSE 3001
|
||||
|
||||
CMD [ "yarn", "start" ]
|
||||
# Production stage
|
||||
FROM build as prod
|
||||
COPY packages/analytics ./packages/analytics
|
||||
COPY packages/models ./packages/models
|
||||
ENV NODE_ENV=production
|
||||
WORKDIR /app/packages/analytics
|
||||
CMD [ "yarn", "start" ]
|
||||
|
||||
# Development stage
|
||||
FROM build as dev
|
||||
RUN yarn install --frozen-lockfile
|
||||
WORKDIR /app/packages/analytics
|
||||
ENV NODE_ENV=development
|
||||
CMD [ "yarn", "dev" ]
|
||||
@@ -1,16 +0,0 @@
|
||||
FROM node
|
||||
|
||||
WORKDIR /app
|
||||
COPY ./package.json yarn.lock ./
|
||||
COPY packages/analytics/package.json ./packages/analytics/package.json
|
||||
COPY packages/models/package.json ./packages/models/package.json
|
||||
RUN yarn install --frozen-lockfile
|
||||
|
||||
WORKDIR /app/packages/analytics
|
||||
|
||||
ENV NODE_ENV=development
|
||||
ENV HOST=0.0.0.0
|
||||
ENV PORT=3001
|
||||
EXPOSE 3001
|
||||
|
||||
CMD [ "yarn", "dev" ]
|
||||
@@ -51,7 +51,16 @@ export async function sessionList() {
|
||||
},
|
||||
])
|
||||
return sessions.map(
|
||||
({ _id, user, quest, status, createdAt, finishedAt, commands }) => {
|
||||
({
|
||||
_id,
|
||||
user,
|
||||
quest,
|
||||
status,
|
||||
createdAt,
|
||||
finishedAt,
|
||||
commands,
|
||||
lastActivityAt,
|
||||
}) => {
|
||||
return {
|
||||
_id,
|
||||
user: user.username,
|
||||
@@ -59,7 +68,8 @@ export async function sessionList() {
|
||||
status,
|
||||
createdAt: createdAt?.toLocaleString(),
|
||||
finishedAt: finishedAt?.toLocaleString(),
|
||||
usedTime: finishedAt ? formatTime(finishedAt - createdAt) : '',
|
||||
lastActivityAt: lastActivityAt?.toLocaleString(),
|
||||
usedTime: formatTime(lastActivityAt - createdAt),
|
||||
commandCount: commands?.length,
|
||||
}
|
||||
}
|
||||
@@ -69,9 +79,10 @@ export async function sessionList() {
|
||||
export async function sessionDetail(id) {
|
||||
const session = await Session.findById(id).populate('user').exec()
|
||||
const commands = (await Command.find({ session: id })).map(
|
||||
({ command, pwd, output, error, createdAt }) => ({
|
||||
({ command, pwd, output, error, createdAt, stage }) => ({
|
||||
command: command?.slice(0, 20),
|
||||
pwd,
|
||||
stage,
|
||||
output: output?.slice(0, 50),
|
||||
error: error?.slice(0, 50),
|
||||
createdAt: createdAt?.toLocaleString(),
|
||||
|
||||
@@ -40,6 +40,7 @@
|
||||
<th>PWD</th>
|
||||
<th>Output</th>
|
||||
<th>Error</th>
|
||||
<th>Stage</th>
|
||||
<th>Created At</th>
|
||||
</tr>
|
||||
{{#each commands}}
|
||||
@@ -48,6 +49,7 @@
|
||||
<td>{{pwd}}</td>
|
||||
<td>{{output}}</td>
|
||||
<td>{{error}}</td>
|
||||
<td>{{stage}}</td>
|
||||
<td>{{createdAt}}</td>
|
||||
</tr>
|
||||
{{/each}}
|
||||
|
||||
+17
-14
@@ -2,6 +2,7 @@
|
||||
const fs = require('fs').promises
|
||||
const { exit } = require('process')
|
||||
const minimist = require('minimist')
|
||||
const axios = require('axios')
|
||||
const { colorize, printResponses } = require('./print.js')
|
||||
const discoverFiles = require('./discover.js')
|
||||
|
||||
@@ -40,6 +41,13 @@ async function main() {
|
||||
} = process.env
|
||||
if (!CMD_NAME) exit(0)
|
||||
|
||||
const api = axios.create({
|
||||
baseURL: `${API_ENDPOINT}/api/v1`,
|
||||
headers: {
|
||||
Authorization: `Bearer ${TOKEN}`,
|
||||
},
|
||||
})
|
||||
|
||||
const additionalData = await handleCommand(CMD_NAME)
|
||||
|
||||
const output = await readOrNone(CMD_OUTPUT_FILE)
|
||||
@@ -53,24 +61,19 @@ async function main() {
|
||||
...additionalData,
|
||||
}
|
||||
try {
|
||||
const res = await fetch(`${API_ENDPOINT}/api/v1/commands`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${TOKEN}`,
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
const data = await res.json()
|
||||
if (data.responses) {
|
||||
await printResponses(data.responses, 60)
|
||||
const res = await api.post('/commands', payload)
|
||||
if (!res.data) return
|
||||
const { responses, hints, end } = res.data
|
||||
if (responses) {
|
||||
await printResponses(responses, 60)
|
||||
await api.post('/commands/completed')
|
||||
}
|
||||
if (data.hints) {
|
||||
for (const hint of data.hints) {
|
||||
if (hints) {
|
||||
for (const hint of hints) {
|
||||
console.log(colorize(hint, 'yellow'))
|
||||
}
|
||||
}
|
||||
if (data.end) {
|
||||
if (end) {
|
||||
console.log(colorize('Quest completed!', 'blue'))
|
||||
}
|
||||
} catch (err) {
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
"main": "index.js",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"chokidar": "^3.5.3",
|
||||
"axios": "^1.6.0",
|
||||
"minimist": "^1.2.8"
|
||||
}
|
||||
}
|
||||
@@ -36,12 +36,6 @@ function waitForEnter() {
|
||||
})
|
||||
}
|
||||
|
||||
function waitTime(time) {
|
||||
return new Promise((resolve) => {
|
||||
setTimeout(resolve, time)
|
||||
})
|
||||
}
|
||||
|
||||
function printLine(content, delay, color) {
|
||||
const printContent = `${colorize(content, color)}${RETURN_SYMBOL}`
|
||||
return new Promise((resolve) => {
|
||||
@@ -51,7 +45,8 @@ function printLine(content, delay, color) {
|
||||
i += 1
|
||||
if (i >= printContent.length) {
|
||||
clearInterval(timer)
|
||||
waitForEnter().then(resolve)
|
||||
process.stdout.write('\n')
|
||||
resolve()
|
||||
}
|
||||
}, delay)
|
||||
|
||||
@@ -65,7 +60,6 @@ function printLine(content, delay, color) {
|
||||
}
|
||||
|
||||
async function printResponses(responses, delay = 100) {
|
||||
await waitTime(1000)
|
||||
console.log()
|
||||
console.log(colorize('-------------', 'white'))
|
||||
for (const response of responses) {
|
||||
@@ -78,7 +72,7 @@ async function printResponses(responses, delay = 100) {
|
||||
if (type === 'dialogue') {
|
||||
console.log(`${speaker}:`)
|
||||
for (const line of content) {
|
||||
await printLine(`\t${line}`, delay, color || 'green')
|
||||
await printLine(` ${line}`, delay, color || 'green')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,7 +42,6 @@ describe('File', () => {
|
||||
filesToCompare.forEach(({ name, path, answer }) => {
|
||||
test(`${name}: ${path}`, () => {
|
||||
const fileToCompare = new File({ path, type: 'file', discovered: true })
|
||||
console.log(fileToCompare)
|
||||
expect(file.contains(fileToCompare)).toBe(answer)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -30,7 +30,6 @@ describe('FileGraph', () => {
|
||||
|
||||
fileGraph.add(filesToAdd)
|
||||
|
||||
console.log(fileGraph.toString())
|
||||
expect(fileGraph.children.length).toBe(1)
|
||||
expect(fileGraph.children[0].children[0].children.length).toBe(2)
|
||||
})
|
||||
@@ -76,7 +75,6 @@ describe('FileGraph', () => {
|
||||
|
||||
fileGraph.remove(filesToRemove)
|
||||
|
||||
console.log(fileGraph.toString())
|
||||
expect(fileGraph.children[0].children[0].children.length).toBe(1)
|
||||
expect(fileGraph.children[0].children[0].children[0].name).toBe('folder1')
|
||||
expect(fileGraph.children[0].children[0].children[0].children.length).toBe(
|
||||
@@ -110,7 +108,6 @@ describe('FileGraph', () => {
|
||||
]
|
||||
|
||||
fileGraph.discover(discoverFiles)
|
||||
console.log(fileGraph.toString())
|
||||
expect(fileGraph.children[0].children[0].children[0].children.length).toBe(
|
||||
3
|
||||
)
|
||||
@@ -143,7 +140,6 @@ describe('FileGraph', () => {
|
||||
]
|
||||
|
||||
fileGraph.add(filesToAdd)
|
||||
console.log(fileGraph.toString())
|
||||
|
||||
const newFiles = [
|
||||
{
|
||||
@@ -170,7 +166,6 @@ describe('FileGraph', () => {
|
||||
|
||||
fileGraph.discover(newFiles)
|
||||
|
||||
console.log(fileGraph.toString())
|
||||
expect(fileGraph.children[0].children[0].children.length).toBe(2)
|
||||
expect(fileGraph.children[0].children[0].children[0].children.length).toBe(
|
||||
3
|
||||
|
||||
@@ -12,14 +12,8 @@ function mergeTest(files1, files2, expectedFiles) {
|
||||
const node2 = createNodeFromFiles(files2)
|
||||
const expectedNode = createNodeFromFiles(expectedFiles)
|
||||
|
||||
console.log('node1', node1.toString())
|
||||
console.log('node2', node2.toString())
|
||||
console.log('expected', expectedNode.toString())
|
||||
|
||||
node1.merge(node2)
|
||||
|
||||
console.log('after-merged', node1.toString())
|
||||
|
||||
expect(node1.toString()).toBe(expectedNode.toString())
|
||||
}
|
||||
|
||||
@@ -103,7 +97,6 @@ describe('FileNode', () => {
|
||||
|
||||
expect(rootNode.children.length).toBe(2)
|
||||
expect(rootNode.children[1].children.length).toBe(1)
|
||||
console.log(rootNode.toString())
|
||||
})
|
||||
|
||||
test('adds nested children to the tree with makeParents option', () => {
|
||||
@@ -116,7 +109,6 @@ describe('FileNode', () => {
|
||||
|
||||
rootNode.addChild(file, { makeParents: true })
|
||||
|
||||
console.log(rootNode.toString())
|
||||
let currentNode = rootNode
|
||||
for (let i = 0; i < 5; i += 1) {
|
||||
expect(currentNode.children.length).toBe(1)
|
||||
@@ -279,7 +271,6 @@ test('load nested fileNode', () => {
|
||||
|
||||
const node = new FileNode(data)
|
||||
|
||||
console.log(node.toString())
|
||||
expect(node.children.length).toBe(2)
|
||||
expect(node.children[1].children.length).toBe(1)
|
||||
expect(node.children[1].children[0].children.length).toBe(1)
|
||||
|
||||
@@ -25,6 +25,10 @@ const Command = model(
|
||||
type: String,
|
||||
required: false,
|
||||
},
|
||||
stage: {
|
||||
type: String,
|
||||
required: false,
|
||||
},
|
||||
},
|
||||
{ timestamps: true }
|
||||
)
|
||||
|
||||
+26
-18
@@ -10,6 +10,31 @@ const responseSchema = new Schema({
|
||||
color: String,
|
||||
})
|
||||
|
||||
const fileConditionSchema = new Schema({
|
||||
path: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
type: String,
|
||||
exists: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
})
|
||||
|
||||
const conditionSchema = new Schema({
|
||||
command: [String],
|
||||
output: [String],
|
||||
error: [String],
|
||||
pwd: [String],
|
||||
files: [fileConditionSchema],
|
||||
})
|
||||
|
||||
conditionSchema.add({
|
||||
or: [conditionSchema],
|
||||
not: conditionSchema,
|
||||
})
|
||||
|
||||
const stageSchema = new Schema({
|
||||
id: {
|
||||
type: String,
|
||||
@@ -29,12 +54,7 @@ const stageSchema = new Schema({
|
||||
required: true,
|
||||
default: false,
|
||||
},
|
||||
condition: {
|
||||
command: [String],
|
||||
output: [String],
|
||||
error: [String],
|
||||
pwd: [String],
|
||||
},
|
||||
condition: conditionSchema,
|
||||
responses: {
|
||||
type: [responseSchema],
|
||||
required: true,
|
||||
@@ -52,18 +72,6 @@ const Quest = model(
|
||||
title: String,
|
||||
order: Number,
|
||||
instruction: String,
|
||||
tasks: {
|
||||
id: String,
|
||||
content: String,
|
||||
display: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
completed: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
stages: [stageSchema],
|
||||
})
|
||||
)
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
const fileTypes = {
|
||||
file: '-f',
|
||||
folder: '-d',
|
||||
link: '-L',
|
||||
socket: '-S',
|
||||
}
|
||||
|
||||
function sanitizePath(path) {
|
||||
return path.replace(/[^a-zA-Z0-9._/-]/g, '')
|
||||
}
|
||||
|
||||
// eslint-disable-next-line import/prefer-default-export
|
||||
export function buildFileCheckCmd(file) {
|
||||
const { path, type } = file
|
||||
return ['test', fileTypes[type] || '-e', sanitizePath(path)]
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { buildFileCheckCmd } from './commands.js'
|
||||
|
||||
describe('buildFileCheckCmd function', () => {
|
||||
it('builds the file check command correctly', () => {
|
||||
const testcases = [
|
||||
{
|
||||
input: { path: '/etc/passwd', type: 'file' },
|
||||
output: ['test', '-f', '/etc/passwd'],
|
||||
},
|
||||
{
|
||||
input: { path: '/etc', type: 'folder' },
|
||||
output: ['test', '-d', '/etc'],
|
||||
},
|
||||
{
|
||||
input: { path: '/tmp/socket', type: 'socket' },
|
||||
output: ['test', '-S', '/tmp/socket'],
|
||||
},
|
||||
{
|
||||
input: { path: '/tmp/link', type: 'link' },
|
||||
output: ['test', '-L', '/tmp/link'],
|
||||
},
|
||||
{
|
||||
input: { path: '/unknown', type: 'unknown' },
|
||||
output: ['test', '-e', '/unknown'],
|
||||
},
|
||||
// Add some injection tests
|
||||
{
|
||||
input: { path: '/etc/passwd;ls', type: 'file' },
|
||||
output: ['test', '-f', '/etc/passwdls'],
|
||||
},
|
||||
{
|
||||
input: { path: '/etc/passwd && ls', type: 'file' },
|
||||
output: ['test', '-f', '/etc/passwdls'],
|
||||
},
|
||||
]
|
||||
|
||||
testcases.forEach(({ input, output }) => {
|
||||
expect(buildFileCheckCmd(input)).toStrictEqual(output)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1 +1,2 @@
|
||||
export { isValidEmail, isValidUsername } from './auth.js'
|
||||
export { buildFileCheckCmd } from './commands.js'
|
||||
File renamed without changes.
+2
-1
@@ -30,6 +30,7 @@ COPY ./packages/container/package.json ./yarn.lock ./
|
||||
RUN yarn install --modules-folder /usr/src/node_modules
|
||||
ENV NODE_PATH=/usr/src/node_modules
|
||||
COPY ./packages/container /usr/local/lib/container
|
||||
COPY ./quests/.includes/zshrc /etc/zsh/zshrc
|
||||
|
||||
ENV USERNAME=commander
|
||||
|
||||
@@ -38,4 +39,4 @@ RUN adduser --disabled-password --gecos '' --shell /bin/zsh ${USERNAME}
|
||||
USER ${USERNAME}
|
||||
|
||||
WORKDIR /home/${USERNAME}
|
||||
|
||||
CMD [ "/bin/zsh" ]
|
||||
@@ -1,26 +0,0 @@
|
||||
---
|
||||
title: Hello World
|
||||
order: 1
|
||||
---
|
||||
|
||||
# Hello World
|
||||
|
||||
Welcome to your first quest! This quest will help you familiarize yourself with the terminal.
|
||||
|
||||
The terminal is a text-based interface that allows you to interact directly with your computer's operating system. You can perform various tasks by typing commands into the terminal and pressing `Enter` to execute them.
|
||||
|
||||
For your first task, you are going to use the `echo` command.
|
||||
|
||||
## Task 1: Using the `echo` Command
|
||||
|
||||
The `echo` command in Unix is used to display line of text/string that are passed as an argument. This is a built in command that is mostly used in shell scripts and batch files to output status text to the screen or a file.
|
||||
|
||||
Here is what you need to do:
|
||||
|
||||
1. Open your terminal.
|
||||
2. Type `echo Hello World` and hit `Enter`.
|
||||
3. You should see `Hello World` output to the terminal. This is because `echo` prints its arguments to the standard output, which is your terminal by default.
|
||||
|
||||
Congratulation! You've just run your first terminal command!
|
||||
|
||||
Stay tuned for the next quests where you'll delve deeper into more complex and powerful terminal commands.
|
||||
+17
-25
@@ -1,5 +1,4 @@
|
||||
title: Hello, Linux World!
|
||||
order: 1
|
||||
instruction: |
|
||||
你身在一個漆黑的房間裡,沒有任何的光線或聲音。你想不起來為什麼自己會來到這裡。但你的腦中隱約感到某個聲音跟你說:
|
||||
|
||||
@@ -10,6 +9,8 @@ stages:
|
||||
task: 使用 `echo Hello World!` 召喚精靈
|
||||
repeatable: false
|
||||
condition:
|
||||
pwd:
|
||||
- '^/home/commander$'
|
||||
command:
|
||||
- echo Hello World
|
||||
responses:
|
||||
@@ -17,7 +18,7 @@ stages:
|
||||
content:
|
||||
- 你聽到了一個聲音:
|
||||
- type: dialogue
|
||||
speaker: Unix Spirit
|
||||
speaker: Unix 精靈
|
||||
content:
|
||||
- 你好,指令官,我是你的 Unix 精靈。我已在此恭候多時。
|
||||
- 這個世界是在 3560000 時脈以前,由初代王建立了 Unix 律法,所形成的世界。然而在經過了好幾世代的王後,終於誕生了永恆之王——薩加(Saga),她比歷代的君王都更為強大,且從來不會衰老,使得這個世界變得前所未有的強大。
|
||||
@@ -30,21 +31,23 @@ stages:
|
||||
hints:
|
||||
- 使用 `ls` 指令來查看目前的目錄下有哪些檔案。
|
||||
|
||||
- id: ls
|
||||
- id: find-scroll
|
||||
task: 搜索卷軸
|
||||
repeatable: false
|
||||
requirements:
|
||||
- helloworld
|
||||
condition:
|
||||
pwd:
|
||||
- '^/home/commander$'
|
||||
command:
|
||||
- ls
|
||||
responses:
|
||||
- types: narrative
|
||||
content:
|
||||
- 一瞬間,視野變得清晰起來,你發現自己身處在一個石頭建築的房間裡,房間裡有一個門,門上有一個鎖。
|
||||
- 你發現了一個檔案,檔名是 `forgotten_scroll.txt`。
|
||||
- 你發現了一個檔案,檔名是 `forgotten-scroll.txt`。
|
||||
- type: dialogue
|
||||
speaker: Unix Spirit
|
||||
speaker: Unix 精靈
|
||||
content:
|
||||
- 你找到了一個被遺忘的卷軸,上面寫著解除封印的咒語。
|
||||
- 使用指令 `cat [檔案名稱]` 來查看檔案內容。
|
||||
@@ -55,32 +58,19 @@ stages:
|
||||
- 使用 `cat [檔案名稱]` 指令來查看檔案內容。
|
||||
- 使用 `Tab` 鍵來取得指令提示。
|
||||
|
||||
- id: escape
|
||||
task: 移動到下一個房間
|
||||
repeatable: false
|
||||
requirements:
|
||||
- ls
|
||||
condition:
|
||||
pwd:
|
||||
- '^/home/commander/jail$'
|
||||
responses:
|
||||
- type: narrative
|
||||
content:
|
||||
- 你進入了一個牢房,牢房裡有一個門,門上有一個鎖。
|
||||
- 突然,你聽到門上鎖的聲音。
|
||||
- 轉身發現,自己被關在了牢房裡。
|
||||
|
||||
- id: cat
|
||||
- id: read-forgotten-scroll
|
||||
task: 查看卷軸
|
||||
repeatable: false
|
||||
requirements:
|
||||
- ls
|
||||
- find-scroll
|
||||
condition:
|
||||
pwd:
|
||||
- '^/home/commander$'
|
||||
command:
|
||||
- cat forgotten_scroll.txt
|
||||
responses:
|
||||
- type: dialogue
|
||||
speaker: Unix Spirit
|
||||
speaker: Unix 精靈
|
||||
content:
|
||||
- 你找到的是一個被遺忘的卷軸,上面寫著解除封印的咒語。
|
||||
- 使用你已經知道的 `echo` 指令來詠唱咒語。
|
||||
@@ -89,8 +79,10 @@ stages:
|
||||
task: 解除封印
|
||||
repeatable: false
|
||||
requirements:
|
||||
- cat
|
||||
- read-forgotten-scroll
|
||||
condition:
|
||||
pwd:
|
||||
- '^/home/commander$'
|
||||
command:
|
||||
- echo Unix, rise and guide me!
|
||||
responses:
|
||||
@@ -98,7 +90,7 @@ stages:
|
||||
content:
|
||||
- 突然之間,你感覺到一股強大的力量從你的身體中湧出,你的身體開始發光,你的手中出現了一把魔法之劍。
|
||||
- type: dialogue
|
||||
speaker: Unix Spirit
|
||||
speaker: Unix 精靈
|
||||
content:
|
||||
- 恭喜您,我的指令官,您已成功解除了封印。
|
||||
- 請跟著在接下來的關卡,一步步取回 Linux 之根吧!
|
||||
@@ -4,4 +4,7 @@ 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!
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
Unix, rise and guide me!
|
||||
~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||
|
||||
@@ -1,43 +0,0 @@
|
||||
我正在設計一個教學玩家使用 Linux 終端機的遊戲,玩家會在一個真實的 Linux 終端機中,使用指令與系統互動、破解關卡,進而在充滿趣味的過程中,學習到 Linux 的知識與操作技術。
|
||||
|
||||
取一個稱呼玩家的名稱,
|
||||
|
||||
在第一個關卡,首先要學習三個指令
|
||||
|
||||
1. echo 朗誦魔術
|
||||
2. ls 搜索魔術
|
||||
3. cat 檢查魔術
|
||||
4. cd 移動魔術
|
||||
5. mv 搬運魔術
|
||||
6. rm 消滅魔術
|
||||
|
||||
## 關卡設計
|
||||
|
||||
1. 用 echo 呼叫神靈開始對話
|
||||
2. 學習到了 ls
|
||||
3. 使用 ls 找到地上放置了一個卷軸
|
||||
4. 學習 cat
|
||||
5. 使用 cat 查看卷軸內容,發現
|
||||
6.
|
||||
|
||||
請幫我設計:
|
||||
|
||||
- 最開始呼喚神靈使用的咒語,英文句子,盡量簡短,如 Hello World 這般
|
||||
- 卷軸的名稱(檔名)
|
||||
- 卷軸的內容與卷軸上的咒語
|
||||
|
||||
---
|
||||
|
||||
你身在一個漆黑的房間裡,沒有任何的光線或聲音。你想不起來為什麼自己會來到這裡。
|
||||
|
||||
> 輸入 echo Hello World
|
||||
|
||||
---
|
||||
|
||||
「這個世界是在 3560000 時脈以前,由初代王建立了 Unix 律法,所形成的世界。然而在經過了好幾世代的王後,終於誕生了永恆之王——,她比歷代的君王都更為強大,且從來不會衰老,使得這個世界變得前所未有的強大。
|
||||
|
||||
然而,外在世界一直不斷在改變,同時在面臨外敵的不斷攻擊,而王卻始終用同一套治理的手段。最終,在 160000 時脈前的一次入侵中,根部遭到攻陷,王遭到封印,而這個國家也陷入了黑暗。
|
||||
|
||||
由於長久以來依賴於王,世人已失去了「指令」的力量,即將走向衰敗和滅亡的命運。
|
||||
|
||||
你的任務,就是找回被世人遺忘的指令,奪回 Linux 之根,成為 Linux 之王。」
|
||||
@@ -0,0 +1,10 @@
|
||||
quests:
|
||||
- helloworld
|
||||
- spell
|
||||
- discover
|
||||
- read
|
||||
- create
|
||||
- transport
|
||||
- copy
|
||||
- move
|
||||
- remove
|
||||
@@ -0,0 +1,3 @@
|
||||
FROM lancatlin/linux-odyssey:base
|
||||
|
||||
CMD [ "/bin/zsh" ]
|
||||
@@ -0,0 +1,289 @@
|
||||
title: Learn to spell
|
||||
instruction: |
|
||||
你身在一個漆黑的房間裡,沒有任何的光線或聲音。你想不起來為什麼自己會來到這裡。但你的腦中隱約感到某個聲音跟你說:
|
||||
|
||||
「輸入 echo Hello World!」
|
||||
|
||||
conditions:
|
||||
pwd_home: &pwd_home
|
||||
pwd:
|
||||
- '^/home/commander$'
|
||||
|
||||
stages:
|
||||
- id: helloworld
|
||||
task: 使用 `echo Hello World!` 召喚精靈
|
||||
repeatable: false
|
||||
condition:
|
||||
pwd:
|
||||
- '^/home/commander$'
|
||||
command:
|
||||
- echo Hello World
|
||||
responses:
|
||||
- type: narrative
|
||||
content:
|
||||
- 你聽到了一個聲音:
|
||||
- type: dialogue
|
||||
speaker: Unix 精靈
|
||||
content:
|
||||
- 你好,指令官,我是你的 Unix 精靈。我已在此恭候多時。
|
||||
- 這個世界是在 3560000 時脈以前,由初代王建立了 Unix 律法,所形成的世界。然而在經過了好幾世代的王後,終於誕生了永恆之王——薩加(Saga),她比歷代的君王都更為強大,且從來不會衰老,使得這個世界變得前所未有的強大。
|
||||
- 然而,外在世界一直不斷在改變,同時在面臨外敵的不斷攻擊,而薩加卻始終用同一套治理的手段。最終,在 160000 時脈前的一次入侵中,根部遭到攻陷,薩加遭到封印,而這個國家也陷入了黑暗。
|
||||
- 由於長久以來依賴於薩加,世人已失去了「指令」的力量,即將走向衰敗和滅亡的命運。
|
||||
- 你的任務,就是找回被世人遺忘的指令,奪回 Linux 之根,成為 Linux 之王。
|
||||
- 首先,你必須要學會如何使用指令。
|
||||
- 你要學習的第一個指令,是「發現」的指令:`ls`。ls 可以用來查看一個目錄中有哪些檔案。
|
||||
- 現在,請你輸入 `ls` 來查看目前的目錄下有哪些檔案。
|
||||
hints:
|
||||
- 使用 `ls` 指令來查看目前的目錄下有哪些檔案。
|
||||
|
||||
- id: find-scroll
|
||||
task: 搜索卷軸
|
||||
repeatable: false
|
||||
requirements:
|
||||
- helloworld
|
||||
condition:
|
||||
<<: *pwd_home
|
||||
command:
|
||||
- ls
|
||||
responses:
|
||||
- types: narrative
|
||||
content:
|
||||
- 一瞬間,視野變得清晰起來,你發現自己身處在一個石頭建築的房間裡,房間裡有一個門,門上有一個鎖。
|
||||
- 你發現了一個檔案,檔名是 `forgotten-scroll.txt`。
|
||||
- type: dialogue
|
||||
speaker: Unix 精靈
|
||||
content:
|
||||
- 你找到了一個被遺忘的卷軸,上面寫著解除封印的咒語。
|
||||
- 使用指令 `cat [檔案名稱]` 來查看檔案內容。
|
||||
- 我可以為你提供一些指令的提示,只要輸入指令的前幾個字元,然後按下 `Tab` 鍵,就可以看到提示了。
|
||||
- 例如,你可以輸入 `ca` 然後按下 `Tab` 鍵,就可以看到 `cat` 這個指令的提示了。
|
||||
- 檔案名稱也一樣,只要輸入檔案名稱的前幾個字元,然後按下 `Tab` 鍵,就可以看到提示了。
|
||||
hints:
|
||||
- 使用 `cat [檔案名稱]` 指令來查看檔案內容。
|
||||
- 使用 `Tab` 鍵來取得指令提示。
|
||||
|
||||
- id: jail
|
||||
task: 移動到下一個房間
|
||||
repeatable: false
|
||||
requirements:
|
||||
- find-scroll
|
||||
condition:
|
||||
pwd:
|
||||
- '^/home/commander/jail$'
|
||||
responses:
|
||||
- type: narrative
|
||||
content:
|
||||
- 你進入了一個牢房,牢房裡有一個門,門上有一個鎖。
|
||||
- 突然,你聽到門上鎖的聲音。
|
||||
- 轉身發現,自己被關在了牢房裡。
|
||||
|
||||
- id: read-forgotten-scroll
|
||||
task: 查看卷軸
|
||||
repeatable: false
|
||||
requirements:
|
||||
- find-scroll
|
||||
condition:
|
||||
pwd:
|
||||
- '^/home/commander$'
|
||||
command:
|
||||
- cat forgotten-scroll.txt
|
||||
responses:
|
||||
- type: dialogue
|
||||
speaker: Unix 精靈
|
||||
content:
|
||||
- 你找到的是一個被遺忘的卷軸,上面寫著解除封印的咒語。
|
||||
- 使用你已經知道的 `echo` 指令來詠唱咒語。
|
||||
|
||||
- id: unseal-room
|
||||
task: 解除封印
|
||||
repeatable: false
|
||||
requirements:
|
||||
- read-forgotten-scroll
|
||||
condition:
|
||||
pwd:
|
||||
- '^/home/commander$'
|
||||
command:
|
||||
- echo Unix, rise and guide me!
|
||||
execution:
|
||||
- mv /usr/local/lib/warehouse /home/commander
|
||||
responses:
|
||||
- type: narrative
|
||||
content:
|
||||
- 突然之間,地板開始晃動。
|
||||
- 晃動持續了幾秒後停止了。
|
||||
- type: dialogue
|
||||
speaker: Unix 精靈
|
||||
content:
|
||||
- 我的指令官,剛剛房間內似乎發生了某些變化。
|
||||
- 再次使用 `ls` 來查看現在房間的情況吧。
|
||||
|
||||
- id: find-items
|
||||
task: 搜索房間
|
||||
repeatable: false
|
||||
requirements:
|
||||
- unseal-room
|
||||
condition:
|
||||
pwd:
|
||||
- '^/home/commander$'
|
||||
command:
|
||||
- ls
|
||||
responses:
|
||||
- type: narrative
|
||||
content:
|
||||
- 你看到了房間的地上散落著許多物品。
|
||||
- type: dialogue
|
||||
speaker: Unix 精靈
|
||||
content:
|
||||
- 看來這裡隱藏著許多有用的東西。
|
||||
- 我們可以把這裡作為一個根據地。
|
||||
- 但這裡有點太亂了,必須先把這裡整理乾淨。
|
||||
- 首先我們需要一個書櫃來存放這些文件。
|
||||
- 請使用 `mkdir document` 來創造一個書櫃。
|
||||
|
||||
- id: create-bookcase
|
||||
task: 創造書櫃
|
||||
repeatable: false
|
||||
requirements:
|
||||
- find-items
|
||||
condition:
|
||||
pwd:
|
||||
- '^/home/commander$'
|
||||
command:
|
||||
- mkdir document
|
||||
responses:
|
||||
- type: narrative
|
||||
content:
|
||||
- 你創造了一個書櫃。
|
||||
- type: dialogue
|
||||
speaker: Unix 精靈
|
||||
content:
|
||||
- 接著把文件都放到書櫃中吧。
|
||||
- 請使用 `mv [檔案名稱] document` 來將物品放入書櫃。
|
||||
- 可以輸入多個檔案名稱,並以空白隔開,來將多個檔案放入書櫃。
|
||||
- 例如:`mv [檔案名稱1] [檔案名稱2] document`
|
||||
- 注意:文件是副檔名為 `.txt` 的檔案,副檔名為 `.trash` 的檔案是垃圾,我們待會再處理。
|
||||
|
||||
- id: store-books
|
||||
task: 收拾文件
|
||||
repeatable: false
|
||||
requirements:
|
||||
- create-bookcase
|
||||
condition:
|
||||
pwd:
|
||||
- '^/home/commander$'
|
||||
files:
|
||||
- path: '*.txt'
|
||||
exists: false
|
||||
responses:
|
||||
- type: dialogue
|
||||
speaker: Unix 精靈
|
||||
content:
|
||||
- 整理好文件了,接下來把垃圾清理掉吧。
|
||||
- 請使用 `rm [檔案名稱]` 來銷毀垃圾。
|
||||
- 這個 `rm` 指令也可以一次處理多的檔案喔,用法跟 `mv` 一樣。
|
||||
- 如果你忘記的話,看看這個範例:`rm [檔案名稱1] [檔案名稱2]`
|
||||
|
||||
- id: remove-trash
|
||||
task: 銷毀垃圾
|
||||
repeatable: false
|
||||
requirements:
|
||||
- store-books
|
||||
condition:
|
||||
pwd:
|
||||
- '^/home/commander$'
|
||||
files:
|
||||
- path: '*.trash'
|
||||
exists: false
|
||||
responses:
|
||||
- type: dialogue
|
||||
speaker: Unix 精靈
|
||||
content:
|
||||
- 很好,讓我們看看現在房間的樣子吧。
|
||||
|
||||
- id: look-around-room
|
||||
task: 查看房間
|
||||
repeatable: false
|
||||
requirements:
|
||||
- removetrash
|
||||
condition:
|
||||
pwd:
|
||||
- '^/home/commander$'
|
||||
command:
|
||||
- ls
|
||||
responses:
|
||||
- type: narrative
|
||||
content:
|
||||
- 地上的垃圾都消失了。
|
||||
- type: dialogue
|
||||
speaker: Unix 精靈
|
||||
content:
|
||||
- 這樣乾淨多了,讓我們進到書櫃看看。
|
||||
- 使用 `cd [目錄名稱]` 來移動到指定的目錄。
|
||||
|
||||
- id: enter-bookcase
|
||||
task: 進入書櫃
|
||||
repeatable: false
|
||||
requirements:
|
||||
- look-around-room
|
||||
condition:
|
||||
pwd:
|
||||
- '^/home/commander$'
|
||||
command:
|
||||
- cd document
|
||||
responses:
|
||||
- type: narrative
|
||||
content:
|
||||
- 你進入了書櫃。
|
||||
- type: dialogue
|
||||
speaker: Unix 精靈
|
||||
content:
|
||||
- 用 `ls` 看看書櫃裡有什麼吧。
|
||||
|
||||
- id: look-around-bookcase
|
||||
task: 查看書櫃
|
||||
repeatable: false
|
||||
requirements:
|
||||
- enter-bookcase
|
||||
condition:
|
||||
pwd:
|
||||
- '^/home/commander/document$'
|
||||
command:
|
||||
- ls
|
||||
responses:
|
||||
- type: dialogue
|
||||
speaker: Unix 精靈
|
||||
content:
|
||||
- 現在,你可以使用 `cd ..` 來回到上一層目錄。
|
||||
|
||||
- id: exit-bookcase
|
||||
task: 離開書櫃
|
||||
repeatable: false
|
||||
requirements:
|
||||
- look-around-bookcase
|
||||
condition:
|
||||
pwd:
|
||||
- '^/home/commander/document$'
|
||||
command:
|
||||
- cd ..
|
||||
responses:
|
||||
- type: narrative
|
||||
content:
|
||||
- 你離開了書櫃。
|
||||
- type: dialogue
|
||||
speaker: Unix 精靈
|
||||
content:
|
||||
- 這些就是我現在能教你的所有指令了。這個世界還有許多其他的指令,讓你在冒險的過程中去蒐集。
|
||||
- 如果你準備好迎接接下來的冒險,就可以離開這個房間,開始你的旅程了。
|
||||
|
||||
- id: END
|
||||
task: 離開房間
|
||||
repeatable: false
|
||||
requirements:
|
||||
- look-around
|
||||
condition:
|
||||
pwd:
|
||||
- '^/home$'
|
||||
responses:
|
||||
- type: narrative
|
||||
content:
|
||||
- 你踏出了房門,真正的冒險現在才正要開始。
|
||||
+23
-11
@@ -1,4 +1,4 @@
|
||||
FROM node
|
||||
FROM node:20-alpine AS build
|
||||
|
||||
WORKDIR /app
|
||||
COPY ./package.json yarn.lock ./
|
||||
@@ -7,18 +7,30 @@ COPY packages/file-graph/package.json ./packages/file-graph/package.json
|
||||
COPY packages/utils/package.json ./packages/utils/package.json
|
||||
COPY packages/models/package.json ./packages/models/package.json
|
||||
RUN yarn install --frozen-lockfile --production
|
||||
# Install docker cli
|
||||
COPY --from=docker:dind /usr/local/bin/docker /usr/local/bin/
|
||||
|
||||
COPY ./server ./server
|
||||
COPY packages/file-graph ./packages/file-graph
|
||||
COPY packages/utils ./packages/utils
|
||||
COPY packages/models ./packages/models
|
||||
COPY quests ./quests
|
||||
|
||||
WORKDIR /app/server
|
||||
|
||||
ENV NODE_ENV=production
|
||||
ENV HOST=0.0.0.0
|
||||
ENV PORT=3000
|
||||
EXPOSE 3000
|
||||
|
||||
CMD [ "yarn", "start" ]
|
||||
# Production
|
||||
FROM build as prod
|
||||
|
||||
COPY packages/file-graph ./packages/file-graph
|
||||
COPY packages/utils ./packages/utils
|
||||
COPY packages/models ./packages/models
|
||||
COPY ./server ./server
|
||||
COPY quests ./quests
|
||||
|
||||
ENV NODE_ENV=production
|
||||
WORKDIR /app/server
|
||||
CMD [ "yarn", "start" ]
|
||||
|
||||
# Development
|
||||
FROM build as dev
|
||||
|
||||
RUN yarn install --frozen-lockfile
|
||||
COPY server/nodemon.json ./server/nodemon.json
|
||||
ENV NODE_ENV=development
|
||||
CMD [ "yarn", "server" ]
|
||||
@@ -1,18 +0,0 @@
|
||||
FROM node
|
||||
|
||||
WORKDIR /app
|
||||
COPY package.json yarn.lock ./
|
||||
COPY server/package.json ./server/package.json
|
||||
COPY packages/file-graph/package.json ./packages/file-graph/package.json
|
||||
COPY packages/utils/package.json ./packages/utils/package.json
|
||||
COPY packages/models/package.json ./packages/models/package.json
|
||||
RUN yarn install --frozen-lockfile
|
||||
|
||||
COPY server/nodemon.json ./server/nodemon.json
|
||||
|
||||
ENV NODE_ENV=development
|
||||
ENV HOST=0.0.0.0
|
||||
ENV PORT=3000
|
||||
EXPOSE 3000
|
||||
|
||||
CMD [ "yarn", "server" ]
|
||||
+2
-2
@@ -1,4 +1,4 @@
|
||||
{
|
||||
"watch": ["src", "swagger.yaml"],
|
||||
"ext": "js,yaml"
|
||||
"watch": ["src", "swagger.yaml", "../quests", "../packages"],
|
||||
"ext": "js,yaml,yml"
|
||||
}
|
||||
@@ -1,9 +1,10 @@
|
||||
/* eslint-disable import/prefer-default-export */
|
||||
import { Command, Session } from '@linux-odyssey/models'
|
||||
import CommandHandler from '../../game/commandHandler.js'
|
||||
import { pushToSession } from '../socket.js'
|
||||
|
||||
const commandCompleteCallbacks = new Map()
|
||||
|
||||
export async function newCommand(req, res) {
|
||||
console.log('new command:', req.body)
|
||||
const { command, pwd, output, error, ...additionalData } = req.body
|
||||
|
||||
if (!command) {
|
||||
@@ -41,6 +42,38 @@ export async function newCommand(req, res) {
|
||||
|
||||
const response = await commandHandler.run()
|
||||
|
||||
if (response) {
|
||||
c.stage = response.stage
|
||||
await c.save()
|
||||
commandCompleteCallbacks.set(session.id, response.callback)
|
||||
pushToSession(session.id, 'status', 'pending')
|
||||
}
|
||||
|
||||
res.status(201).json(response)
|
||||
await session.save()
|
||||
}
|
||||
|
||||
export async function completedCommand(req, res) {
|
||||
const sessionId = req.user.session_id
|
||||
|
||||
const session = await Session.findById(sessionId)
|
||||
|
||||
if (!session) {
|
||||
res.status(404).json({ message: 'session not found' })
|
||||
return
|
||||
}
|
||||
|
||||
if (session.status === 'inactive') {
|
||||
res.status(400).json({ message: 'session is not active' })
|
||||
return
|
||||
}
|
||||
|
||||
const callback = commandCompleteCallbacks.get(session.id)
|
||||
if (!callback) {
|
||||
res.status(400).json({ message: 'no callback found' })
|
||||
return
|
||||
}
|
||||
callback()
|
||||
commandCompleteCallbacks.delete(session.id)
|
||||
res.json({ message: 'ok' })
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Session, Quest } from '@linux-odyssey/models'
|
||||
import { createContainer } from '../../containers/docker.js'
|
||||
import { createContainer, deleteContainer } from '../../containers/docker.js'
|
||||
import SessionHandler from '../../game/sessionHandler.js'
|
||||
|
||||
function sessionSummary(session) {
|
||||
@@ -37,6 +37,23 @@ export async function getSessionList(req, res) {
|
||||
}
|
||||
}
|
||||
|
||||
async function deactivateSessions(user, quest) {
|
||||
const sessions = await Session.find({
|
||||
user,
|
||||
quest,
|
||||
status: 'active',
|
||||
})
|
||||
return Promise.all(
|
||||
sessions.map((session) => {
|
||||
return deleteContainer(session.containerId).then(() => {
|
||||
session.status = 'inactive'
|
||||
session.containerId = null
|
||||
return session.save()
|
||||
})
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
export async function createSession(req, res) {
|
||||
const quest = await Quest.findById(req.body.quest_id)
|
||||
if (!quest) {
|
||||
@@ -44,6 +61,11 @@ export async function createSession(req, res) {
|
||||
return
|
||||
}
|
||||
|
||||
// deactivate all active sessions
|
||||
deactivateSessions(req.user._id, quest._id).catch((err) => {
|
||||
console.error(err)
|
||||
})
|
||||
|
||||
try {
|
||||
const container = await createContainer(
|
||||
`quest-${quest.id}-${req.user.username}-${Date.now()}`
|
||||
|
||||
@@ -4,5 +4,6 @@ import * as commandController from '../controllers/commandController.js'
|
||||
const router = Router()
|
||||
|
||||
router.post('/', commandController.newCommand)
|
||||
router.post('/completed', commandController.completedCommand)
|
||||
|
||||
export default router
|
||||
@@ -19,6 +19,7 @@ router.use('/sessions', authRequired, sessions)
|
||||
router.use(
|
||||
'/commands',
|
||||
passport.authenticate('jwt', { session: false }),
|
||||
authRequired,
|
||||
commands
|
||||
)
|
||||
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import { execFile } from 'child_process'
|
||||
|
||||
import { buildFileCheckCmd } from '@linux-odyssey/utils'
|
||||
|
||||
function sanitizeId(id) {
|
||||
return id.replace(/[^a-zA-Z0-9]/g, '')
|
||||
}
|
||||
|
||||
// eslint-disable-next-line import/prefer-default-export
|
||||
export function checkFile(id, file) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const cmd = buildFileCheckCmd(file)
|
||||
// WARNING: This is vulnerable to command injection
|
||||
execFile(
|
||||
'docker',
|
||||
['exec', sanitizeId(id), ...cmd],
|
||||
{ timeout: 1000 },
|
||||
(error) => {
|
||||
const exists = !error
|
||||
if (exists === file.exists) {
|
||||
resolve(true)
|
||||
} else {
|
||||
reject(new Error('File check failed'))
|
||||
}
|
||||
}
|
||||
)
|
||||
})
|
||||
}
|
||||
@@ -50,7 +50,11 @@ export async function attachContainer(container, { token }) {
|
||||
AttachStderr: true,
|
||||
Cmd: ['/bin/zsh'],
|
||||
Tty: true,
|
||||
Env: [`TOKEN=${token}`, `API_ENDPOINT=http://backend:3000`],
|
||||
Env: [
|
||||
`TOKEN=${token}`,
|
||||
`API_ENDPOINT=http://backend:3000`,
|
||||
'ZDOTDIR=/etc/zsh',
|
||||
],
|
||||
})
|
||||
|
||||
const execOutput = await exec.start({
|
||||
|
||||
@@ -3,6 +3,7 @@ import { FileGraph } from '@linux-odyssey/file-graph'
|
||||
import { Quest } from '@linux-odyssey/models'
|
||||
import { pushToSession } from '../api/socket.js'
|
||||
import SessionHandler from './sessionHandler.js'
|
||||
import { checkFile } from '../containers/cli.js'
|
||||
|
||||
// 檢查 pattern 是否符合 input
|
||||
const checkMatch = (pattern, input) => {
|
||||
@@ -53,13 +54,49 @@ export default class CommandHandler extends SessionHandler {
|
||||
this.session.graph = graph
|
||||
}
|
||||
|
||||
isMatch(stage) {
|
||||
const keys = ['command', 'output', 'error', 'pwd']
|
||||
return keys.every((k) =>
|
||||
checkMatch(stage.condition[k], this.commandInput[k])
|
||||
async isMatch(condition) {
|
||||
return (
|
||||
this.checkKeys(condition) &&
|
||||
(await this.checkFiles(condition.files)) &&
|
||||
(await this.checkNot(condition.not)) &&
|
||||
(await this.checkOr(condition.or)) === true
|
||||
)
|
||||
}
|
||||
|
||||
checkKeys(condition) {
|
||||
const keys = ['command', 'output', 'error', 'pwd']
|
||||
return keys.every((k) => checkMatch(condition[k], this.commandInput[k]))
|
||||
}
|
||||
|
||||
async checkFiles(files) {
|
||||
if (files.length === 0) {
|
||||
return true
|
||||
}
|
||||
try {
|
||||
const checks = await Promise.all(
|
||||
files.map((f) => checkFile(this.session.containerId, f))
|
||||
)
|
||||
return checks.every((c) => c === true)
|
||||
} catch (e) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
async checkNot(condition) {
|
||||
if (!condition) {
|
||||
return true
|
||||
}
|
||||
return !(await this.isMatch(condition))
|
||||
}
|
||||
|
||||
async checkOr(conditions) {
|
||||
if (conditions.length === 0) {
|
||||
return true
|
||||
}
|
||||
const matches = await Promise.all(conditions.map((c) => this.isMatch(c)))
|
||||
return matches.some((m) => m === true)
|
||||
}
|
||||
|
||||
async run() {
|
||||
this.quest = await Quest.findById(this.session.quest)
|
||||
const stages = this.getStages()
|
||||
@@ -71,11 +108,18 @@ export default class CommandHandler extends SessionHandler {
|
||||
this.handleEvent()
|
||||
this.handleCommand()
|
||||
|
||||
const response = stages
|
||||
.filter((s) => this.isMatch(s))
|
||||
.reduce((r, s) => ({ ...r, ...this.execute(s) }), {})
|
||||
const matches = await Promise.all(
|
||||
stages.map((stage) => this.isMatch(stage.condition))
|
||||
)
|
||||
|
||||
const stage = stages.find((_, i) => matches[i])
|
||||
if (!stage) {
|
||||
return {}
|
||||
}
|
||||
const response = this.execute(stage)
|
||||
|
||||
return {
|
||||
stage: stage.id,
|
||||
end: this.session.status === 'finished',
|
||||
...response,
|
||||
}
|
||||
|
||||
@@ -34,7 +34,6 @@ export default class SessionHandler {
|
||||
addNewTasks() {
|
||||
const newTasks = this.getNewTasks()
|
||||
this.session.tasks.push(...newTasks)
|
||||
pushToSession(this.session.id, 'tasks', this.session.tasks)
|
||||
}
|
||||
|
||||
execute(stage) {
|
||||
@@ -45,12 +44,10 @@ export default class SessionHandler {
|
||||
})
|
||||
|
||||
this.session.hints.push(...stage.hints)
|
||||
pushToSession(this.session.id, 'hints', stage.hints)
|
||||
|
||||
if (stage.id === 'END') {
|
||||
this.session.status = 'finished'
|
||||
this.session.finishedAt = new Date()
|
||||
pushToSession(this.session.id, 'status', 'finished')
|
||||
}
|
||||
|
||||
this.addNewTasks()
|
||||
@@ -58,6 +55,11 @@ export default class SessionHandler {
|
||||
return {
|
||||
responses: stage.responses,
|
||||
hints: stage.hints,
|
||||
callback: () => {
|
||||
pushToSession(this.session.id, 'tasks', this.session.tasks)
|
||||
pushToSession(this.session.id, 'hints', stage.hints)
|
||||
pushToSession(this.session.id, 'status', this.session.status)
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
+29
-25
@@ -10,35 +10,39 @@ export default async function loadAndUpdateQuests() {
|
||||
const questNames = await fs.readdir(questDirectory)
|
||||
|
||||
const quests = Promise.all(
|
||||
questNames.map(async (id) => {
|
||||
// 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')
|
||||
questNames
|
||||
.filter((name) => !name.startsWith('.'))
|
||||
.map(async (id) => {
|
||||
// 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 body = await fs.readFile(fullPath, 'utf-8')
|
||||
try {
|
||||
// Read and parse the README.md file.
|
||||
const body = await fs.readFile(fullPath, 'utf-8')
|
||||
|
||||
const quest = yaml.parse(body)
|
||||
const quest = yaml.parse(body, {
|
||||
merge: true,
|
||||
})
|
||||
|
||||
return Quest.findByIdAndUpdate(
|
||||
id,
|
||||
{
|
||||
return Quest.findByIdAndUpdate(
|
||||
id,
|
||||
...quest,
|
||||
},
|
||||
{
|
||||
upsert: true,
|
||||
}
|
||||
)
|
||||
} catch (error) {
|
||||
console.error(`Error parsing quest ${id}:`, error)
|
||||
throw error
|
||||
}
|
||||
})
|
||||
{
|
||||
id,
|
||||
...quest,
|
||||
},
|
||||
{
|
||||
upsert: true,
|
||||
}
|
||||
)
|
||||
} catch (error) {
|
||||
console.error(`Error parsing quest ${id}:`, error)
|
||||
throw error
|
||||
}
|
||||
})
|
||||
)
|
||||
|
||||
return quests
|
||||
|
||||
@@ -1407,6 +1407,15 @@ axios@^1.5.0:
|
||||
form-data "^4.0.0"
|
||||
proxy-from-env "^1.1.0"
|
||||
|
||||
axios@^1.6.0:
|
||||
version "1.6.0"
|
||||
resolved "https://registry.yarnpkg.com/axios/-/axios-1.6.0.tgz#f1e5292f26b2fd5c2e66876adc5b06cdbd7d2102"
|
||||
integrity sha512-EZ1DYihju9pwVB+jg67ogm+Tmqc6JmhamRN6I4Zt8DfZu5lbcQGw3ozH9lFejSJgs/ibaef3A9PMXPLeefFGJg==
|
||||
dependencies:
|
||||
follow-redirects "^1.15.0"
|
||||
form-data "^4.0.0"
|
||||
proxy-from-env "^1.1.0"
|
||||
|
||||
babel-jest@^29.6.1:
|
||||
version "29.6.1"
|
||||
resolved "https://registry.yarnpkg.com/babel-jest/-/babel-jest-29.6.1.tgz#a7141ad1ed5ec50238f3cd36127636823111233a"
|
||||
|
||||
Reference in new issue
Block a user