diff --git a/README.md b/README.md index 122d011..58467aa 100644 --- a/README.md +++ b/README.md @@ -48,8 +48,11 @@ docker compose -f docker-compose.build.yml push Source: `app/` ``` +# Connect to dev server (https://odyssey.wancat.cc) yarn app -# Open http://localhost:5173/ + +# Connect to localhost (http://localhost:3000) +yarn app:local ``` ## Swagger diff --git a/app/package.json b/app/package.json index de08c19..bd2350a 100644 --- a/app/package.json +++ b/app/package.json @@ -4,7 +4,8 @@ "version": "0.0.0", "type": "module", "scripts": { - "dev": "vite", + "dev": "API_TARGET=https://odyssey.wancat.cc vite", + "dev:local": "vite", "build": "vite build", "preview": "vite preview" }, diff --git a/app/src/App.vue b/app/src/App.vue index 58f4090..8bade64 100644 --- a/app/src/App.vue +++ b/app/src/App.vue @@ -19,6 +19,7 @@ onMounted(async () => { socket.on('disconnect', function close() { console.log('Disconnected from the server.') }) + socket.on('message', console.log) }) diff --git a/app/src/components/TerminalPart.vue b/app/src/components/TerminalPart.vue index 2b61bf5..bdedd4d 100644 --- a/app/src/components/TerminalPart.vue +++ b/app/src/components/TerminalPart.vue @@ -55,11 +55,11 @@ onMounted(() => { term.open(terminal.value) term.focus() resizeScreen() - socket.on('message', (message) => { + socket.on('terminal', (message) => { term.write(message) }) term.onData((key) => { - socket.send(key) + socket.emit('terminal', key) }) }) diff --git a/app/src/utils/api.js b/app/src/utils/api.js index 61b68da..f598681 100644 --- a/app/src/utils/api.js +++ b/app/src/utils/api.js @@ -1,7 +1,7 @@ import axios from 'axios' const api = axios.create({ - baseURL: 'https://odyssey.wancat.cc/api/v1', + baseURL: '/api', }) export default api diff --git a/app/src/utils/socket.js b/app/src/utils/socket.js index 96c66e3..69050ca 100644 --- a/app/src/utils/socket.js +++ b/app/src/utils/socket.js @@ -1,7 +1,5 @@ import io from 'socket.io-client' -const host = 'wss://odyssey.wancat.cc' - class SocketWrapper { constructor() { this.socket = null @@ -14,7 +12,7 @@ class SocketWrapper { this.socket.disconnect() } - this.socket = io(host, { + this.socket = io('', { query: { session_id: session._id, }, diff --git a/app/vite.config.js b/app/vite.config.js index cf94bdc..2683207 100644 --- a/app/vite.config.js +++ b/app/vite.config.js @@ -7,8 +7,13 @@ export default defineConfig({ server: { proxy: { '/api': { - // target: 'http://localhost:3000', // the server url - target: 'http://odyssey.wancat.cc ', + target: `${process.env.API_TARGET || 'http://localhost:3000'}/api/v1`, + changeOrigin: true, + rewrite: (path) => path.replace(/^\/api/, ''), + }, + '/socket.io': { + target: process.env.API_TARGET || 'ws://localhost:3000', + changeOrigin: true, ws: true, }, }, diff --git a/package.json b/package.json index a0fc4ba..6ecf712 100644 --- a/package.json +++ b/package.json @@ -12,8 +12,9 @@ ], "scripts": { "app": "yarn workspace app dev", + "app:local": "yarn workspace app dev:local", "server": "yarn workspace server dev", - "cli": "yarn workspace server cli", + "cli": "yarn workspace client start", "lint": "eslint --ext .js,.vue .", "format": "prettier --ignore-path .prettierignore --write app server" }, diff --git a/packages/cli/package.json b/packages/cli/package.json deleted file mode 100644 index d8c4598..0000000 --- a/packages/cli/package.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "name": "cli", - "version": "1.0.0", - "main": "index.js", - "license": "MIT", - "dependencies": {} -} diff --git a/server/src/client.js b/packages/client/index.js similarity index 87% rename from server/src/client.js rename to packages/client/index.js index 557b88d..5363631 100644 --- a/server/src/client.js +++ b/packages/client/index.js @@ -2,8 +2,14 @@ import { stdin, stdout, exit } from 'process' import { io } from 'socket.io-client' import { program } from 'commander' -import { get } from './utils/env.js' +function get(key, defaultValue) { + const value = process.env[key] + if (value != null) { + return value + } + return defaultValue +} stdin.setRawMode(true) stdin.resume() stdin.setEncoding('utf8') @@ -60,10 +66,17 @@ async function connect(sessionId) { console.log('Connected to the server.') }) - socket.on('message', function incoming(data) { + socket.on('message', console.log) + + socket.on('terminal', function incoming(data) { stdout.write(data) }) + socket.on('graph', (data) => { + console.log('receive graph:') + console.log(data) + }) + socket.on('close', function close() { console.log('Disconnected from the server.') exit() @@ -74,7 +87,7 @@ async function connect(sessionId) { socket.close() exit() } else { - socket.send(key) + socket.emit('terminal', key) } }) } diff --git a/packages/client/package.json b/packages/client/package.json new file mode 100644 index 0000000..968cf60 --- /dev/null +++ b/packages/client/package.json @@ -0,0 +1,14 @@ +{ + "name": "client", + "version": "1.0.0", + "main": "index.js", + "license": "MIT", + "type": "module", + "scripts": { + "start": "node index.js" + }, + "dependencies": { + "commander": "^11.0.0", + "socket.io-client": "^4.7.2" + } +} diff --git a/packages/container/cli.js b/packages/container/cli.js new file mode 100644 index 0000000..fac7eba --- /dev/null +++ b/packages/container/cli.js @@ -0,0 +1,81 @@ +/* eslint-disable no-restricted-syntax */ +const fs = require('fs').promises +const { exit } = require('process') +const minimist = require('minimist') +const { colorize, printResponses } = require('./print.js') +const discoverFiles = require('./discover.js') + +async function readOrNone(file) { + try { + return await fs.readFile(file, 'utf8') + } catch (err) { + return '' + } +} + +const commandListeners = { + ls: [discoverFiles], +} + +async function handleCommand(command) { + const argv = minimist(command.split(' ')) + const name = argv._[0] + const listeners = commandListeners[name] + if (!listeners) return {} + return listeners.reduce(async (result, listener) => { + const res = await listener(argv) + return { ...res, ...result } + }, {}) +} + +async function main() { + const { + API_ENDPOINT, + TOKEN, + PWD, + CMD_NAME, + CMD_OUTPUT_FILE, + CMD_ERROR_FILE, + CMD_EXIT_CODE, + } = process.env + if (!CMD_NAME) exit(0) + + const additionalData = await handleCommand(CMD_NAME) + + const output = await readOrNone(CMD_OUTPUT_FILE) + const error = await readOrNone(CMD_ERROR_FILE) + const payload = { + command: CMD_NAME, + output, + error, + exit_code: CMD_EXIT_CODE, + token: TOKEN, + pwd: PWD, + ...additionalData, + } + try { + const res = await fetch(`${API_ENDPOINT}/api/v1/commands`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(payload), + }) + const data = await res.json() + if (data.responses) { + await printResponses(data.responses, 60) + } + if (data.hints) { + for (const hint of data.hints) { + console.log(colorize(hint, 'yellow')) + } + } + if (data.end) { + console.log(colorize('Quest completed!', 'blue')) + } + } catch (err) { + console.error(err) + } +} + +main().then(exit) diff --git a/packages/container/discover.js b/packages/container/discover.js new file mode 100644 index 0000000..781a58d --- /dev/null +++ b/packages/container/discover.js @@ -0,0 +1,45 @@ +const fs = require('fs').promises +const path = require('path') + +async function collectFilesInfo(inputPath, level = 0, hiddenFiles = false) { + const stats = await fs.stat(inputPath) + const name = path.basename(inputPath) + const discovered = level <= 1 && (hiddenFiles || !name.startsWith('.')) + const files = [ + { + path: inputPath, + name, + type: stats.isDirectory() ? 'folder' : 'file', + discovered, + }, + ] + if (stats.isDirectory() && level < 2) { + try { + let entries = await fs.readdir(inputPath) + if (level > 0) { + entries = entries.slice(0, 5) + } + const result = await Promise.all( + entries.map((entry) => + collectFilesInfo(path.join(inputPath, entry), level + 1, hiddenFiles) + ) + ) + files.push(...result.flat()) + } catch (err) { + // ignore + } + } + return files +} + +async function discoverFiles(argv) { + const targetPath = argv._.length < 2 ? ['.'] : argv._.slice(1) + const result = await Promise.all( + targetPath + .map((p) => path.resolve(process.cwd(), p)) + .map((p) => collectFilesInfo(p, 0, argv.a || argv.all)) + ) + return { discover: result.flat() } +} + +module.exports = discoverFiles diff --git a/packages/container/package.json b/packages/container/package.json new file mode 100644 index 0000000..2c6bc8a --- /dev/null +++ b/packages/container/package.json @@ -0,0 +1,10 @@ +{ + "name": "container", + "version": "1.0.0", + "main": "index.js", + "license": "MIT", + "dependencies": { + "chokidar": "^3.5.3", + "minimist": "^1.2.8" + } +} diff --git a/packages/cli/cli.js b/packages/container/print.js similarity index 58% rename from packages/cli/cli.js rename to packages/container/print.js index 39d5ecd..76a77a5 100644 --- a/packages/cli/cli.js +++ b/packages/container/print.js @@ -1,7 +1,5 @@ -/* eslint-disable no-await-in-loop */ /* eslint-disable no-restricted-syntax */ -const fs = require('fs').promises -const { exit } = require('process') +/* eslint-disable no-await-in-loop */ const readline = require('readline') const RETURN_SYMBOL = '\u21B5' @@ -78,59 +76,7 @@ async function printResponses(responses, delay = 100) { rl.close() } -async function readOrNone(file) { - try { - return await fs.readFile(file, 'utf8') - } catch (err) { - return '' - } +module.exports = { + printResponses, + colorize, } - -async function main() { - const { - API_ENDPOINT, - TOKEN, - PWD, - CMD_NAME, - CMD_OUTPUT_FILE, - CMD_ERROR_FILE, - CMD_EXIT_CODE, - } = process.env - if (!CMD_NAME) exit(0) - - const output = await readOrNone(CMD_OUTPUT_FILE) - const error = await readOrNone(CMD_ERROR_FILE) - const payload = { - command: CMD_NAME, - output, - error, - exit_code: CMD_EXIT_CODE, - token: TOKEN, - pwd: PWD, - } - try { - const res = await fetch(`${API_ENDPOINT}/api/v1/commands`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify(payload), - }) - const data = await res.json() - if (data.responses) { - await printResponses(data.responses, 60) - } - if (data.hints) { - for (const hint of data.hints) { - console.log(colorize(hint, 'yellow')) - } - } - if (data.end) { - console.log(colorize('Quest completed!', 'blue')) - } - } catch (err) { - console.error(err) - } -} - -main().then(exit).catch(console.error) diff --git a/server/package.json b/server/package.json index 6a40895..9895bae 100644 --- a/server/package.json +++ b/server/package.json @@ -14,10 +14,9 @@ "dockerode": "^3.3.5", "express": "^4.18.2", "jsonwebtoken": "^9.0.1", - "marked": "^5.1.1", + "minimist": "^1.2.8", "mongoose": "^7.4.0", "socket.io": "^4.7.1", - "socket.io-client": "^4.7.1", "swagger-ui-express": "^5.0.0", "yaml": "^2.3.1" }, diff --git a/server/quests/helloworld/Dockerfile b/server/quests/helloworld/Dockerfile index 8a8942d..cd7b038 100644 --- a/server/quests/helloworld/Dockerfile +++ b/server/quests/helloworld/Dockerfile @@ -6,6 +6,16 @@ RUN apt-get update && apt-get install -y \ zsh \ jq +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 @@ -14,6 +24,4 @@ WORKDIR /home/rudeus COPY ./server/quests/helloworld/home /home/rudeus/ -COPY ./packages/cli/cli.js /usr/local/bin/cli.js - CMD [ "/bin/zsh" ] diff --git a/server/quests/helloworld/home/.zshrc b/server/quests/helloworld/home/.zshrc index d8275fd..92044b5 100644 --- a/server/quests/helloworld/home/.zshrc +++ b/server/quests/helloworld/home/.zshrc @@ -29,8 +29,14 @@ precmd() { export CMD_ERROR_FILE=/tmp/cmd.error # Now run your Node.js script with the result as an argument - node /usr/local/bin/cli.js + node /usr/local/lib/container/cli.js; rm /tmp/cmd.out /tmp/cmd.error 2>/dev/null - rm /tmp/cmd.out /tmp/cmd.error 2>/dev/null # cleanup + # Reset variables + unset CMD_START_TIME + unset CMD_END_TIME + unset CMD_EXIT_CODE + unset CMD_OUTPUT_FILE + unset CMD_ERROR_FILE + unset CMD_NAME } diff --git a/server/src/api/controllers/commandController.js b/server/src/api/controllers/commandController.js index add1214..c37121d 100644 --- a/server/src/api/controllers/commandController.js +++ b/server/src/api/controllers/commandController.js @@ -6,7 +6,8 @@ import commandHandler from '../../game/commandHandler.js' export async function newCommand(req, res) { console.log('new command:', req.body) - const { token, command, pwd, output, error } = req.body + const { token, command, pwd, output, error, ...additionalData } = req.body + console.log('additionalData:', additionalData) if (!command) { res.status(400).json({ message: 'command is required' }) @@ -39,7 +40,7 @@ export async function newCommand(req, res) { await c.save() - const response = await commandHandler(session, c) + const response = await commandHandler(session, c, additionalData) console.log(response) res.status(201).json(response) diff --git a/server/src/api/socket.js b/server/src/api/socket.js index d1f33e5..7e167ae 100644 --- a/server/src/api/socket.js +++ b/server/src/api/socket.js @@ -3,6 +3,21 @@ import { getOrCreateContainer, attachContainer } from '../utils/docker.js' import Session from '../models/session.js' import { defaultUser, genSessionJWT } from '../utils/auth.js' +const sessions = new Map() + +function listenToSession(sessionId, event, callback) { + const callbacks = sessions.get((sessionId, event)) || [] + sessions.set((sessionId, event), callbacks.concat(callback)) +} + +export function pushToSession(sessionId, event, data) { + console.log('Push:', sessionId, event) + const callbacks = sessions.get((sessionId, event)) + if (callbacks) { + callbacks.forEach((callback) => callback(data)) + } +} + export default (server) => { const io = new Server(server) @@ -50,10 +65,12 @@ export default (server) => { const stream = await attachContainer(container, { token }) stream.on('data', (chunk) => { - socket.send(chunk.toString()) + socket.emit('terminal', chunk.toString()) }) - socket.on('message', function incoming(message) { + socket.on('message', console.log) + + socket.on('terminal', function incoming(message) { stream.write(message) }) @@ -61,6 +78,10 @@ export default (server) => { console.log('Disconnected from the client.') }) + listenToSession(session.id, 'graph', (data) => { + socket.emit('graph', data) + }) + socket.send(`${container.id}\n`) }) } diff --git a/server/src/game/commandHandler.js b/server/src/game/commandHandler.js index 4dd0df5..b467b5c 100644 --- a/server/src/game/commandHandler.js +++ b/server/src/game/commandHandler.js @@ -1,4 +1,6 @@ +import minimist from 'minimist' import Quest from '../models/quest.js' +import { pushToSession } from '../api/socket.js' // 檢查 pattern 是否符合 input const checkMatch = (pattern, input) => { @@ -9,7 +11,7 @@ const checkMatch = (pattern, input) => { }) } -const commandHandler = async (session, commandInput) => { +async function commandHandler(session, commandInput, additionalData) { const quest = await Quest.findById(session.quest) const stage = quest.stages.find((s) => s.id === session.progress) if (!stage) { @@ -17,6 +19,17 @@ const commandHandler = async (session, commandInput) => { return {} } + const argv = minimist(commandInput.command.split(' ')) + + 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) const outputMatch = checkMatch(stage.condition.output, commandInput.output) const errorMatch = checkMatch(stage.condition.error, commandInput.output) diff --git a/server/src/utils/docker.js b/server/src/utils/docker.js index 730a6e1..af6c634 100644 --- a/server/src/utils/docker.js +++ b/server/src/utils/docker.js @@ -18,7 +18,7 @@ const containerOptions = { ? [] : [ `${projectPath}/server/quests/helloworld/home:/home/rudeus`, - `${projectPath}/packages/cli/cli.js:/usr/local/bin/cli.js`, + `${projectPath}/packages/container:/usr/local/lib/container`, ], }, } diff --git a/yarn.lock b/yarn.lock index 47e89d5..264332c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1811,14 +1811,14 @@ end-of-stream@^1.1.0, end-of-stream@^1.4.1: dependencies: once "^1.4.0" -engine.io-client@~6.5.1: - version "6.5.1" - resolved "https://registry.yarnpkg.com/engine.io-client/-/engine.io-client-6.5.1.tgz#1735fb8ae3bae5ae13115e18d2f484daf005dd9c" - integrity sha512-hE5wKXH8Ru4L19MbM1GgYV/2Qo54JSMh1rlJbfpa40bEWkCKNo3ol2eOtGmowcr+ysgbI7+SGL+by42Q3pt/Ng== +engine.io-client@~6.5.2: + version "6.5.2" + resolved "https://registry.yarnpkg.com/engine.io-client/-/engine.io-client-6.5.2.tgz#8709e22c291d4297ae80318d3c8baeae71f0e002" + integrity sha512-CQZqbrpEYnrpGqC07a9dJDz4gePZUgTPMU3NKJPSeQOyw27Tst4Pl3FemKoFGAlHzgZmKjoRmiJvbWfhCXUlIg== dependencies: "@socket.io/component-emitter" "~3.1.0" debug "~4.3.1" - engine.io-parser "~5.1.0" + engine.io-parser "~5.2.1" ws "~8.11.0" xmlhttprequest-ssl "~2.0.0" @@ -1827,6 +1827,11 @@ engine.io-parser@~5.1.0: resolved "https://registry.yarnpkg.com/engine.io-parser/-/engine.io-parser-5.1.0.tgz#d593d6372d7f79212df48f807b8cace1ea1cb1b8" integrity sha512-enySgNiK5tyZFynt3z7iqBR+Bto9EVVVvDFuTT0ioHCGbzirZVGDGiQjZzEp8hWl6hd5FSVytJGuScX1C1C35w== +engine.io-parser@~5.2.1: + version "5.2.1" + resolved "https://registry.yarnpkg.com/engine.io-parser/-/engine.io-parser-5.2.1.tgz#9f213c77512ff1a6cc0c7a86108a7ffceb16fcfb" + integrity sha512-9JktcM3u18nU9N2Lz3bWeBgxVgOKpw7yhRaoxQA3FUDZzzw+9WlA6p4G4u0RixNkg14fH7EfEc/RhpurtiROTQ== + engine.io@~6.5.0: version "6.5.1" resolved "https://registry.yarnpkg.com/engine.io/-/engine.io-6.5.1.tgz#59725f8593ccc891abb47f1efcdc52a089525a56" @@ -3432,11 +3437,6 @@ makeerror@1.0.12: dependencies: tmpl "1.0.5" -marked@^5.1.1: - version "5.1.1" - resolved "https://registry.yarnpkg.com/marked/-/marked-5.1.1.tgz#40b3963bb9da225314f746d5012ba7e34942f636" - integrity sha512-bTmmGdEINWmOMDjnPWDxGPQ4qkDLeYorpYbEtFOXzOruTwUE671q4Guiuchn4N8h/v6NGd7916kXsm3Iz4iUSg== - media-typer@0.3.0: version "0.3.0" resolved "https://registry.yarnpkg.com/media-typer/-/media-typer-0.3.0.tgz#8710d7af0aa626f8fffa1ce00168545263255748" @@ -3509,7 +3509,7 @@ minimatch@^3.0.4, minimatch@^3.0.5, minimatch@^3.1.1, minimatch@^3.1.2: dependencies: brace-expansion "^1.1.7" -minimist@^1.2.0, minimist@^1.2.6: +minimist@^1.2.0, minimist@^1.2.6, minimist@^1.2.8: version "1.2.8" resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.8.tgz#c1a464e7693302e082a075cee0c057741ac4772c" integrity sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA== @@ -4269,14 +4269,14 @@ socket.io-adapter@~2.5.2: dependencies: ws "~8.11.0" -socket.io-client@^4.7.1: - version "4.7.1" - resolved "https://registry.yarnpkg.com/socket.io-client/-/socket.io-client-4.7.1.tgz#48e5f703abe4fb0402182bcf9c06b7820fb3453b" - integrity sha512-Qk3Xj8ekbnzKu3faejo4wk2MzXA029XppiXtTF/PkbTg+fcwaTw1PlDrTrrrU4mKoYC4dvlApOnSeyLCKwek2w== +socket.io-client@^4.7.2: + version "4.7.2" + resolved "https://registry.yarnpkg.com/socket.io-client/-/socket.io-client-4.7.2.tgz#f2f13f68058bd4e40f94f2a1541f275157ff2c08" + integrity sha512-vtA0uD4ibrYD793SOIAwlo8cj6haOeMHrGvwPxJsxH7CeIksqJ+3Zc06RvWTIFgiSqx4A3sOnTXpfAEE2Zyz6w== dependencies: "@socket.io/component-emitter" "~3.1.0" debug "~4.3.2" - engine.io-client "~6.5.1" + engine.io-client "~6.5.2" socket.io-parser "~4.2.4" socket.io-parser@~4.2.4: