mirror of
https://github.com/linux-odyssey/linux-odyssey.git
synced 2026-11-05 19:20:12 +08:00
Merge branch 'main' into app-neil
This commit is contained in:
commit
ac8e2bc73a
45 files changed
+800
-394
No files matched your search
@@ -1,12 +1,32 @@
|
|||||||
name: CI/CD Workflow
|
name: CI/CD
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
branches:
|
|
||||||
- main
|
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
|
ci:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v3
|
||||||
|
|
||||||
|
- uses: actions/setup-node@v3
|
||||||
|
with:
|
||||||
|
node-version: 20
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: yarn install
|
||||||
|
|
||||||
|
- name: Lint
|
||||||
|
run: yarn lint
|
||||||
|
|
||||||
|
- name: Test
|
||||||
|
run: yarn test
|
||||||
|
|
||||||
build-and-push:
|
build-and-push:
|
||||||
|
needs: ci
|
||||||
|
if: github.ref == 'refs/heads/main'
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
@@ -35,6 +55,14 @@ jobs:
|
|||||||
push: true
|
push: true
|
||||||
tags: docker.io/${{ secrets.DOCKERHUB_USERNAME }}/linux-odyssey:app
|
tags: docker.io/${{ secrets.DOCKERHUB_USERNAME }}/linux-odyssey:app
|
||||||
|
|
||||||
|
- name: Build base image
|
||||||
|
uses: docker/build-push-action@v2
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
file: ./quests/Dockerfile
|
||||||
|
push: false
|
||||||
|
tags: docker.io/${{ secrets.DOCKERHUB_USERNAME }}/linux-odyssey:base
|
||||||
|
|
||||||
- name: Build quest helloworld
|
- name: Build quest helloworld
|
||||||
uses: docker/build-push-action@v2
|
uses: docker/build-push-action@v2
|
||||||
with:
|
with:
|
||||||
|
|||||||
@@ -1,22 +0,0 @@
|
|||||||
name: ESLint Checks
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
lint:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: Checkout
|
|
||||||
uses: actions/checkout@v3
|
|
||||||
|
|
||||||
- uses: actions/setup-node@v3
|
|
||||||
with:
|
|
||||||
node-version: 20
|
|
||||||
|
|
||||||
- name: Install dependencies
|
|
||||||
run: yarn install
|
|
||||||
|
|
||||||
- name: Lint
|
|
||||||
run: yarn lint
|
|
||||||
@@ -9,6 +9,7 @@ lerna-debug.log*
|
|||||||
.bash_history
|
.bash_history
|
||||||
.viminfo
|
.viminfo
|
||||||
*.swp
|
*.swp
|
||||||
|
home
|
||||||
|
|
||||||
# Diagnostic reports (https://nodejs.org/api/report.html)
|
# Diagnostic reports (https://nodejs.org/api/report.html)
|
||||||
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
|
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
|
||||||
|
|||||||
+8
-4
@@ -1,14 +1,18 @@
|
|||||||
# Build stage
|
# Build stage
|
||||||
FROM node as build-stage
|
FROM node as build-stage
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY app/package.json yarn.lock ./
|
COPY package.json yarn.lock ./
|
||||||
RUN yarn install
|
COPY app/package.json ./app/package.json
|
||||||
COPY app .
|
COPY packages/file-graph/package.json ./packages/file-graph/package.json
|
||||||
|
RUN yarn install --frozen-lockfile
|
||||||
|
|
||||||
|
COPY app ./app
|
||||||
|
COPY packages/file-graph ./packages/file-graph
|
||||||
RUN yarn build
|
RUN yarn build
|
||||||
|
|
||||||
# Production stage
|
# Production stage
|
||||||
FROM nginx:stable-alpine as production-stage
|
FROM nginx:stable-alpine as production-stage
|
||||||
COPY --from=build-stage /app/dist /usr/share/nginx/html
|
COPY --from=build-stage /app/app/dist /usr/share/nginx/html
|
||||||
COPY deployment/nginx.conf /etc/nginx/conf.d/default.conf
|
COPY deployment/nginx.conf /etc/nginx/conf.d/default.conf
|
||||||
EXPOSE 80
|
EXPOSE 80
|
||||||
CMD ["nginx", "-g", "daemon off;"]
|
CMD ["nginx", "-g", "daemon off;"]
|
||||||
+5
-2
@@ -1,7 +1,10 @@
|
|||||||
FROM node
|
FROM node
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY app/package.json yarn.lock ./
|
COPY package.json yarn.lock ./
|
||||||
|
COPY app/package.json ./app/package.json
|
||||||
|
COPY packages/file-graph/package.json ./packages/file-graph/package.json
|
||||||
RUN yarn install --frozen-lockfile
|
RUN yarn install --frozen-lockfile
|
||||||
COPY app .
|
|
||||||
|
WORKDIR /app/app
|
||||||
CMD [ "yarn", "dev:docker", "--host", "0.0.0.0" ]
|
CMD [ "yarn", "dev:docker", "--host", "0.0.0.0" ]
|
||||||
@@ -11,6 +11,7 @@
|
|||||||
"preview": "vite preview"
|
"preview": "vite preview"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@linux-odyssey/file-graph": "*",
|
||||||
"@fortawesome/fontawesome-svg-core": "^6.4.0",
|
"@fortawesome/fontawesome-svg-core": "^6.4.0",
|
||||||
"@fortawesome/free-brands-svg-icons": "^6.4.0",
|
"@fortawesome/free-brands-svg-icons": "^6.4.0",
|
||||||
"@fortawesome/free-regular-svg-icons": "^6.4.0",
|
"@fortawesome/free-regular-svg-icons": "^6.4.0",
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
<template>
|
||||||
|
<a href="#" :class="nodeClass()">{{ node.discovered ? node.name : '???' }}</a>
|
||||||
|
<ul :class="nodeClass()" v-if="node.children && node.children.length > 0">
|
||||||
|
<li :class="nodeClass()" v-for="child in node.children" :key="child.path">
|
||||||
|
<FileNode :node="child" :pwd="pwd" />
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
const props = defineProps({
|
||||||
|
node: {
|
||||||
|
type: Object,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
pwd: {
|
||||||
|
type: String,
|
||||||
|
required: true,
|
||||||
|
default: '',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
function nodeClass() {
|
||||||
|
const classes = []
|
||||||
|
if (props.node.discovered) classes.push('discovered')
|
||||||
|
if (props.node.path === props.pwd) classes.push('pwd')
|
||||||
|
return classes
|
||||||
|
}
|
||||||
|
</script>
|
||||||
@@ -1,19 +1,19 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { ref } from 'vue'
|
import { onMounted, ref } from 'vue'
|
||||||
import api from '../utils/api'
|
import api from '../utils/api'
|
||||||
|
import sessionManager from '../utils/session'
|
||||||
|
|
||||||
const questData = ref(null)
|
const questData = ref(null)
|
||||||
const questErr = ref(null)
|
const questErr = ref(null)
|
||||||
|
|
||||||
api
|
onMounted(async () => {
|
||||||
.get('/quests/helloworld')
|
try {
|
||||||
.then((res) => {
|
const res = await api.get('/quests/helloworld')
|
||||||
console.log(res.data)
|
|
||||||
questData.value = res.data
|
questData.value = res.data
|
||||||
})
|
} catch (err) {
|
||||||
.catch((err) => {
|
|
||||||
questErr.value = err
|
questErr.value = err
|
||||||
})
|
}
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -26,8 +26,24 @@ api
|
|||||||
</h1>
|
</h1>
|
||||||
<hr class="border-text-tertiary border-2 my-2" />
|
<hr class="border-text-tertiary border-2 my-2" />
|
||||||
<div>
|
<div>
|
||||||
<p class="text-red-500" v-if="questErr">{{ questErr.message }}</p>
|
<div v-if="questData">
|
||||||
<p class="text-text" v-else-if="questData">{{ questData.instruction }}</p>
|
<p class="text-text">{{ questData.instruction }}</p>
|
||||||
|
<br />
|
||||||
|
<p class="text-text">Tasks:</p>
|
||||||
|
<ul v-if="sessionManager.session.value">
|
||||||
|
<li v-for="task in sessionManager.session.value.tasks" :key="task.id">
|
||||||
|
<p class="text-text">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
class="form-checkbox"
|
||||||
|
disabled
|
||||||
|
:checked="task.completed"
|
||||||
|
/>{{ task.name }}
|
||||||
|
</p>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<p class="text-red-500" v-else-if="questErr">{{ questErr.message }}</p>
|
||||||
<p class="text-text" v-else>Loading...</p>
|
<p class="text-text" v-else>Loading...</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,56 +1,16 @@
|
|||||||
<script setup></script>
|
<script setup>
|
||||||
|
import FileNode from './FileNode.vue'
|
||||||
|
import sessionManager from '../utils/session.js'
|
||||||
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="tree w-max h-max">
|
<div class="tree w-max h-max">
|
||||||
<ul>
|
<ul>
|
||||||
<li>
|
<li>
|
||||||
<a href="#">Parent</a>
|
<FileNode
|
||||||
<ul>
|
:node="sessionManager.graph.value"
|
||||||
<li>
|
:pwd="sessionManager.pwd.value"
|
||||||
<a href="#">Child</a>
|
/>
|
||||||
<ul>
|
|
||||||
<li>
|
|
||||||
<a href="#">Grand Child</a>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<a href="#">Child</a>
|
|
||||||
<ul>
|
|
||||||
<li><a href="#">Grand Child</a></li>
|
|
||||||
<li>
|
|
||||||
<a href="#">Grand Child</a>
|
|
||||||
<ul>
|
|
||||||
<li>
|
|
||||||
<a href="#">Great Grand Child</a>
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<a href="#">Great Grand Child</a>
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<a href="#">Great Grand Child</a>
|
|
||||||
<ul>
|
|
||||||
<li><a>Grand Child</a></li>
|
|
||||||
<li>
|
|
||||||
<a>Grand Child</a>
|
|
||||||
<ul>
|
|
||||||
<li>
|
|
||||||
<a>Great Grand Child</a>
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
<a>Great Grand Child</a>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</li>
|
|
||||||
<li><a>Grand Child</a></li>
|
|
||||||
</ul>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</li>
|
|
||||||
<li><a href="#">Grand Child</a></li>
|
|
||||||
</ul>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
@@ -149,17 +109,18 @@
|
|||||||
-moz-transition: all 0.5s;
|
-moz-transition: all 0.5s;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tree li a:hover,
|
a.discovered {
|
||||||
.tree li a:hover + ul li a {
|
|
||||||
background: #c8e4f8;
|
background: #c8e4f8;
|
||||||
color: #000;
|
color: #000;
|
||||||
border: 1px solid #94a0b4;
|
border: 1px solid #94a0b4;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tree li a:hover + ul li::after,
|
ul.discovered,
|
||||||
.tree li a:hover + ul li::before,
|
li.discovered {
|
||||||
.tree li a:hover + ul::before,
|
|
||||||
.tree li a:hover + ul ul::before {
|
|
||||||
border-color: #94a0b4;
|
border-color: #94a0b4;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
a.pwd {
|
||||||
|
box-shadow: 0 0 10px rgba(255, 255, 255, 0.5); /* 水平偏移、垂直偏移、模糊半徑、顏色 */
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
@@ -1,10 +1,19 @@
|
|||||||
import { ref } from 'vue'
|
import { ref } from 'vue'
|
||||||
|
import { FileGraph } from '@linux-odyssey/file-graph'
|
||||||
import api from './api'
|
import api from './api'
|
||||||
|
|
||||||
class SessionManager {
|
class SessionManager {
|
||||||
constructor(questId) {
|
constructor(questId) {
|
||||||
this.questId = questId
|
this.questId = questId
|
||||||
this.session = ref(null)
|
this.session = ref(null)
|
||||||
|
this.graph = ref(
|
||||||
|
new FileGraph({
|
||||||
|
path: '/',
|
||||||
|
type: 'folder',
|
||||||
|
discovered: false,
|
||||||
|
})
|
||||||
|
)
|
||||||
|
this.pwd = ref('')
|
||||||
}
|
}
|
||||||
|
|
||||||
getSession() {
|
getSession() {
|
||||||
@@ -14,6 +23,20 @@ class SessionManager {
|
|||||||
setSession(session) {
|
setSession(session) {
|
||||||
console.log('Update session:', session)
|
console.log('Update session:', session)
|
||||||
this.session.value = session
|
this.session.value = session
|
||||||
|
this.graph.value = new FileGraph(session.graph)
|
||||||
|
}
|
||||||
|
|
||||||
|
handleGraphUpdate(event) {
|
||||||
|
if (event.discover) {
|
||||||
|
this.graph.value.discover(event.discover)
|
||||||
|
}
|
||||||
|
if (event.pwd) {
|
||||||
|
this.pwd.value = event.pwd
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setTasks(tasks) {
|
||||||
|
this.session.value.tasks = tasks
|
||||||
}
|
}
|
||||||
|
|
||||||
async createSession() {
|
async createSession() {
|
||||||
@@ -37,7 +60,8 @@ class SessionManager {
|
|||||||
if (sessions.length === 0) {
|
if (sessions.length === 0) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const session = sessions[sessions.length - 1]
|
const { _id: sessionId } = sessions[sessions.length - 1]
|
||||||
|
const { data: session } = await api.get(`/sessions/${sessionId}`)
|
||||||
console.log(
|
console.log(
|
||||||
`Last Session ID: ${session._id}, Quest: ${session.quest}, Created At: ${session.createdAt}`
|
`Last Session ID: ${session._id}, Quest: ${session.quest}, Created At: ${session.createdAt}`
|
||||||
)
|
)
|
||||||
@@ -49,6 +73,7 @@ class SessionManager {
|
|||||||
if (!this.session.value) {
|
if (!this.session.value) {
|
||||||
await this.createSession()
|
await this.createSession()
|
||||||
}
|
}
|
||||||
|
console.log(this.session.value)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -18,6 +18,12 @@ onMounted(async () => {
|
|||||||
socket.on('disconnect', function close() {
|
socket.on('disconnect', function close() {
|
||||||
console.log('Disconnected from the server.')
|
console.log('Disconnected from the server.')
|
||||||
})
|
})
|
||||||
|
socket.on('graph', (event) => {
|
||||||
|
sessionManager.handleGraphUpdate(event)
|
||||||
|
})
|
||||||
|
socket.on('tasks', (tasks) => {
|
||||||
|
sessionManager.setTasks(tasks)
|
||||||
|
})
|
||||||
socket.on('message', console.log)
|
socket.on('message', console.log)
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -40,6 +40,12 @@ services:
|
|||||||
- MONGO_PASS=user-password
|
- MONGO_PASS=user-password
|
||||||
- MONGO_DB=odyssey-test
|
- MONGO_DB=odyssey-test
|
||||||
|
|
||||||
|
base:
|
||||||
|
image: lancatlin/linux-odyssey:base
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: ./quests/Dockerfile
|
||||||
|
|
||||||
helloworld:
|
helloworld:
|
||||||
image: lancatlin/linux-odyssey:helloworld
|
image: lancatlin/linux-odyssey:helloworld
|
||||||
build:
|
build:
|
||||||
|
|||||||
+11
-3
@@ -9,7 +9,8 @@ services:
|
|||||||
networks:
|
networks:
|
||||||
- internal
|
- internal
|
||||||
volumes:
|
volumes:
|
||||||
- ./app/src:/app/src
|
- ./app:/app/app
|
||||||
|
- ./packages/file-graph:/app/packages/file-graph
|
||||||
backend:
|
backend:
|
||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
@@ -21,8 +22,9 @@ services:
|
|||||||
- '3000:3000'
|
- '3000:3000'
|
||||||
volumes:
|
volumes:
|
||||||
- /var/run/docker.sock:/var/run/docker.sock
|
- /var/run/docker.sock:/var/run/docker.sock
|
||||||
- ./server/src:/app/src
|
- ./server/src:/app/server/src
|
||||||
- ./server/swagger.yaml:/app/swagger.yaml
|
- ./server/swagger.yaml:/app/server/swagger.yaml
|
||||||
|
- ./packages:/app/packages
|
||||||
- ./quests:/app/quests
|
- ./quests:/app/quests
|
||||||
environment:
|
environment:
|
||||||
- MONGO_URL=mongodb://db:27017/odyssey-test
|
- MONGO_URL=mongodb://db:27017/odyssey-test
|
||||||
@@ -46,6 +48,12 @@ services:
|
|||||||
environment:
|
environment:
|
||||||
- SWAGGER_FILE=/tmp/swagger.yaml
|
- SWAGGER_FILE=/tmp/swagger.yaml
|
||||||
|
|
||||||
|
base:
|
||||||
|
image: lancatlin/linux-odyssey:base
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: ./quests/Dockerfile
|
||||||
|
|
||||||
helloworld:
|
helloworld:
|
||||||
image: lancatlin/linux-odyssey:helloworld
|
image: lancatlin/linux-odyssey:helloworld
|
||||||
build:
|
build:
|
||||||
|
|||||||
@@ -13,10 +13,12 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"app": "yarn workspace app dev",
|
"app": "yarn workspace app dev",
|
||||||
"app:local": "yarn workspace app dev:local",
|
"app:local": "yarn workspace app dev:local",
|
||||||
|
"build": "yarn workspace app build",
|
||||||
"server": "yarn workspace server dev",
|
"server": "yarn workspace server dev",
|
||||||
"cli": "yarn workspace client start",
|
"cli": "yarn workspace client start",
|
||||||
"lint": "eslint --ext .js,.vue .",
|
"lint": "eslint --ext .js,.vue .",
|
||||||
"format": "prettier --ignore-path .prettierignore --write app server",
|
"format": "prettier --ignore-path .prettierignore --write app server",
|
||||||
|
"test": "yarn node --experimental-vm-modules $(yarn bin jest)",
|
||||||
"clean": "yarn workspace server clean"
|
"clean": "yarn workspace server clean"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
+32
-16
@@ -1,8 +1,11 @@
|
|||||||
/* eslint-disable no-underscore-dangle */
|
/* eslint-disable no-underscore-dangle */
|
||||||
|
import axios from 'axios'
|
||||||
import { stdin, stdout, exit } from 'process'
|
import { stdin, stdout, exit } from 'process'
|
||||||
import { io } from 'socket.io-client'
|
import { io } from 'socket.io-client'
|
||||||
import { program } from 'commander'
|
import { program } from 'commander'
|
||||||
|
|
||||||
|
let api = null
|
||||||
|
|
||||||
function get(key, defaultValue) {
|
function get(key, defaultValue) {
|
||||||
const value = process.env[key]
|
const value = process.env[key]
|
||||||
if (value != null) {
|
if (value != null) {
|
||||||
@@ -14,26 +17,29 @@ stdin.setRawMode(true)
|
|||||||
stdin.resume()
|
stdin.resume()
|
||||||
stdin.setEncoding('utf8')
|
stdin.setEncoding('utf8')
|
||||||
|
|
||||||
async function createdSession() {
|
function debug(...args) {
|
||||||
|
if (program.opts().debug) {
|
||||||
|
console.debug(...args)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createSession() {
|
||||||
console.log('Creating a new session...')
|
console.log('Creating a new session...')
|
||||||
const res = await fetch(`${program.opts().host}/api/v1/sessions`, {
|
const res = await api.post('/sessions', {
|
||||||
method: 'POST',
|
quest_id: 'helloworld',
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/json',
|
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
quest_id: 'helloworld',
|
|
||||||
}),
|
|
||||||
})
|
})
|
||||||
const data = await res.json()
|
const { data } = await res
|
||||||
|
if (!data._id) {
|
||||||
|
console.error(data)
|
||||||
|
exit(1)
|
||||||
|
}
|
||||||
console.log('Created Session ID:', data._id)
|
console.log('Created Session ID:', data._id)
|
||||||
return data
|
return data
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getSessionList() {
|
async function getSessionList() {
|
||||||
const res = await fetch(`${program.opts().host}/api/v1/sessions`)
|
const res = await api.get('/sessions')
|
||||||
const data = await res.json()
|
return res.data
|
||||||
return data
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function lastSession() {
|
async function lastSession() {
|
||||||
@@ -73,8 +79,11 @@ async function connect(sessionId) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
socket.on('graph', (data) => {
|
socket.on('graph', (data) => {
|
||||||
console.log('receive graph:')
|
debug('receive graph:', data)
|
||||||
console.log(data)
|
})
|
||||||
|
|
||||||
|
socket.on('tasks', (data) => {
|
||||||
|
debug('receive tasks:', data)
|
||||||
})
|
})
|
||||||
|
|
||||||
socket.on('close', function close() {
|
socket.on('close', function close() {
|
||||||
@@ -93,12 +102,18 @@ async function connect(sessionId) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function main() {
|
async function main() {
|
||||||
|
api = axios.create({
|
||||||
|
baseURL: `${program.opts().host}/api/v1`,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
})
|
||||||
console.log(program.opts().session)
|
console.log(program.opts().session)
|
||||||
// main()
|
// main()
|
||||||
|
|
||||||
const sessionId =
|
const sessionId =
|
||||||
program.opts().session ||
|
program.opts().session ||
|
||||||
(program.opts().create ? (await createdSession())._id : null) ||
|
(program.opts().create ? (await createSession())._id : null) ||
|
||||||
(await lastSession())._id
|
(await lastSession())._id
|
||||||
|
|
||||||
await connect(sessionId)
|
await connect(sessionId)
|
||||||
@@ -107,6 +122,7 @@ async function main() {
|
|||||||
program
|
program
|
||||||
.option('-s, --session <string>', 'Session ID')
|
.option('-s, --session <string>', 'Session ID')
|
||||||
.option('-c, --create', 'Create a new session')
|
.option('-c, --create', 'Create a new session')
|
||||||
|
.option('-d, --debug', 'Debug mode')
|
||||||
.option(
|
.option(
|
||||||
'-h, --host <string>',
|
'-h, --host <string>',
|
||||||
'Server host',
|
'Server host',
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
"start": "node index.js"
|
"start": "node index.js"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"axios": "^1.5.0",
|
||||||
"commander": "^11.0.0",
|
"commander": "^11.0.0",
|
||||||
"socket.io-client": "^4.7.2"
|
"socket.io-client": "^4.7.2"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,6 +36,12 @@ function waitForEnter() {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function waitTime(time) {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
setTimeout(resolve, time)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
function printLine(content, delay, color) {
|
function printLine(content, delay, color) {
|
||||||
const printContent = `${colorize(content, color)}${RETURN_SYMBOL}`
|
const printContent = `${colorize(content, color)}${RETURN_SYMBOL}`
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
@@ -59,6 +65,9 @@ function printLine(content, delay, color) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function printResponses(responses, delay = 100) {
|
async function printResponses(responses, delay = 100) {
|
||||||
|
await waitTime(1000)
|
||||||
|
console.log()
|
||||||
|
console.log(colorize('-------------', 'white'))
|
||||||
for (const response of responses) {
|
for (const response of responses) {
|
||||||
const { type, content, speaker, color } = response
|
const { type, content, speaker, color } = response
|
||||||
if (type === 'narrative') {
|
if (type === 'narrative') {
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { basename } from './utils.js'
|
|||||||
|
|
||||||
export default class File {
|
export default class File {
|
||||||
constructor({ path, type, discovered }) {
|
constructor({ path, type, discovered }) {
|
||||||
this.path = path
|
this.path = path.trimEnd('/')
|
||||||
this.name = basename(path)
|
this.name = basename(path)
|
||||||
this.type = type
|
this.type = type
|
||||||
this.discovered = discovered
|
this.discovered = discovered
|
||||||
@@ -13,6 +13,9 @@ export default class File {
|
|||||||
}
|
}
|
||||||
|
|
||||||
contains(file) {
|
contains(file) {
|
||||||
return this.isDirectory() && file.path.startsWith(this.path)
|
if (!this.isDirectory()) return false
|
||||||
|
if (this.path === file.path) return true
|
||||||
|
const parentPath = this.path.endsWith('/') ? this.path : `${this.path}/`
|
||||||
|
return file.path.startsWith(parentPath)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import File from './file.js'
|
||||||
|
|
||||||
|
describe('File', () => {
|
||||||
|
let file
|
||||||
|
beforeEach(() => {
|
||||||
|
file = new File({ path: '/home/user', type: 'folder', discovered: true })
|
||||||
|
})
|
||||||
|
|
||||||
|
const filesToCompare = [
|
||||||
|
{
|
||||||
|
name: 'direct children',
|
||||||
|
path: '/home/user/file1.txt',
|
||||||
|
answer: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'nested children',
|
||||||
|
path: '/home/user/folder1/file1.txt',
|
||||||
|
answer: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'outer file',
|
||||||
|
path: '/etc/file1.txt',
|
||||||
|
answer: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'fake children',
|
||||||
|
path: '/home/user2/file2.txt',
|
||||||
|
answer: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'same',
|
||||||
|
path: '/home/user',
|
||||||
|
answer: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'same with trailing',
|
||||||
|
path: '/home/user/',
|
||||||
|
answer: true,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
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)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -1,17 +1,13 @@
|
|||||||
import FileNode from './fileNode.js'
|
import FileNode from './fileNode.js'
|
||||||
|
|
||||||
export default class FileGraph {
|
export default class FileGraph extends FileNode {
|
||||||
constructor(root) {
|
|
||||||
this.root = new FileNode(root)
|
|
||||||
}
|
|
||||||
|
|
||||||
add(files) {
|
add(files) {
|
||||||
// Logic to add the file node to the graph
|
// Logic to add the file node to the graph
|
||||||
// You can implement the logic to find the appropriate location in the file graph
|
// You can implement the logic to find the appropriate location in the file graph
|
||||||
// If the file node already exists, throw an error
|
// If the file node already exists, throw an error
|
||||||
|
|
||||||
files.forEach((file) => {
|
files.forEach((file) => {
|
||||||
this.root.addChild(file, { makeParents: true })
|
this.addChild(file, { makeParents: true })
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -21,7 +17,7 @@ export default class FileGraph {
|
|||||||
// If the file node doesn't exist, throw an error
|
// If the file node doesn't exist, throw an error
|
||||||
|
|
||||||
files.forEach((file) => {
|
files.forEach((file) => {
|
||||||
this.root.removeChild(file)
|
this.removeChild(file)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -36,7 +32,7 @@ export default class FileGraph {
|
|||||||
newNode.addChild(file, { makeParents: true })
|
newNode.addChild(file, { makeParents: true })
|
||||||
})
|
})
|
||||||
|
|
||||||
this.root.merge(newNode)
|
this.merge(newNode)
|
||||||
}
|
}
|
||||||
|
|
||||||
handleEvent(event) {
|
handleEvent(event) {
|
||||||
|
|||||||
@@ -30,9 +30,9 @@ describe('FileGraph', () => {
|
|||||||
|
|
||||||
fileGraph.add(filesToAdd)
|
fileGraph.add(filesToAdd)
|
||||||
|
|
||||||
console.log(fileGraph.root.toString())
|
console.log(fileGraph.toString())
|
||||||
expect(fileGraph.root.children.length).toBe(1)
|
expect(fileGraph.children.length).toBe(1)
|
||||||
expect(fileGraph.root.children[0].children[0].children.length).toBe(2)
|
expect(fileGraph.children[0].children[0].children.length).toBe(2)
|
||||||
})
|
})
|
||||||
|
|
||||||
test('remove files from graph', () => {
|
test('remove files from graph', () => {
|
||||||
@@ -76,14 +76,44 @@ describe('FileGraph', () => {
|
|||||||
|
|
||||||
fileGraph.remove(filesToRemove)
|
fileGraph.remove(filesToRemove)
|
||||||
|
|
||||||
console.log(fileGraph.root.toString())
|
console.log(fileGraph.toString())
|
||||||
expect(fileGraph.root.children[0].children[0].children.length).toBe(1)
|
expect(fileGraph.children[0].children[0].children.length).toBe(1)
|
||||||
expect(fileGraph.root.children[0].children[0].children[0].name).toBe(
|
expect(fileGraph.children[0].children[0].children[0].name).toBe('folder1')
|
||||||
'folder1'
|
expect(fileGraph.children[0].children[0].children[0].children.length).toBe(
|
||||||
|
0
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('discover', () => {
|
||||||
|
const fileGraph = new FileGraph(root)
|
||||||
|
const discoverFiles = [
|
||||||
|
{
|
||||||
|
path: '/home/user/folder1',
|
||||||
|
type: 'folder',
|
||||||
|
discovered: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/home/user/folder1/file1.txt',
|
||||||
|
type: 'file',
|
||||||
|
discovered: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/home/user/folder1/file2.txt',
|
||||||
|
type: 'file',
|
||||||
|
discovered: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/home/user/folder1/file3.txt',
|
||||||
|
type: 'file',
|
||||||
|
discovered: true,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
fileGraph.discover(discoverFiles)
|
||||||
|
console.log(fileGraph.toString())
|
||||||
|
expect(fileGraph.children[0].children[0].children[0].children.length).toBe(
|
||||||
|
3
|
||||||
)
|
)
|
||||||
expect(
|
|
||||||
fileGraph.root.children[0].children[0].children[0].children.length
|
|
||||||
).toBe(0)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
test('discover new files', () => {
|
test('discover new files', () => {
|
||||||
@@ -113,7 +143,7 @@ describe('FileGraph', () => {
|
|||||||
]
|
]
|
||||||
|
|
||||||
fileGraph.add(filesToAdd)
|
fileGraph.add(filesToAdd)
|
||||||
console.log(fileGraph.root.toString())
|
console.log(fileGraph.toString())
|
||||||
|
|
||||||
const newFiles = [
|
const newFiles = [
|
||||||
{
|
{
|
||||||
@@ -140,10 +170,10 @@ describe('FileGraph', () => {
|
|||||||
|
|
||||||
fileGraph.discover(newFiles)
|
fileGraph.discover(newFiles)
|
||||||
|
|
||||||
console.log(fileGraph.root.toString())
|
console.log(fileGraph.toString())
|
||||||
expect(fileGraph.root.children[0].children[0].children.length).toBe(2)
|
expect(fileGraph.children[0].children[0].children.length).toBe(2)
|
||||||
expect(
|
expect(fileGraph.children[0].children[0].children[0].children.length).toBe(
|
||||||
fileGraph.root.children[0].children[0].children[0].children.length
|
3
|
||||||
).toBe(3)
|
)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
@@ -9,7 +9,8 @@ import { basename, dirname } from './utils.js'
|
|||||||
export default class FileNode extends File {
|
export default class FileNode extends File {
|
||||||
constructor(file) {
|
constructor(file) {
|
||||||
super(file)
|
super(file)
|
||||||
this.children = file.children || []
|
this.children = []
|
||||||
|
if (file.children) this.children = file.children.map((f) => new FileNode(f))
|
||||||
}
|
}
|
||||||
|
|
||||||
addChild(fileInput, { makeParents } = {}) {
|
addChild(fileInput, { makeParents } = {}) {
|
||||||
@@ -76,7 +77,12 @@ export default class FileNode extends File {
|
|||||||
// Merge the children of the provided file node into this node
|
// Merge the children of the provided file node into this node
|
||||||
// If a child already exists, merge the children of that child
|
// If a child already exists, merge the children of that child
|
||||||
// If a child does not exist, add it to this node
|
// If a child does not exist, add it to this node
|
||||||
|
|
||||||
|
// If this is the same node, merge the children
|
||||||
if (fileNode.path === this.path) {
|
if (fileNode.path === this.path) {
|
||||||
|
// Update the state of this node
|
||||||
|
this.discovered = this.discovered || fileNode.discovered
|
||||||
|
|
||||||
fileNode.children.forEach((child) => {
|
fileNode.children.forEach((child) => {
|
||||||
const existingChild = this.children.find((c) => c.path === child.path)
|
const existingChild = this.children.find((c) => c.path === child.path)
|
||||||
if (existingChild) {
|
if (existingChild) {
|
||||||
@@ -92,12 +98,17 @@ export default class FileNode extends File {
|
|||||||
fileNode.children.some((c) => c.path === child.path)
|
fileNode.children.some((c) => c.path === child.path)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
} else if (this.contains(fileNode)) {
|
}
|
||||||
|
// If this contains the parent node
|
||||||
|
else if (this.contains(fileNode)) {
|
||||||
const childNode = this.children.find((child) => child.contains(fileNode))
|
const childNode = this.children.find((child) => child.contains(fileNode))
|
||||||
|
// If a child node exists, merge the provided node into the child node
|
||||||
if (childNode) {
|
if (childNode) {
|
||||||
childNode.merge(fileNode)
|
childNode.merge(fileNode)
|
||||||
} else {
|
}
|
||||||
this.addChild(fileNode)
|
// If a child node does not exist, add the provided node as a child
|
||||||
|
else {
|
||||||
|
this.addChild(fileNode, { makeParents: true })
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
@@ -108,12 +119,15 @@ export default class FileNode extends File {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
toString() {
|
toString(level = 0) {
|
||||||
const indent = ' '.repeat(this.path.split('/').length - 1)
|
let { name } = this
|
||||||
const suffix = this.isDirectory() ? '/' : ''
|
const indent = ' '.repeat(level)
|
||||||
|
name = `${indent}${name}`
|
||||||
|
if (this.isDirectory()) name += '/'
|
||||||
|
if (!this.discovered) name += `*`
|
||||||
const childrenString = this.children
|
const childrenString = this.children
|
||||||
.map((child) => child.toString())
|
.map((child) => child.toString(level + 1))
|
||||||
.join('')
|
.join('')
|
||||||
return `${indent}${this.name}${suffix}\n${childrenString}`
|
return `${name}\n${childrenString}`
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -193,6 +193,35 @@ describe('FileNode', () => {
|
|||||||
mergeTest(files1, files2, expected)
|
mergeTest(files1, files2, expected)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('merge-hidden', () => {
|
||||||
|
const root = {
|
||||||
|
path: '/',
|
||||||
|
type: 'folder',
|
||||||
|
}
|
||||||
|
const files1 = [
|
||||||
|
root,
|
||||||
|
{ path: '/a', type: 'file', discovered: true },
|
||||||
|
{ path: '/.b', type: 'file', discovered: false },
|
||||||
|
{ path: '/c', type: 'folder', discovered: true },
|
||||||
|
{ path: '/c/.d', type: 'file', discovered: false },
|
||||||
|
]
|
||||||
|
|
||||||
|
const files2 = [
|
||||||
|
{ path: '/c', type: 'folder', discovered: true },
|
||||||
|
{ path: '/c/.d', type: 'file', discovered: true },
|
||||||
|
]
|
||||||
|
|
||||||
|
const expected = [
|
||||||
|
root,
|
||||||
|
{ path: '/a', type: 'file', discovered: true },
|
||||||
|
{ path: '/.b', type: 'file', discovered: false },
|
||||||
|
{ path: '/c', type: 'folder', discovered: true },
|
||||||
|
{ path: '/c/.d', type: 'file', discovered: true },
|
||||||
|
]
|
||||||
|
|
||||||
|
mergeTest(files1, files2, expected)
|
||||||
|
})
|
||||||
|
|
||||||
test('remove-child', () => {
|
test('remove-child', () => {
|
||||||
const root = {
|
const root = {
|
||||||
path: '/',
|
path: '/',
|
||||||
@@ -219,3 +248,39 @@ describe('FileNode', () => {
|
|||||||
removeChildTest(files, fileToRemove, expected)
|
removeChildTest(files, fileToRemove, expected)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('load nested fileNode', () => {
|
||||||
|
const data = {
|
||||||
|
path: '/',
|
||||||
|
type: 'folder',
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
path: '/a',
|
||||||
|
type: 'file',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/b',
|
||||||
|
type: 'folder',
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
path: '/b/c',
|
||||||
|
type: 'folder',
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
path: '/b/c/d',
|
||||||
|
type: 'file',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
})
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
export { default as File } from './file.js'
|
||||||
|
export { default as FileNode } from './fileNode.js'
|
||||||
|
export { default as FileGraph } from './fileGraph.js'
|
||||||
@@ -1,10 +1,9 @@
|
|||||||
{
|
{
|
||||||
"name": "file-graph",
|
"name": "@linux-odyssey/file-graph",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"main": "index.js",
|
"main": "index.js",
|
||||||
|
"exports": "./index.js",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {}
|
||||||
"test": "yarn node --experimental-vm-modules $(yarn bin jest)"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
FROM ubuntu:22.04
|
||||||
|
|
||||||
|
LABEL maintainer="wancat@wancat.cc"
|
||||||
|
|
||||||
|
RUN apt-get update && apt-get install -y \
|
||||||
|
locales \
|
||||||
|
curl \
|
||||||
|
vim \
|
||||||
|
zsh \
|
||||||
|
ca-certificates \
|
||||||
|
gnupg
|
||||||
|
|
||||||
|
RUN sed -i -e 's/# zh_TW.UTF-8 UTF-8/zh_TW.UTF-8 UTF-8/' /etc/locale.gen && \
|
||||||
|
dpkg-reconfigure --frontend=noninteractive locales && \
|
||||||
|
update-locale LANG=zh_TW.UTF-8
|
||||||
|
|
||||||
|
ENV LANG zh_TW.UTF-8
|
||||||
|
ENV LANGUAGE zh_TW:zh
|
||||||
|
ENV LC_ALL zh_TW.UTF-8
|
||||||
|
|
||||||
|
ENV NODE_MAJOR=20
|
||||||
|
RUN curl -fsSL https://deb.nodesource.com/gpgkey/nodesource-repo.gpg.key | gpg --dearmor -o /etc/apt/keyrings/nodesource.gpg
|
||||||
|
RUN echo "deb [signed-by=/etc/apt/keyrings/nodesource.gpg] https://deb.nodesource.com/node_$NODE_MAJOR.x nodistro main" | tee /etc/apt/sources.list.d/nodesource.list
|
||||||
|
RUN apt-get update && apt-get install nodejs -y
|
||||||
|
|
||||||
|
WORKDIR /usr/local/lib/container
|
||||||
|
|
||||||
|
RUN npm install -g yarn
|
||||||
|
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
|
||||||
|
|
||||||
|
ENV USERNAME=commander
|
||||||
|
|
||||||
|
RUN adduser --disabled-password --gecos '' --shell /bin/zsh ${USERNAME}
|
||||||
|
|
||||||
|
USER ${USERNAME}
|
||||||
|
|
||||||
|
WORKDIR /home/${USERNAME}
|
||||||
|
|
||||||
@@ -1,35 +1,5 @@
|
|||||||
FROM node:latest
|
FROM lancatlin/linux-odyssey:base
|
||||||
|
|
||||||
RUN apt-get update && apt-get install -y \
|
COPY ./quests/helloworld/home ${HOME}
|
||||||
locales \
|
|
||||||
curl \
|
|
||||||
vim \
|
|
||||||
zsh
|
|
||||||
|
|
||||||
RUN sed -i -e 's/# zh_TW.UTF-8 UTF-8/zh_TW.UTF-8 UTF-8/' /etc/locale.gen && \
|
|
||||||
dpkg-reconfigure --frontend=noninteractive locales && \
|
|
||||||
update-locale LANG=zh_TW.UTF-8
|
|
||||||
|
|
||||||
ENV LANG zh_TW.UTF-8
|
|
||||||
ENV LANGUAGE zh_TW:zh
|
|
||||||
ENV LC_ALL zh_TW.UTF-8
|
|
||||||
|
|
||||||
WORKDIR /usr/local/lib/container
|
|
||||||
|
|
||||||
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
|
|
||||||
|
|
||||||
RUN adduser --disabled-password --gecos '' --shell /bin/zsh rudeus
|
|
||||||
|
|
||||||
USER rudeus
|
|
||||||
|
|
||||||
WORKDIR /home/rudeus
|
|
||||||
|
|
||||||
COPY ./quests/helloworld/home /home/rudeus/
|
|
||||||
|
|
||||||
CMD [ "/bin/zsh" ]
|
CMD [ "/bin/zsh" ]
|
||||||
@@ -4,11 +4,14 @@ instruction: |
|
|||||||
你身在一個漆黑的房間裡,沒有任何的光線或聲音。你想不起來為什麼自己會來到這裡。但你的腦中隱約感到某個聲音跟你說:
|
你身在一個漆黑的房間裡,沒有任何的光線或聲音。你想不起來為什麼自己會來到這裡。但你的腦中隱約感到某個聲音跟你說:
|
||||||
|
|
||||||
「輸入 echo Hello World!」
|
「輸入 echo Hello World!」
|
||||||
|
|
||||||
stages:
|
stages:
|
||||||
- id: helloworld
|
- id: helloworld
|
||||||
|
task: 使用 `echo Hello World!` 召喚精靈
|
||||||
|
repeatable: false
|
||||||
condition:
|
condition:
|
||||||
command:
|
command:
|
||||||
- echo Hello World!
|
- echo Hello World
|
||||||
responses:
|
responses:
|
||||||
- type: narrative
|
- type: narrative
|
||||||
content:
|
content:
|
||||||
@@ -22,12 +25,16 @@ stages:
|
|||||||
- 由於長久以來依賴於薩加,世人已失去了「指令」的力量,即將走向衰敗和滅亡的命運。
|
- 由於長久以來依賴於薩加,世人已失去了「指令」的力量,即將走向衰敗和滅亡的命運。
|
||||||
- 你的任務,就是找回被世人遺忘的指令,奪回 Linux 之根,成為 Linux 之王。
|
- 你的任務,就是找回被世人遺忘的指令,奪回 Linux 之根,成為 Linux 之王。
|
||||||
- 首先,你必須要學會如何使用指令。
|
- 首先,你必須要學會如何使用指令。
|
||||||
- 一個指令是由一個指令名稱和一些參數所組成的,而指令名稱和參數之間是以空白字元分隔。
|
- 你要學習的第一個指令,是「發現」的指令:`ls`。ls 可以用來查看一個目錄中有哪些檔案。
|
||||||
- 例如,你可以輸入 `ls` 來查看目前的目錄下有哪些檔案。
|
- 現在,請你輸入 `ls` 來查看目前的目錄下有哪些檔案。
|
||||||
hints:
|
hints:
|
||||||
- 使用 `ls` 指令來查看目前的目錄下有哪些檔案。
|
- 使用 `ls` 指令來查看目前的目錄下有哪些檔案。
|
||||||
next: ls
|
|
||||||
- id: ls
|
- id: ls
|
||||||
|
task: 搜索卷軸
|
||||||
|
repeatable: false
|
||||||
|
requirements:
|
||||||
|
- helloworld
|
||||||
condition:
|
condition:
|
||||||
command:
|
command:
|
||||||
- ls
|
- ls
|
||||||
@@ -47,8 +54,27 @@ stages:
|
|||||||
hints:
|
hints:
|
||||||
- 使用 `cat [檔案名稱]` 指令來查看檔案內容。
|
- 使用 `cat [檔案名稱]` 指令來查看檔案內容。
|
||||||
- 使用 `Tab` 鍵來取得指令提示。
|
- 使用 `Tab` 鍵來取得指令提示。
|
||||||
next: cat
|
|
||||||
|
- id: escape
|
||||||
|
task: 移動到下一個房間
|
||||||
|
repeatable: false
|
||||||
|
requirements:
|
||||||
|
- ls
|
||||||
|
condition:
|
||||||
|
pwd:
|
||||||
|
- '^/home/commander/jail$'
|
||||||
|
responses:
|
||||||
|
- type: narrative
|
||||||
|
content:
|
||||||
|
- 你進入了一個牢房,牢房裡有一個門,門上有一個鎖。
|
||||||
|
- 突然,你聽到門上鎖的聲音。
|
||||||
|
- 轉身發現,自己被關在了牢房裡。
|
||||||
|
|
||||||
- id: cat
|
- id: cat
|
||||||
|
task: 查看卷軸
|
||||||
|
repeatable: false
|
||||||
|
requirements:
|
||||||
|
- ls
|
||||||
condition:
|
condition:
|
||||||
command:
|
command:
|
||||||
- cat forgotten_scroll.txt
|
- cat forgotten_scroll.txt
|
||||||
@@ -58,8 +84,12 @@ stages:
|
|||||||
content:
|
content:
|
||||||
- 你找到的是一個被遺忘的卷軸,上面寫著解除封印的咒語。
|
- 你找到的是一個被遺忘的卷軸,上面寫著解除封印的咒語。
|
||||||
- 使用你已經知道的 `echo` 指令來詠唱咒語。
|
- 使用你已經知道的 `echo` 指令來詠唱咒語。
|
||||||
next: echo
|
|
||||||
- id: echo
|
- id: END
|
||||||
|
task: 解除封印
|
||||||
|
repeatable: false
|
||||||
|
requirements:
|
||||||
|
- cat
|
||||||
condition:
|
condition:
|
||||||
command:
|
command:
|
||||||
- echo Unix, rise and guide me!
|
- echo Unix, rise and guide me!
|
||||||
@@ -71,5 +101,4 @@ stages:
|
|||||||
speaker: Unix Spirit
|
speaker: Unix Spirit
|
||||||
content:
|
content:
|
||||||
- 恭喜您,我的指令官,您已成功解除了封印。
|
- 恭喜您,我的指令官,您已成功解除了封印。
|
||||||
- 請跟著在接下了關卡,一步步取回 Linux 之根吧!
|
- 請跟著在接下來的關卡,一步步取回 Linux 之根吧!
|
||||||
next: END
|
|
||||||
@@ -6,7 +6,10 @@
|
|||||||
|
|
||||||
1. echo 朗誦魔術
|
1. echo 朗誦魔術
|
||||||
2. ls 搜索魔術
|
2. ls 搜索魔術
|
||||||
3. cat 檢察魔術
|
3. cat 檢查魔術
|
||||||
|
4. cd 移動魔術
|
||||||
|
5. mv 搬運魔術
|
||||||
|
6. rm 消滅魔術
|
||||||
|
|
||||||
## 關卡設計
|
## 關卡設計
|
||||||
|
|
||||||
@@ -14,8 +17,8 @@
|
|||||||
2. 學習到了 ls
|
2. 學習到了 ls
|
||||||
3. 使用 ls 找到地上放置了一個卷軸
|
3. 使用 ls 找到地上放置了一個卷軸
|
||||||
4. 學習 cat
|
4. 學習 cat
|
||||||
5. 使用 cat 查看卷軸內容
|
5. 使用 cat 查看卷軸內容,發現
|
||||||
6. 用 echo 念出卷軸上的咒語
|
6.
|
||||||
|
|
||||||
請幫我設計:
|
請幫我設計:
|
||||||
|
|
||||||
|
|||||||
+7
-3
@@ -1,13 +1,17 @@
|
|||||||
FROM node
|
FROM node
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY server/package.json yarn.lock ./
|
COPY ./package.json yarn.lock ./
|
||||||
|
COPY server/package.json ./server/package.json
|
||||||
|
COPY packages/file-graph/package.json ./packages/file-graph/package.json
|
||||||
RUN yarn install --frozen-lockfile --production
|
RUN yarn install --frozen-lockfile --production
|
||||||
|
|
||||||
COPY server/src ./src
|
COPY ./server ./server
|
||||||
COPY server/swagger.yaml .
|
COPY packages/file-graph ./packages/file-graph
|
||||||
COPY quests ./quests
|
COPY quests ./quests
|
||||||
|
|
||||||
|
WORKDIR /app/server
|
||||||
|
|
||||||
ENV NODE_ENV=production
|
ENV NODE_ENV=production
|
||||||
ENV HOST=0.0.0.0
|
ENV HOST=0.0.0.0
|
||||||
ENV PORT=3000
|
ENV PORT=3000
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
FROM node
|
FROM node
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY server/package.json yarn.lock ./
|
COPY package.json yarn.lock ./
|
||||||
|
COPY server/package.json ./server/package.json
|
||||||
|
COPY packages/file-graph/package.json ./packages/file-graph/package.json
|
||||||
RUN yarn install --frozen-lockfile
|
RUN yarn install --frozen-lockfile
|
||||||
|
|
||||||
ENV NODE_ENV=development
|
ENV NODE_ENV=development
|
||||||
@@ -9,4 +11,4 @@ ENV HOST=0.0.0.0
|
|||||||
ENV PORT=3000
|
ENV PORT=3000
|
||||||
EXPOSE 3000
|
EXPOSE 3000
|
||||||
|
|
||||||
CMD [ "yarn", "dev" ]
|
CMD [ "yarn", "server" ]
|
||||||
@@ -10,6 +10,7 @@
|
|||||||
"clean": "node src/cli/clean.js"
|
"clean": "node src/cli/clean.js"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@linux-odyssey/file-graph": "*",
|
||||||
"commander": "^11.0.0",
|
"commander": "^11.0.0",
|
||||||
"dockerode": "^3.3.5",
|
"dockerode": "^3.3.5",
|
||||||
"express": "^4.18.2",
|
"express": "^4.18.2",
|
||||||
|
|||||||
@@ -2,12 +2,11 @@
|
|||||||
import Command from '../../models/command.js'
|
import Command from '../../models/command.js'
|
||||||
import Session from '../../models/session.js'
|
import Session from '../../models/session.js'
|
||||||
import { verifySessionJWT } from '../../utils/auth.js'
|
import { verifySessionJWT } from '../../utils/auth.js'
|
||||||
import commandHandler from '../../game/commandHandler.js'
|
import CommandHandler from '../../game/commandHandler.js'
|
||||||
|
|
||||||
export async function newCommand(req, res) {
|
export async function newCommand(req, res) {
|
||||||
console.log('new command:', req.body)
|
console.log('new command:', req.body)
|
||||||
const { token, command, pwd, output, error, ...additionalData } = req.body
|
const { token, command, pwd, output, error, ...additionalData } = req.body
|
||||||
console.log('additionalData:', additionalData)
|
|
||||||
|
|
||||||
if (!command) {
|
if (!command) {
|
||||||
res.status(400).json({ message: 'command is required' })
|
res.status(400).json({ message: 'command is required' })
|
||||||
@@ -36,7 +35,6 @@ export async function newCommand(req, res) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
session.lastActivityAt = new Date()
|
session.lastActivityAt = new Date()
|
||||||
await session.save()
|
|
||||||
|
|
||||||
const c = new Command({
|
const c = new Command({
|
||||||
session,
|
session,
|
||||||
@@ -48,8 +46,10 @@ export async function newCommand(req, res) {
|
|||||||
|
|
||||||
await c.save()
|
await c.save()
|
||||||
|
|
||||||
const response = await commandHandler(session, c, additionalData)
|
const commandHandler = new CommandHandler(session, c, additionalData)
|
||||||
console.log(response)
|
|
||||||
|
const response = await commandHandler.run()
|
||||||
|
|
||||||
res.status(201).json(response)
|
res.status(201).json(response)
|
||||||
|
await session.save()
|
||||||
}
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import Session from '../../models/session.js'
|
import Session from '../../models/session.js'
|
||||||
import Quest from '../../models/quest.js'
|
import Quest from '../../models/quest.js'
|
||||||
import { createContainer } from '../../containers/docker.js'
|
import { createContainer } from '../../containers/docker.js'
|
||||||
|
import SessionHandler from '../../game/sessionHandler.js'
|
||||||
|
|
||||||
export async function getSessionList(req, res) {
|
export async function getSessionList(req, res) {
|
||||||
try {
|
try {
|
||||||
@@ -45,20 +46,22 @@ export async function createSession(req, res) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const progress = quest.stages[0].id
|
|
||||||
const container = await createContainer(
|
const container = await createContainer(
|
||||||
`quest-${quest.id}-${req.user.username}-${Date.now()}`
|
`quest-${quest.id}-${req.user.username}-${Date.now()}`
|
||||||
)
|
)
|
||||||
const newSession = new Session({
|
const newSession = new Session({
|
||||||
user: req.user,
|
user: req.user,
|
||||||
quest,
|
quest,
|
||||||
progress,
|
|
||||||
containerId: container.id,
|
containerId: container.id,
|
||||||
})
|
})
|
||||||
|
|
||||||
const session = await newSession.save()
|
const session = new SessionHandler(newSession)
|
||||||
res.status(201).json(session)
|
session.addNewTasks()
|
||||||
|
|
||||||
|
await newSession.save()
|
||||||
|
res.status(201).json(newSession)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
console.error(err)
|
||||||
res.status(400).json({ message: err.message })
|
res.status(400).json({ message: err.message })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+74
-58
@@ -5,19 +5,85 @@ import { defaultUser, genSessionJWT } from '../utils/auth.js'
|
|||||||
|
|
||||||
const sessions = new Map()
|
const sessions = new Map()
|
||||||
|
|
||||||
function listenToSession(sessionId, event, callback) {
|
function listenToSession(sessionId, callback) {
|
||||||
const callbacks = sessions.get((sessionId, event)) || []
|
const callbacks = sessions.get(sessionId) || []
|
||||||
sessions.set((sessionId, event), callbacks.concat(callback))
|
sessions.set(sessionId, callbacks.concat(callback))
|
||||||
}
|
}
|
||||||
|
|
||||||
export function pushToSession(sessionId, event, data) {
|
function removeFromSession(sessionId, event, callback) {
|
||||||
console.log('Push:', sessionId, event)
|
const callbacks = sessions.get((sessionId, event)) || []
|
||||||
const callbacks = sessions.get((sessionId, event))
|
sessions.set(
|
||||||
|
(sessionId, event),
|
||||||
|
callbacks.filter((cb) => cb !== callback)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function pushToSession(sessionId, event, ...args) {
|
||||||
|
const callbacks = sessions.get(sessionId)
|
||||||
if (callbacks) {
|
if (callbacks) {
|
||||||
callbacks.forEach((callback) => callback(data))
|
callbacks.forEach((callback) => callback(event, ...args))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function connectClient(socket) {
|
||||||
|
console.log('Connected to the client.')
|
||||||
|
const sessionId = socket.handshake.query.session_id
|
||||||
|
if (!sessionId) {
|
||||||
|
socket.send('Session ID not found.')
|
||||||
|
socket.disconnect()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const session = await Session.findOne({
|
||||||
|
_id: sessionId,
|
||||||
|
user: socket.user,
|
||||||
|
status: 'active',
|
||||||
|
})
|
||||||
|
if (!session) {
|
||||||
|
socket.send('Session not found.')
|
||||||
|
socket.disconnect()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const token = await genSessionJWT(session)
|
||||||
|
|
||||||
|
let container
|
||||||
|
let stream
|
||||||
|
try {
|
||||||
|
container = await getAndStartContainer(session.containerId)
|
||||||
|
stream = await attachContainer(container, { token })
|
||||||
|
} catch (err) {
|
||||||
|
socket.send(err.message)
|
||||||
|
socket.disconnect()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
stream.socket.on('data', (chunk) => {
|
||||||
|
socket.emit('terminal', chunk.toString())
|
||||||
|
})
|
||||||
|
|
||||||
|
socket.on('message', console.log)
|
||||||
|
|
||||||
|
socket.on('terminal', function incoming(message) {
|
||||||
|
stream.socket.write(message)
|
||||||
|
})
|
||||||
|
|
||||||
|
const socketCallback = (event, ...data) => {
|
||||||
|
socket.emit(event, ...data)
|
||||||
|
}
|
||||||
|
|
||||||
|
listenToSession(session.id, socketCallback)
|
||||||
|
|
||||||
|
socket.on('disconnect', () => {
|
||||||
|
console.log('Disconnected from the client.')
|
||||||
|
stream.socket.write('exit\n')
|
||||||
|
removeFromSession(session.id, socketCallback)
|
||||||
|
stream.destroy()
|
||||||
|
})
|
||||||
|
|
||||||
|
socket.send(`${container.id}\n`)
|
||||||
|
}
|
||||||
|
|
||||||
export default (server) => {
|
export default (server) => {
|
||||||
const io = new Server(server)
|
const io = new Server(server)
|
||||||
|
|
||||||
@@ -34,55 +100,5 @@ export default (server) => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
io.on('connection', async (socket) => {
|
io.on('connection', connectClient)
|
||||||
console.log('Connected to the client.')
|
|
||||||
const sessionId = socket.handshake.query.session_id
|
|
||||||
if (!sessionId) {
|
|
||||||
socket.send('Session ID not found.')
|
|
||||||
socket.disconnect()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
let session
|
|
||||||
try {
|
|
||||||
session = await Session.findOne({
|
|
||||||
_id: sessionId,
|
|
||||||
user: socket.user,
|
|
||||||
status: 'active',
|
|
||||||
})
|
|
||||||
if (!session) {
|
|
||||||
socket.send('Session not found.')
|
|
||||||
socket.disconnect()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
socket.send(err.message)
|
|
||||||
socket.disconnect()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const token = await genSessionJWT(session)
|
|
||||||
|
|
||||||
const container = await getAndStartContainer(session.containerId)
|
|
||||||
const stream = await attachContainer(container, { token })
|
|
||||||
|
|
||||||
stream.on('data', (chunk) => {
|
|
||||||
socket.emit('terminal', chunk.toString())
|
|
||||||
})
|
|
||||||
|
|
||||||
socket.on('message', console.log)
|
|
||||||
|
|
||||||
socket.on('terminal', function incoming(message) {
|
|
||||||
stream.write(message)
|
|
||||||
})
|
|
||||||
|
|
||||||
socket.on('close', () => {
|
|
||||||
console.log('Disconnected from the client.')
|
|
||||||
})
|
|
||||||
|
|
||||||
listenToSession(session.id, 'graph', (data) => {
|
|
||||||
socket.emit('graph', data)
|
|
||||||
})
|
|
||||||
|
|
||||||
socket.send(`${container.id}\n`)
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
@@ -15,7 +15,7 @@ const containerOptions = {
|
|||||||
Binds:
|
Binds:
|
||||||
!config.isProduction && config.hostPwd
|
!config.isProduction && config.hostPwd
|
||||||
? [
|
? [
|
||||||
`${config.hostPwd}/quests/helloworld/home:/home/rudeus`,
|
`${config.hostPwd}/quests/helloworld/home:/home/commander`,
|
||||||
`${config.hostPwd}/packages/container:/usr/local/lib/container`,
|
`${config.hostPwd}/packages/container:/usr/local/lib/container`,
|
||||||
]
|
]
|
||||||
: [],
|
: [],
|
||||||
@@ -35,16 +35,11 @@ export async function createContainer(name) {
|
|||||||
|
|
||||||
export async function getAndStartContainer(id) {
|
export async function getAndStartContainer(id) {
|
||||||
console.log(`Getting container: ${id}`)
|
console.log(`Getting container: ${id}`)
|
||||||
let container
|
const container = engine.getContainer(id)
|
||||||
try {
|
if (!(await container.inspect()).State.Running) {
|
||||||
container = engine.getContainer(id)
|
await container.start()
|
||||||
if (!(await container.inspect()).State.Running) {
|
|
||||||
await container.start()
|
|
||||||
}
|
|
||||||
return container
|
|
||||||
} catch (error) {
|
|
||||||
throw new Error(`Container not found: ${id}`)
|
|
||||||
}
|
}
|
||||||
|
return container
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function attachContainer(container, { token }) {
|
export async function attachContainer(container, { token }) {
|
||||||
@@ -63,7 +58,7 @@ export async function attachContainer(container, { token }) {
|
|||||||
Tty: true,
|
Tty: true,
|
||||||
})
|
})
|
||||||
|
|
||||||
return execOutput.socket
|
return execOutput
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function deleteContainer(id) {
|
export async function deleteContainer(id) {
|
||||||
|
|||||||
@@ -1,58 +1,83 @@
|
|||||||
import minimist from 'minimist'
|
import minimist from 'minimist'
|
||||||
|
import { FileGraph } from '@linux-odyssey/file-graph'
|
||||||
import Quest from '../models/quest.js'
|
import Quest from '../models/quest.js'
|
||||||
import { pushToSession } from '../api/socket.js'
|
import { pushToSession } from '../api/socket.js'
|
||||||
|
import SessionHandler from './sessionHandler.js'
|
||||||
|
|
||||||
// 檢查 pattern 是否符合 input
|
// 檢查 pattern 是否符合 input
|
||||||
const checkMatch = (pattern, input) => {
|
const checkMatch = (pattern, input) => {
|
||||||
if (pattern.length === 0) return true
|
if (!pattern || pattern.length === 0) return true
|
||||||
return pattern.some((p) => {
|
return pattern.some((p) => {
|
||||||
const regex = new RegExp(p)
|
const regex = new RegExp(p)
|
||||||
return regex.test(input)
|
return regex.test(input)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
async function commandHandler(session, commandInput, additionalData) {
|
export default class CommandHandler extends SessionHandler {
|
||||||
const quest = await Quest.findById(session.quest)
|
constructor(session, commandInput, additionalData) {
|
||||||
const stage = quest.stages.find((s) => s.id === session.progress)
|
super(session)
|
||||||
if (!stage) {
|
this.commandInput = commandInput
|
||||||
console.error('stage not found', session.progress)
|
this.argv = minimist(this.commandInput.command.split(' '))
|
||||||
return {}
|
|
||||||
|
this.additionalData = additionalData
|
||||||
}
|
}
|
||||||
|
|
||||||
const argv = minimist(commandInput.command.split(' '))
|
handleEvent() {
|
||||||
|
if (this.additionalData.discover) this.discoverHandler()
|
||||||
if ('discover' in additionalData) {
|
|
||||||
pushToSession(session.id, 'graph', {
|
|
||||||
pwd: commandInput.pwd,
|
|
||||||
discover: additionalData.discover,
|
|
||||||
})
|
|
||||||
} else if (argv._[0] === 'cd') {
|
|
||||||
pushToSession(session.id, 'graph', { pwd: commandInput.pwd })
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const commandMatch = checkMatch(stage.condition.command, commandInput.command)
|
handleCommand() {
|
||||||
const outputMatch = checkMatch(stage.condition.output, commandInput.output)
|
const command = this.argv._[0]
|
||||||
const errorMatch = checkMatch(stage.condition.error, commandInput.output)
|
switch (command) {
|
||||||
|
case 'cd':
|
||||||
|
pushToSession(this.session.id, 'graph', {
|
||||||
|
pwd: this.commandInput.pwd,
|
||||||
|
})
|
||||||
|
break
|
||||||
|
|
||||||
if (!commandMatch || !outputMatch || !errorMatch) return {}
|
case 'ls':
|
||||||
|
pushToSession(this.session.id, 'graph', {
|
||||||
|
pwd: this.commandInput.pwd,
|
||||||
|
discover: this.additionalData.discover,
|
||||||
|
})
|
||||||
|
break
|
||||||
|
|
||||||
session.completion.push(session.progress)
|
default:
|
||||||
session.hints.push(...stage.hints)
|
break
|
||||||
|
}
|
||||||
session.progress = stage.next
|
|
||||||
await session.save()
|
|
||||||
|
|
||||||
if (stage.next === 'END') {
|
|
||||||
session.status = 'finished'
|
|
||||||
session.finishedAt = new Date()
|
|
||||||
await session.save()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
async discoverHandler() {
|
||||||
responses: stage.responses,
|
const graph = new FileGraph(this.session.graph)
|
||||||
hints: stage.hints,
|
graph.discover(this.additionalData.discover)
|
||||||
end: stage.next === 'END',
|
this.session.graph = graph
|
||||||
|
}
|
||||||
|
|
||||||
|
isMatch(stage) {
|
||||||
|
const keys = ['command', 'output', 'error', 'pwd']
|
||||||
|
return keys.every((k) =>
|
||||||
|
checkMatch(stage.condition[k], this.commandInput[k])
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
async run() {
|
||||||
|
this.quest = await Quest.findById(this.session.quest)
|
||||||
|
const stages = this.getStages()
|
||||||
|
if (stages.length === 0) {
|
||||||
|
console.error('stage not found', this.session.tasks)
|
||||||
|
return {}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.handleEvent()
|
||||||
|
this.handleCommand()
|
||||||
|
|
||||||
|
const response = stages
|
||||||
|
.filter((s) => this.isMatch(s))
|
||||||
|
.reduce((r, s) => ({ ...r, ...this.execute(s) }), {})
|
||||||
|
|
||||||
|
return {
|
||||||
|
end: this.session.status === 'finished',
|
||||||
|
...response,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export default commandHandler
|
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import { pushToSession } from '../api/socket.js'
|
||||||
|
|
||||||
|
export default class SessionHandler {
|
||||||
|
constructor(session) {
|
||||||
|
this.session = session
|
||||||
|
this.quest = session.quest
|
||||||
|
}
|
||||||
|
|
||||||
|
getTask(stage) {
|
||||||
|
return this.session.tasks.find((t) => t.id === stage.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
isCompleted(stage) {
|
||||||
|
const task = this.getTask(stage)
|
||||||
|
return task ? task.completed : false
|
||||||
|
}
|
||||||
|
|
||||||
|
getStages() {
|
||||||
|
return this.quest.stages.filter(
|
||||||
|
(stage) =>
|
||||||
|
stage.requirements.every((r) => this.isCompleted({ id: r })) &&
|
||||||
|
(stage.repeatable || !this.isCompleted(stage))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
getNewTasks() {
|
||||||
|
const stages = this.getStages()
|
||||||
|
return stages
|
||||||
|
.filter((s) => s.task)
|
||||||
|
.filter((s) => !this.session.tasks.some((t) => t.id === s.id))
|
||||||
|
.map((s) => ({ id: s.id, name: s.task, completed: false }))
|
||||||
|
}
|
||||||
|
|
||||||
|
addNewTasks() {
|
||||||
|
const newTasks = this.getNewTasks()
|
||||||
|
this.session.tasks.push(...newTasks)
|
||||||
|
pushToSession(this.session.id, 'tasks', this.session.tasks)
|
||||||
|
}
|
||||||
|
|
||||||
|
execute(stage) {
|
||||||
|
this.session.tasks
|
||||||
|
.filter((t) => t.id === stage.id)
|
||||||
|
.forEach((t) => {
|
||||||
|
t.completed = true
|
||||||
|
})
|
||||||
|
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
|
||||||
|
this.addNewTasks()
|
||||||
|
|
||||||
|
return {
|
||||||
|
responses: stage.responses,
|
||||||
|
hints: stage.hints,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+30
-16
@@ -15,19 +15,25 @@ const stageSchema = new Schema({
|
|||||||
type: String,
|
type: String,
|
||||||
required: true,
|
required: true,
|
||||||
},
|
},
|
||||||
|
task: {
|
||||||
|
type: String,
|
||||||
|
},
|
||||||
|
requirements: [
|
||||||
|
{
|
||||||
|
type: String,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
repeatable: {
|
||||||
|
type: Boolean,
|
||||||
|
required: true,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
condition: {
|
condition: {
|
||||||
command: {
|
command: [String],
|
||||||
type: [String],
|
output: [String],
|
||||||
required: false,
|
error: [String],
|
||||||
},
|
pwd: [String],
|
||||||
output: {
|
|
||||||
type: [String],
|
|
||||||
required: false,
|
|
||||||
},
|
|
||||||
error: {
|
|
||||||
type: [String],
|
|
||||||
required: false,
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
responses: {
|
responses: {
|
||||||
type: [responseSchema],
|
type: [responseSchema],
|
||||||
@@ -37,10 +43,6 @@ const stageSchema = new Schema({
|
|||||||
type: [String],
|
type: [String],
|
||||||
required: true,
|
required: true,
|
||||||
},
|
},
|
||||||
next: {
|
|
||||||
type: String,
|
|
||||||
required: true,
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const Quest = model(
|
const Quest = model(
|
||||||
@@ -50,6 +52,18 @@ const Quest = model(
|
|||||||
title: String,
|
title: String,
|
||||||
order: Number,
|
order: Number,
|
||||||
instruction: String,
|
instruction: String,
|
||||||
|
tasks: {
|
||||||
|
id: String,
|
||||||
|
content: String,
|
||||||
|
display: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
completed: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
stages: [stageSchema],
|
stages: [stageSchema],
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,4 +1,33 @@
|
|||||||
import { model, Schema } from 'mongoose'
|
import { model, Schema } from 'mongoose'
|
||||||
|
import { FileNode } from '@linux-odyssey/file-graph'
|
||||||
|
|
||||||
|
const nodeSchema = new Schema({
|
||||||
|
path: String,
|
||||||
|
type: String,
|
||||||
|
discovered: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
nodeSchema.add({
|
||||||
|
children: [nodeSchema],
|
||||||
|
})
|
||||||
|
|
||||||
|
const taskSchema = new Schema({
|
||||||
|
id: {
|
||||||
|
type: String,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
name: {
|
||||||
|
type: String,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
completed: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
const Session = model(
|
const Session = model(
|
||||||
'Session',
|
'Session',
|
||||||
@@ -25,9 +54,15 @@ const Session = model(
|
|||||||
default: Date.now,
|
default: Date.now,
|
||||||
required: true,
|
required: true,
|
||||||
},
|
},
|
||||||
progress: String,
|
tasks: [taskSchema],
|
||||||
completion: [String],
|
|
||||||
hints: [String],
|
hints: [String],
|
||||||
|
graph: {
|
||||||
|
type: nodeSchema,
|
||||||
|
default: new FileNode({
|
||||||
|
path: '/',
|
||||||
|
type: 'folder',
|
||||||
|
}),
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{ timestamps: true }
|
{ timestamps: true }
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ export function genSessionJWT(session) {
|
|||||||
jwt.sign(
|
jwt.sign(
|
||||||
{ session_id: session.id },
|
{ session_id: session.id },
|
||||||
config.jwtSecret,
|
config.jwtSecret,
|
||||||
{ expiresIn: '1h' },
|
{ expiresIn: config.expiry },
|
||||||
(err, token) => {
|
(err, token) => {
|
||||||
if (err) {
|
if (err) {
|
||||||
reject(err)
|
reject(err)
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import yaml from 'yaml'
|
|||||||
|
|
||||||
import Quest from '../models/quest.js'
|
import Quest from '../models/quest.js'
|
||||||
|
|
||||||
const questDirectory = path.join(process.cwd(), 'quests')
|
const questDirectory = path.join(process.cwd(), '..', 'quests')
|
||||||
|
|
||||||
export default async function loadAndUpdateQuests() {
|
export default async function loadAndUpdateQuests() {
|
||||||
const questNames = await fs.readdir(questDirectory)
|
const questNames = await fs.readdir(questDirectory)
|
||||||
|
|||||||
+1
-1
@@ -392,7 +392,7 @@ components:
|
|||||||
type: string
|
type: string
|
||||||
description: current stage id
|
description: current stage id
|
||||||
example: helloworld
|
example: helloworld
|
||||||
completion:
|
completed:
|
||||||
type: array
|
type: array
|
||||||
description: list of completed stage ids
|
description: list of completed stage ids
|
||||||
items:
|
items:
|
||||||
|
|||||||
@@ -1,56 +0,0 @@
|
|||||||
import jest from 'jest'
|
|
||||||
import * as sessionController from '../../src/api/controllers/sessionController'
|
|
||||||
import Session from '../../src/models/session'
|
|
||||||
|
|
||||||
jest.mock('../../src/models/session')
|
|
||||||
|
|
||||||
describe('Session Controller', () => {
|
|
||||||
let mockReq
|
|
||||||
let mockRes
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
mockReq = {
|
|
||||||
user: { id: 'userid' },
|
|
||||||
body: {},
|
|
||||||
params: {},
|
|
||||||
}
|
|
||||||
mockRes = {
|
|
||||||
status: jest.fn().mockReturnThis(),
|
|
||||||
json: jest.fn().mockReturnThis(),
|
|
||||||
end: jest.fn().mockReturnThis(),
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
test('getSessionList returns a list of sessions', async () => {
|
|
||||||
Session.find.mockResolvedValue(['session1', 'session2'])
|
|
||||||
await sessionController.getSessionList(mockReq, mockRes)
|
|
||||||
expect(mockRes.json).toHaveBeenCalledWith(['session1', 'session2'])
|
|
||||||
})
|
|
||||||
|
|
||||||
test('createSession creates a new session', async () => {
|
|
||||||
mockReq.body.exercise_id = 'exerciseid'
|
|
||||||
const mockSession = { user: 'userid', exercise: 'exerciseid' }
|
|
||||||
Session.mockReturnValue(mockSession)
|
|
||||||
mockSession.save = jest.fn().mockResolvedValue('newsess')
|
|
||||||
await sessionController.createSession(mockReq, mockRes)
|
|
||||||
expect(mockRes.status).toHaveBeenCalledWith(201)
|
|
||||||
expect(mockRes.json).toHaveBeenCalledWith('newsess')
|
|
||||||
})
|
|
||||||
|
|
||||||
test('getSessionById returns the correct session', async () => {
|
|
||||||
mockReq.params.session_id = 'sessionid'
|
|
||||||
const mockSession = { user: 'userid', _id: 'sessionid' }
|
|
||||||
Session.findOne.mockResolvedValue(mockSession)
|
|
||||||
await sessionController.getSessionById(mockReq, mockRes)
|
|
||||||
expect(mockRes.json).toHaveBeenCalledWith(mockSession)
|
|
||||||
})
|
|
||||||
|
|
||||||
test('deleteSessionById deletes a session', async () => {
|
|
||||||
mockReq.params.session_id = 'sessionid'
|
|
||||||
const mockSession = { user: 'userid', _id: 'sessionid' }
|
|
||||||
Session.findOneAndDelete.mockResolvedValue(mockSession)
|
|
||||||
await sessionController.deleteSessionById(mockReq, mockRes)
|
|
||||||
expect(mockRes.status).toHaveBeenCalledWith(204)
|
|
||||||
expect(mockRes.end).toHaveBeenCalled()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -1212,6 +1212,15 @@ axios@^1.4.0:
|
|||||||
form-data "^4.0.0"
|
form-data "^4.0.0"
|
||||||
proxy-from-env "^1.1.0"
|
proxy-from-env "^1.1.0"
|
||||||
|
|
||||||
|
axios@^1.5.0:
|
||||||
|
version "1.5.0"
|
||||||
|
resolved "https://registry.yarnpkg.com/axios/-/axios-1.5.0.tgz#f02e4af823e2e46a9768cfc74691fdd0517ea267"
|
||||||
|
integrity sha512-D4DdjDo5CY50Qms0qGQTTw6Q44jl7zRwY7bthds06pUGfChBCTcQs+N743eFWGEd6pRTMd6A+I87aWyFV5wiZQ==
|
||||||
|
dependencies:
|
||||||
|
follow-redirects "^1.15.0"
|
||||||
|
form-data "^4.0.0"
|
||||||
|
proxy-from-env "^1.1.0"
|
||||||
|
|
||||||
babel-jest@^29.6.1:
|
babel-jest@^29.6.1:
|
||||||
version "29.6.1"
|
version "29.6.1"
|
||||||
resolved "https://registry.yarnpkg.com/babel-jest/-/babel-jest-29.6.1.tgz#a7141ad1ed5ec50238f3cd36127636823111233a"
|
resolved "https://registry.yarnpkg.com/babel-jest/-/babel-jest-29.6.1.tgz#a7141ad1ed5ec50238f3cd36127636823111233a"
|
||||||
|
|||||||
Reference in new issue
Block a user