Merge branch 'main' into app-neil

This commit is contained in:
wancat committed 2023-09-10 20:12:43 +08:00
commit ac8e2bc73a
45 files changed
+800 -394

No files matched your search

+31 -3
View File
@@ -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:
-22
View File
@@ -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
+1
View File
@@ -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
View File
@@ -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
View File
@@ -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" ]
+1
View File
@@ -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",
+29
View File
@@ -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>
+26 -10
View File
@@ -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>
+15 -54
View File
@@ -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>
+26 -1
View File
@@ -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)
} }
} }
+6
View File
@@ -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>
+6
View File
@@ -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
View File
@@ -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:
+2
View File
@@ -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
View File
@@ -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',
+1
View File
@@ -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"
} }
+9
View File
@@ -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') {
+5 -2
View File
@@ -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)
} }
} }
+49
View File
@@ -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)
})
})
})
+4 -8
View File
@@ -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) {
+46 -16
View File
@@ -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) )
}) })
}) })
+23 -9
View File
@@ -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}`
} }
} }
+65
View File
@@ -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)
})
+3
View File
@@ -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'
+3 -4
View File
@@ -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)"
}
} }
+41
View File
@@ -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}
+2 -32
View File
@@ -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" ]
+38 -9
View File
@@ -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 -3
View File
@@ -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
View File
@@ -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
+4 -2
View File
@@ -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" ]
+1
View File
@@ -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
View File
@@ -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`)
})
} }
+6 -11
View File
@@ -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) {
+61 -36
View File
@@ -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
+62
View File
@@ -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
View File
@@ -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],
}) })
) )
+37 -2
View File
@@ -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 }
) )
+1 -1
View File
@@ -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)
+1 -1
View File
@@ -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
View File
@@ -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()
})
})
+9
View File
@@ -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"