Compare commits

..
Author SHA1 Message Date
Justin Lin 0e37b13d99 wip: not working yet... 2025-03-11 19:56:56 +08:00
Justin Lin b2a251576c fix: save stdout and stderr without tee 2025-02-22 14:07:15 +08:00
248 changed files with 4778 additions and 6096 deletions

No files matched your search

+1 -2
View File
@@ -1,5 +1,4 @@
**/node_modules **/node_modules
.git .git
**/dist **/dist
**/tsconfig.tsbuildinfo **/tsconfig.tsbuildinfo
data
+3 -3
View File
@@ -24,9 +24,6 @@ ADMIN_PORT=3001
# TRUSTED_PROXIES=1.2.3.4 # TRUSTED_PROXIES=1.2.3.4
# valid configurations see isIP & isIPRange in https://github.com/validatorjs/validator.js?tab=readme-ov-file#validators # valid configurations see isIP & isIPRange in https://github.com/validatorjs/validator.js?tab=readme-ov-file#validators
# RATE_LIMIT_MAX_FAILED_ATTEMPTS=5
# RATE_LIMIT_LOCKOUT_TIME=10
# GOOGLE_CLIENT_ID= # GOOGLE_CLIENT_ID=
# GOOGLE_CLIENT_SECRET= # GOOGLE_CLIENT_SECRET=
# GITHUB_CLIENT_ID= # GITHUB_CLIENT_ID=
@@ -34,6 +31,9 @@ ADMIN_PORT=3001
# SURVEY_URL= # SURVEY_URL=
# BUG_REPORT_URL= # BUG_REPORT_URL=
# SURVEY_URL=
# BUG_REPORT_URL=
# Docker related config # Docker related config
# DOCKER_NETWORK=host # DOCKER_NETWORK=host
# QUEST_IMAGE=linuxodyssey/quest-base # QUEST_IMAGE=linuxodyssey/quest-base
+4 -4
View File
@@ -1,6 +1,6 @@
HOST=localhost HOST=localhost
PORT=3000 PORT=3000
BASE_URL=http://localhost:8000 BASE_URL=http://localhost:5173
MONGO_URL=mongodb://localhost:27017/odyssey-test MONGO_URL=mongodb://localhost:27017/odyssey-test
SECRET_KEY=sample-secret SECRET_KEY=sample-secret
DOCKER_NETWORK=linux-odyssey-players-dev DOCKER_NETWORK=linux-odyssey-players-dev
@@ -12,9 +12,6 @@ DOCKER_NETWORK=linux-odyssey-players-dev
# TRUSTED_PROXIES=1.2.3.4 # TRUSTED_PROXIES=1.2.3.4
# valid configurations see isIP & isIPRange in https://github.com/validatorjs/validator.js?tab=readme-ov-file#validators # valid configurations see isIP & isIPRange in https://github.com/validatorjs/validator.js?tab=readme-ov-file#validators
# RATE_LIMIT_MAX_FAILED_ATTEMPTS=5
# RATE_LIMIT_LOCKOUT_TIME=10
# GOOGLE_CLIENT_ID= # GOOGLE_CLIENT_ID=
# GOOGLE_CLIENT_SECRET= # GOOGLE_CLIENT_SECRET=
# GITHUB_CLIENT_ID= # GITHUB_CLIENT_ID=
@@ -22,6 +19,9 @@ DOCKER_NETWORK=linux-odyssey-players-dev
# SURVEY_URL= # SURVEY_URL=
# BUG_REPORT_URL= # BUG_REPORT_URL=
# SURVEY_URL=
# BUG_REPORT_URL=
# Docker related config # Docker related config
# DOCKER_NETWORK=host # DOCKER_NETWORK=host
# QUEST_IMAGE=linuxodyssey/quest-base # QUEST_IMAGE=linuxodyssey/quest-base
+7 -33
View File
@@ -20,12 +20,9 @@ module.exports = {
settings: { settings: {
'import/resolver': { 'import/resolver': {
node: { node: {
extensions: ['.js', '.ts'], extensions: ['.js', '.jsx', '.ts', '.tsx'],
moduleDirectory: ['node_modules', 'packages', 'server/src'],
},
typescript: {
alwaysTryTypes: true,
}, },
typescript: {},
}, },
vue: { vue: {
version: '3.0', // Explicitly specify Vue 3 version: '3.0', // Explicitly specify Vue 3
@@ -52,7 +49,7 @@ module.exports = {
'@typescript-eslint/explicit-module-boundary-types': 'off', '@typescript-eslint/explicit-module-boundary-types': 'off',
'@typescript-eslint/no-unused-vars': [ '@typescript-eslint/no-unused-vars': [
'warn', 'error',
{ {
args: 'after-used', args: 'after-used',
argsIgnorePattern: '^_', argsIgnorePattern: '^_',
@@ -66,44 +63,21 @@ module.exports = {
// Allow missing file extensions // Allow missing file extensions
'import/extensions': [ 'import/extensions': [
'off', 'warn',
'ignorePackages', 'ignorePackages',
{ {
js: 'never', js: 'off',
jsx: 'never', jsx: 'never',
ts: 'never', ts: 'never',
tsx: 'never', tsx: 'never',
}, },
], ],
'no-unused-vars': 'warn',
'no-useless-constructor': 'off',
'@typescript-eslint/no-useless-constructor': 'error',
'no-empty-function': 'off',
'@typescript-eslint/no-empty-function': 'error',
'no-shadow': 'off',
'@typescript-eslint/no-shadow': 'error',
'no-restricted-imports': ['error', '../*'],
'import/no-relative-packages': 'off',
'no-await-in-loop': 'off',
}, },
overrides: [ overrides: [
{ {
files: ['**/tests/**/*.js', '**/tests/**/*.ts', '**/*.test.ts'], files: ['**/tests/**/*.js', '**/tests/**/*.ts', '**/*.test.ts'], // Adjust the pattern to match your test files
rules: { rules: {
'import/extensions': 'off', 'import/extensions': 'off', // Turn off the rule for test files
'import/no-extraneous-dependencies': 'off',
},
},
{
files: ['server/**/*.ts'],
rules: {
'import/no-extraneous-dependencies': 'off',
},
},
{
files: ['**/packages/**/*.ts'],
rules: {
'no-restricted-imports': 'off',
'import/no-extraneous-dependencies': 'off', 'import/no-extraneous-dependencies': 'off',
}, },
}, },
+3 -4
View File
@@ -18,9 +18,9 @@ on:
secrets: secrets:
DOCKERHUB_USERNAME: DOCKERHUB_USERNAME:
required: false required: true
DOCKERHUB_TOKEN: DOCKERHUB_TOKEN:
required: false required: true
jobs: jobs:
build-and-push: build-and-push:
@@ -42,7 +42,6 @@ jobs:
type=semver,pattern={{version}} type=semver,pattern={{version}}
- name: Login to Docker Hub - name: Login to Docker Hub
if: always()
uses: docker/login-action@v1 uses: docker/login-action@v1
with: with:
username: ${{ secrets.DOCKERHUB_USERNAME }} username: ${{ secrets.DOCKERHUB_USERNAME }}
@@ -54,5 +53,5 @@ jobs:
context: . context: .
file: ${{ inputs.dockerfilePath }} file: ${{ inputs.dockerfilePath }}
target: ${{ inputs.target }} target: ${{ inputs.target }}
push: ${{ secrets.DOCKERHUB_USERNAME != '' }} push: true
tags: ${{ steps.meta.outputs.tags }} tags: ${{ steps.meta.outputs.tags }}
+1 -1
View File
@@ -24,7 +24,7 @@ jobs:
uses: ./.github/workflows/build-docker-image.yml uses: ./.github/workflows/build-docker-image.yml
with: with:
imageName: 'linuxodyssey/analytics' imageName: 'linuxodyssey/analytics'
dockerfilePath: './services/analytics/Dockerfile' dockerfilePath: './packages/analytics/Dockerfile'
target: prod target: prod
secrets: inherit secrets: inherit
+9
View File
@@ -2,6 +2,15 @@ name: CI Checks
on: on:
workflow_call: workflow_call:
secrets:
GOOGLE_CLIENT_ID:
required: true
GOOGLE_CLIENT_SECRET:
required: true
GH_CLIENT_ID:
required: true
GH_CLIENT_SECRET:
required: true
jobs: jobs:
ci: ci:
+1 -2
View File
@@ -1,7 +1,6 @@
name: E2E Tests name: E2E Tests
on: on: [push, pull_request]
workflow_dispatch:
jobs: jobs:
e2e: e2e:
+1
View File
@@ -12,6 +12,7 @@ concurrency:
jobs: jobs:
ci-checks: ci-checks:
uses: ./.github/workflows/ci-checks.yml uses: ./.github/workflows/ci-checks.yml
secrets: inherit
build: build:
needs: ci-checks needs: ci-checks
+1
View File
@@ -10,6 +10,7 @@ concurrency:
jobs: jobs:
ci-checks: ci-checks:
uses: ./.github/workflows/ci-checks.yml uses: ./.github/workflows/ci-checks.yml
secrets: inherit
build: build:
uses: ./.github/workflows/build.yml uses: ./.github/workflows/build.yml
+1
View File
@@ -12,6 +12,7 @@ concurrency:
jobs: jobs:
ci-checks: ci-checks:
uses: ./.github/workflows/ci-checks.yml uses: ./.github/workflows/ci-checks.yml
secrets: inherit
build: build:
needs: ci-checks needs: ci-checks
+1 -6
View File
@@ -1,8 +1,4 @@
config config
.quest
tmp
services/terminal-service/terminal-service
bin
# Logs # Logs
logs logs
@@ -144,5 +140,4 @@ dist
.pnp.* .pnp.*
#screenshots #screenshots
tests/cypress/screenshots tests/cypress/screenshots
services/cmd-hook/cmd-hook
+1 -7
View File
@@ -1,11 +1,5 @@
{ {
"editor.tabSize": 2, "editor.tabSize": 2,
"editor.defaultFormatter": "esbenp.prettier-vscode", "editor.defaultFormatter": "esbenp.prettier-vscode",
"editor.formatOnSave": true, "editor.formatOnSave": true
"yaml.schemas": {
"packages/game/quest.json": "quests/**/game.yml"
},
"[go]": {
"editor.defaultFormatter": "golang.go"
}
} }
+15 -33
View File
@@ -3,13 +3,6 @@
[![Production](https://github.com/linux-odyssey/linux-odyssey/actions/workflows/production.yml/badge.svg)](https://github.com/linux-odyssey/linux-odyssey/actions/workflows/production.yml) [![Production](https://github.com/linux-odyssey/linux-odyssey/actions/workflows/production.yml/badge.svg)](https://github.com/linux-odyssey/linux-odyssey/actions/workflows/production.yml)
[![Staging](https://github.com/linux-odyssey/linux-odyssey/actions/workflows/staging.yml/badge.svg)](https://github.com/linux-odyssey/linux-odyssey/actions/workflows/staging.yml) [![Staging](https://github.com/linux-odyssey/linux-odyssey/actions/workflows/staging.yml/badge.svg)](https://github.com/linux-odyssey/linux-odyssey/actions/workflows/staging.yml)
## TLDR
yarn install
cp .env.sample .env
docker compose up -d
yarn dev
## Requirements ## Requirements
- NodeJS v20+ - NodeJS v20+
@@ -48,6 +41,10 @@ The configuration is stored in the `.env` file. You can copy from the sample fil
cp .env.sample .env cp .env.sample .env
The backend will use a SSH key pair to login to the player's container. The private key is on the host, and the public key is copied to the player's container.
The key pair is stored in `./config/ssh_key` and `./config/ssh_key.pub`. If it doesn't exist, the backend will generate a new pair and store them.
## Docker Setup ## Docker Setup
### Docker compose files explaination ### Docker compose files explaination
@@ -73,6 +70,7 @@ This will start the db. it will automatically restarts by Docker.
Building quest images: Building quest images:
docker compose build base docker compose build base
yarn build:quests
### Build Quest Images in Details ### Build Quest Images in Details
@@ -110,32 +108,31 @@ The base image is built with docker-compose, so you can use `docker compose buil
### Optional: Mounting Host Directory ### Optional: Mounting Host Directory
To update the home content of a quest, simply re-create a new session will do. The home content is mounted and copied on container creation. To improve the development experience, we can mount the host directory to the container in the pattern:
To further improve the development experience, we can mount the host directory to the container in the pattern:
/path/to/linux-odyssey/quests/${quest_id}/home:/home/commander /path/to/linux-odyssey/quests/${quest_id}/home:/home/commander
/path/to/linux-odyssey/packages/container:/usr/local/lib/container
You can edit the files on your host, and the changes will be reflected in the container. This is disabled by default. To enable it, you can set the following environment variables: You can edit the files on your host, and the changes will be reflected in the container. This is disabled by default. To enable it, you can set the following environment variables:
MOUNT_QUEST=true MOUNT_QUEST=true
### Optional: Mounting terminal service & cmd-hook
To make the development of terminal services & cmd-hook smoother, we can enable `MOUNT_CLI` to mount the local binaries into container.
MOUNT_CLI
And you can run `air` inside of `services/terminal-service` or `services/cmd-hook` to get auto recompiling.
## Development ## Development
Copy `.env.sample` to `.env` and customize it for your needs. Copy `.env.sample` to `.env` and customize it for your needs.
Build the whole project for the first time:
yarn build
This will build the typescript for each package and the server.
Run dev server (frontend, backend): Run dev server (frontend, backend):
yarn dev yarn dev
**IMPORTANT NOTE**: only the app and server have hot-reloading, other packages require manually running `yarn dev` in each package.
To develop analytics dashboard: To develop analytics dashboard:
yarn analytics yarn analytics
@@ -166,17 +163,6 @@ Run Cypress in container, create brand-new containers along with it:
## Deployment ## Deployment
Create production env
cp .env.prod.sample .env.prod
Edit `.env.prod`
BASE_URL=https://yourdomain.com # IMPORTANT to make login work
SECRET_KEY=xxxx # Change it to something safe!
Start the service
# Build the images (only allowed for maintainers) # Build the images (only allowed for maintainers)
docker compose -f docker-compose.prod.yml build docker compose -f docker-compose.prod.yml build
docker compose -f docker-compose.prod.yml push docker compose -f docker-compose.prod.yml push
@@ -194,7 +180,3 @@ You can copy from `.env.sample`
**YOU MUST CHANGE THE SECRET_KEY** in `.env` file. **YOU MUST CHANGE THE SECRET_KEY** in `.env` file.
SECRET_KEY=your-secret-key SECRET_KEY=your-secret-key
You can generate a random key by running
yarn setup
+18 -7
View File
@@ -1,20 +1,31 @@
# Base stage # Base stage
FROM node:20-alpine AS build FROM node:20-alpine as base
WORKDIR /app WORKDIR /app
COPY package.json yarn.lock ./ COPY package.json yarn.lock tsconfig.json ./
COPY packages/file-graph/package.json ./packages/file-graph/package.json
COPY packages/utils/package.json ./packages/utils/package.json
COPY packages/constants/package.json ./packages/constants/package.json
COPY packages/models/package.json ./packages/models/package.json
COPY app/package.json ./app/package.json COPY app/package.json ./app/package.json
COPY server/package.json ./server/package.json
RUN yarn install --frozen-lockfile RUN yarn install --frozen-lockfile
COPY tsconfig.json ./tsconfig.json # Dev stage
COPY server ./server FROM base as dev
COPY packages ./packages WORKDIR /app/app
CMD [ "yarn", "dev:docker", "--host", "0.0.0.0" ]
# Build stage
FROM base as build
COPY packages/file-graph ./packages/file-graph
COPY packages/utils ./packages/utils
COPY packages/constants ./packages/constants
COPY packages/models ./packages/models
COPY app ./app COPY app ./app
RUN yarn workspace app build RUN yarn workspace app build
# Production stage # Production stage
FROM nginx:stable-alpine AS prod FROM nginx:stable-alpine as prod
COPY --from=build /app/app/dist /usr/share/nginx/html COPY --from=build /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
+12 -11
View File
@@ -5,8 +5,8 @@
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
"dev:docker": "API_TARGET=http://backend:3000 vite", "dev:docker": "API_TARGET=http://backend:3000 vite",
"build": "vue-tsc && vite build", "build": "vue-tsc -b && vite build",
"type-check": "vue-tsc", "type-check": "vue-tsc -b",
"preview": "vite preview" "preview": "vite preview"
}, },
"dependencies": { "dependencies": {
@@ -16,13 +16,15 @@
"@fortawesome/free-regular-svg-icons": "^6.4.0", "@fortawesome/free-regular-svg-icons": "^6.4.0",
"@fortawesome/free-solid-svg-icons": "^6.4.0", "@fortawesome/free-solid-svg-icons": "^6.4.0",
"@fortawesome/vue-fontawesome": "^3.0.3", "@fortawesome/vue-fontawesome": "^3.0.3",
"@trpc/client": "^11.1.0", "@linux-odyssey/constants": "*",
"@linux-odyssey/file-graph": "*",
"@linux-odyssey/models": "*",
"@linux-odyssey/utils": "*",
"@types/dompurify": "^3.0.5", "@types/dompurify": "^3.0.5",
"axios": "^1.8.2", "axios": "^1.6.0",
"dompurify": "^3.2.4", "dompurify": "^3.0.6",
"marked": "^10.0.0", "marked": "^10.0.0",
"pinia": "^2.1.7", "pinia": "^2.1.7",
"reconnecting-websocket": "^4.4.0",
"socket.io-client": "^4.7.1", "socket.io-client": "^4.7.1",
"validator": "^13.11.0", "validator": "^13.11.0",
"vue": "^3.3.4", "vue": "^3.3.4",
@@ -35,14 +37,13 @@
}, },
"devDependencies": { "devDependencies": {
"@types/validator": "^13.11.9", "@types/validator": "^13.11.9",
"@vitejs/plugin-vue": "^5.2.2", "@vitejs/plugin-vue": "^4.5.2",
"@vue/compiler-sfc": "^3.3.13", "@vue/compiler-sfc": "^3.3.13",
"@vue/tsconfig": "^0.7.0",
"autoprefixer": "^10.4.14", "autoprefixer": "^10.4.14",
"postcss": "^8.4.31", "postcss": "^8.4.31",
"tailwindcss": "^3.3.3", "tailwindcss": "^3.3.3",
"typescript": "~5.7.2", "typescript": "^5.3.3",
"vite": "^6.3.1", "vite": "^4.5.3",
"vue-tsc": "^2.2.8" "vue-tsc": "^2.0.10"
} }
} }
+1 -1
View File
@@ -22,7 +22,7 @@ function nodeClass() {
<template> <template>
<a href="#" :class="nodeClass()"> <a href="#" :class="nodeClass()">
<font-awesome-icon <font-awesome-icon
v-if="node.type === 'directory'" v-if="node.type === 'folder'"
:icon="['far', 'folder']" :icon="['far', 'folder']"
class="text-text-primary size-8" class="text-text-primary size-8"
/> />
+4 -24
View File
@@ -31,31 +31,11 @@ const html = computed(() => {
.markdown-content :deep(ol) { .markdown-content :deep(ol) {
list-style: decimal; list-style: decimal;
padding-left: 1em; padding-left: 1em;
line-height: 1.2;
} }
.markdown-content :deep(ul) { .markdown-content :deep(code) {
list-style: disc; background-color: #282a33;
padding-left: 1em;
line-height: 1.2;
}
.markdown-content :deep(pre) {
background-color: #2e3440;
border-radius: 0.5rem;
padding: 0.5rem;
font-size: 0.9rem;
color: #d8dee9;
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.1);
border: 1px solid #4c566a;
line-height: 1.5;
font-family: 'monospace';
}
.markdown-content :deep(code:not(pre code)) {
background-color: #2e3440;
border-radius: 0.25rem; border-radius: 0.25rem;
padding: 0.2rem 0.4rem; padding: 0.25rem;
font-size: 0.9rem; font-size: 0.875rem;
color: #d8dee9;
font-family: 'monospace';
} }
</style> </style>
+2 -2
View File
@@ -2,8 +2,8 @@
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { useToast } from 'vue-toastification' import { useToast } from 'vue-toastification'
import { ref, defineProps, onMounted } from 'vue' import { ref, defineProps, onMounted } from 'vue'
import { getQuest } from '../utils/api'
import MarkdownText from './MarkdownText.vue' import MarkdownText from './MarkdownText.vue'
import { trpc } from '../utils/trpc'
const props = defineProps({ const props = defineProps({
questTitle: { questTitle: {
@@ -49,7 +49,7 @@ const handleColour = () => {
} }
} }
onMounted(async () => { onMounted(async () => {
const quest = await trpc.quests.getQuestDetail.query(questId) const quest = await getQuest(questId)
questInstruction.value = quest.instruction questInstruction.value = quest.instruction
handleColour() handleColour()
}) })
+6 -6
View File
@@ -1,8 +1,8 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, onMounted, watch, computed } from 'vue' import { ref, onMounted, watch, computed } from 'vue'
import { useToast } from 'vue-toastification' import { useToast } from 'vue-toastification'
import { DAG } from '../../../packages/utils' import { DAG } from '@linux-odyssey/utils'
import { trpc } from '../utils/trpc' import { getQuests } from '../utils/api'
import useUserProfile from '../store/userProfile' import useUserProfile from '../store/userProfile'
import QuestIntro from './QuestIntro.vue' import QuestIntro from './QuestIntro.vue'
@@ -64,19 +64,19 @@ async function computeGraphData() {
if (!store.progress) return if (!store.progress) return
try { try {
const quests = await trpc.quests.getQuests.query() const quests = await getQuests()
const dag = new DAG(quests) const dag = new DAG(quests)
const nodesValues = dag.getNodes() const nodesValues = dag.getNodes()
const nodes = nodesValues.map((node) => ({ const nodes = nodesValues.map((node) => ({
id: node.id, id: node._id,
title: node.title, title: node.title,
x: marginX * node.layer * 2, x: marginX * node.layer * 2,
y: y:
marginY * node.index - marginY * node.index -
(marginX * dag.getLayer(node.id)) / 2 + (marginX * dag.getLayer(node._id)) / 2 +
svgHeight.value / 2, svgHeight.value / 2,
completed: store.progress[node.id]?.completed || false, completed: store.progress[node._id]?.completed || false,
unlocked: node.requirements.every( unlocked: node.requirements.every(
(req: string) => store.progress[req]?.completed (req: string) => store.progress[req]?.completed
), ),
+1 -1
View File
@@ -1,7 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { useToast } from 'vue-toastification' import { useToast } from 'vue-toastification'
import { useSession } from '../../store/session' import useSession from '../../store/session'
import { openBugReport, openQuestSurvey } from '../../utils/formbricks' import { openBugReport, openQuestSurvey } from '../../utils/formbricks'
const router = useRouter() const router = useRouter()
+10 -28
View File
@@ -1,13 +1,12 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, ref, watch } from 'vue' import { computed, ref, watch } from 'vue'
import { useSession } from '../../store/session' import useSession from '../../store/session'
import MarkdownText from '../MarkdownText.vue' import MarkdownText from '../MarkdownText.vue'
const sessionStore = useSession() const sessionStore = useSession()
const current = ref(-1) const current = ref(-1)
const hintDetails = ref<HTMLDetailsElement | null>(null)
const length = computed(() => { const length = computed(() => {
return sessionStore.session?.responses?.length ?? 0 return sessionStore.session?.responses.length ?? 0
}) })
const disabled = computed(() => { const disabled = computed(() => {
@@ -15,7 +14,6 @@ const disabled = computed(() => {
}) })
watch(length, () => { watch(length, () => {
console.log('length update', length.value)
current.value = length.value - 1 current.value = length.value - 1
}) })
@@ -30,19 +28,6 @@ const right = () => {
current.value += 1 current.value += 1
} }
} }
const response = computed(() => {
if (!sessionStore.session) return null
if (current.value < 0) return null
if (current.value >= sessionStore.session.responses.length) return null
return sessionStore.session.responses[current.value]
})
watch(response, () => {
if (hintDetails.value) {
hintDetails.value.open = false
}
})
</script> </script>
<template> <template>
@@ -77,30 +62,27 @@ watch(response, () => {
@click="right" @click="right"
:disabled="disabled" :disabled="disabled"
> >
<font-awesome-icon <font-awesome-icon :icon="['fas', 'arrow-right']" class="text-text" />
:icon="['fas', 'arrow-right']"
:class="disabled ? 'text-gray-600' : 'text-text'"
/>
</button> </button>
</div> </div>
</div> </div>
<div id="hint" class="bg-bg p-8 overflow-y-auto"> <div id="hint" class="bg-bg p-8 overflow-y-auto">
<ul> <ul>
<li <li
v-if="response" v-for="response in sessionStore.session?.responses[current]"
:key="response.type" :key="response.type"
class="text-text font-xl whitespace-pre-wrap" class="text-text font-xl whitespace-pre-wrap"
> >
<MarkdownText :content="response.content" /> <div v-for="content in response.content" :key="content">
<MarkdownText :content="content" />
</div>
</li> </li>
<li <li
v-if="response?.hint" v-for="hint in sessionStore.session?.hints[current]"
:key="hint"
class="text-text-primary font-xl whitespace-pre-wrap" class="text-text-primary font-xl whitespace-pre-wrap"
> >
<details ref="hintDetails"> <MarkdownText :content="hint" />
<summary class="cursor-pointer">提示</summary>
<MarkdownText :content="response.hint" />
</details>
</li> </li>
</ul> </ul>
<br /> <br />
+3 -3
View File
@@ -1,12 +1,12 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue' import { computed } from 'vue'
import MarkdownText from '../MarkdownText.vue' import MarkdownText from '../MarkdownText.vue'
import { useSession } from '../../store/session' import useSession from '../../store/session'
import { Task } from '../../types'
const sessionStore = useSession() const sessionStore = useSession()
const tasks = computed(() => { const tasks = computed(() => {
console.log('session', sessionStore.session) return sessionStore.session?.tasks.map((task: Task) => {
return sessionStore.session?.tasks.map((task) => {
const prefix = task.completed ? '✓' : '➤' const prefix = task.completed ? '✓' : '➤'
const color = task.completed ? 'text-text-primary' : 'text-text' const color = task.completed ? 'text-text-primary' : 'text-text'
return { return {
+1 -1
View File
@@ -1,7 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { useToast } from 'vue-toastification' import { useToast } from 'vue-toastification'
import { computed } from 'vue' import { computed } from 'vue'
import { useSession } from '../../store/session' import useSession from '../../store/session'
const toast = useToast() const toast = useToast()
const sessionStore = useSession() const sessionStore = useSession()
+11 -32
View File
@@ -1,44 +1,23 @@
<script setup lang="ts"> <script setup lang="ts">
import { onMounted, onUnmounted, ref } from 'vue' import { onMounted, ref } from 'vue'
import { useSession, useTerminal } from '../../store/session' import { useTerminal } from '../../store/session'
const sessionStore = useSession()
const terminal = ref<HTMLElement | null>(null) const terminal = ref<HTMLElement | null>(null)
const term = useTerminal()
onMounted(() => { onMounted(() => {
const term = useTerminal()
if (!terminal.value) return if (!terminal.value) return
term.mount(terminal.value) term.mount(terminal.value)
window.addEventListener('resize', () => term.resizeScreen())
const handleResize = () => term.resizeScreen()
window.addEventListener('resize', handleResize)
onUnmounted(() => {
window.removeEventListener('resize', handleResize)
})
}) })
</script> </script>
<template> <template>
<div class="flex flex-col h-full"> <font-awesome-icon :icon="['fas', 'terminal']" class="text-text p-1" />
<div class="flex items-center gap-2 p-1"> <button id="Terminal" class="text-text h-[5%]">終端機</button>
<font-awesome-icon :icon="['fas', 'terminal']" class="text-text" /> <div
<button id="Terminal" class="text-text">終端機</button> id="terminal"
ref="terminal"
<a class="bg-bg-secondary h-[calc(100%-5%)] p-0.5"
class="text-sm font-normal text-gray-200 ml-10" ></div>
:href="`/terminal/${sessionStore.session?.containerName}?token=${sessionStore.session?.token}`"
target="_blank"
>
Open Terminal in New Tab
</a>
</div>
<div
id="terminal"
ref="terminal"
class="bg-bg-secondary flex-grow overflow-hidden px-2"
style="height: calc(100vh - 40px)"
></div>
</div>
</template> </template>
+1 -1
View File
@@ -1,6 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import FileNode from '../FileNode.vue' import FileNode from '../FileNode.vue'
import { useSession } from '../../store/session' import useSession from '../../store/session'
const sessionStore = useSession() const sessionStore = useSession()
</script> </script>
@@ -3,9 +3,9 @@ import { onMounted } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { useToast } from 'vue-toastification' import { useToast } from 'vue-toastification'
import { logout } from '../../utils/auth' import { logout } from '../../utils/auth'
import { trpc } from '../../utils/trpc' import { surveyUrl } from '../../config'
import { openBugReport } from '../../utils/formbricks' import { openBugReport } from '../../utils/formbricks'
import { useSession } from '../../store/session' import useSession from '../../store/session'
import useUserProfile from '../../store/userProfile' import useUserProfile from '../../store/userProfile'
const userStore = useUserProfile() const userStore = useUserProfile()
@@ -26,11 +26,6 @@ const handleLogout = async () => {
} }
} }
const openSurvey = async () => {
const surveyUrl = await trpc.links.surveyLink.query()
window.open(surveyUrl, '_blank')
}
onMounted(async () => { onMounted(async () => {
try { try {
await userStore.loadUserProfile() await userStore.loadUserProfile()
@@ -66,18 +61,18 @@ onMounted(async () => {
> >
{{ userStore.username }} {{ userStore.username }}
</p> </p>
<button <a
title="Survey" title="Survey"
:href="surveyUrl"
target="_blank" target="_blank"
class="md:h-5 md:w-5 px-1.5 w-auto" class="md:h-5 md:w-5 px-1.5 w-auto"
@click="openSurvey"
> >
<font-awesome-icon <font-awesome-icon
:icon="['fas', 'file-invoice']" :icon="['fas', 'file-invoice']"
class="text-text-primary inline" class="text-text-primary inline"
/> />
<span class="text-text sm:hidden px-2">Survey</span> <span class="text-text sm:hidden px-2">Survey</span>
</button> </a>
<RouterLink <RouterLink
title="LeaderBoard" title="LeaderBoard"
to="/leaderboard" to="/leaderboard"
+48 -71
View File
@@ -1,40 +1,22 @@
import { defineStore } from 'pinia' import { defineStore } from 'pinia'
import { import { FileGraph, FileObject } from '@linux-odyssey/file-graph'
FileGraph, import { IQuest } from '@linux-odyssey/models'
type FileGraphUpdateEvent, import { createSession, getActiveSession, getQuest } from '../utils/api'
} from '../../../packages/file-graph'
import type { IResponse, ITask } from '../../../packages/game'
import type { SessionDetail } from '../../../server/src/routers/sessionRouter'
import Socket from '../utils/socket' import Socket from '../utils/socket'
import SocketTerminal from '../utils/terminal' import SocketTerminal from '../utils/terminal'
import type { Session } from '../types' import { Session, StageResponse } from '../types'
import { trpc } from '../utils/trpc'
const socket = new Socket() const socket = new Socket()
const term = new SocketTerminal() const term = new SocketTerminal(40, 80)
let hasSetup = false let hasSetup = false
interface QuestDetailResponse {
id: string
title: string
instruction: string
requirements: string[]
}
interface SessionUpdate {
status: string
responses: IResponse[]
tasks: ITask[]
graphUpdate: FileGraphUpdateEvent
}
interface Store { interface Store {
session: Session | null session: Session | null
questId: string questId: string
quest: QuestDetailResponse | null quest: IQuest | null
} }
export const useSession = defineStore('session', { const useSession = defineStore('session', {
state: (): Store => ({ state: (): Store => ({
session: null, session: null,
questId: '', questId: '',
@@ -42,55 +24,52 @@ export const useSession = defineStore('session', {
}), }),
actions: { actions: {
async setQuest(questId: string) { async setQuest(questId: string) {
this.quest = await trpc.quests.getQuestDetail.query(questId) this.quest = await getQuest(questId)
this.questId = questId this.questId = questId
}, },
async setSession(session: SessionDetail) { async setSession(session: Session) {
this.session = { this.session = session
...session, this.session.graph = new FileGraph(session.graph)
graph: new FileGraph(session.graph),
pwd: '/home/commander',
containerName: session.containerName || '',
}
term.reset() term.reset()
await socket.connect(this.session) await socket.connect(session)
await term.connect(
`/terminal/${session.containerName}/ws?token=${session.token}`
)
term.focus() term.focus()
}, },
async createSession() { async createSession() {
const session = await trpc.session.createSession.mutate({ const session = await createSession(this.questId)
questId: this.questId,
})
await this.setSession(session) await this.setSession(session)
term.send('echo start\n') socket.emit('terminal', 'echo start\n')
}, },
async getActiveSession() { async getActiveSession() {
const session = await trpc.session.getActiveSession.query({ const session = await getActiveSession(this.questId)
questId: this.questId,
})
if (session) { if (session) {
this.setSession(session) this.setSession(session)
} }
}, },
newUpdate(update: SessionUpdate) { updateGraph(event: { discover?: FileObject[]; pwd?: string }) {
if (!this.session) return if (!this.session) return
this.session.responses = update.responses if (event.discover) {
this.session.tasks = update.tasks this.session.graph.discover(event.discover)
this.session.status = update.status }
// if ( if (event.pwd) {
// response.status === 'finished' && this.session.pwd = event.pwd
// this.session.status !== 'finished' }
// ) {
// this.finish()
// }
// this.session.status = response.status
}, },
finish() { newResponse(response: StageResponse) {
if (!this.session) return if (!this.session) return
this.session.status = 'finished' this.session.responses.push(response.responses)
this.session.hints.push(response.hints)
if (response.tasks) {
this.session.tasks = response.tasks
}
if (
response.status === 'finished' &&
this.session.status !== 'finished'
) {
this.finish()
}
this.session.status = response.status
}, },
finish() {},
reset() { reset() {
socket.reset() socket.reset()
term.reset() term.reset()
@@ -98,21 +77,17 @@ export const useSession = defineStore('session', {
}, },
setup() { setup() {
if (hasSetup) return if (hasSetup) return
// socket.on('terminal', (data: string) => { socket.on('terminal', (data: string) => {
// term.write(data) term.write(data)
// })
// term.onData((data: string) => {
// socket.emit('terminal', data)
// })
socket.on('graph', (event: FileGraphUpdateEvent) => {
if (!this.session) return
this.session.graph.handleEvent(event)
if (event.pwd) {
this.session.pwd = event.pwd
}
}) })
socket.on('update', (update: SessionUpdate) => { term.onData((data: string) => {
this.newUpdate(update) socket.emit('terminal', data)
})
socket.on('graph', (event: { discover: FileObject[]; pwd: string }) => {
this.updateGraph(event)
})
socket.on('response', (response: StageResponse) => {
this.newResponse(response)
}) })
hasSetup = true hasSetup = true
}, },
@@ -126,3 +101,5 @@ export const useSession = defineStore('session', {
export function useTerminal() { export function useTerminal() {
return term return term
} }
export default useSession
+2 -2
View File
@@ -1,5 +1,5 @@
import { defineStore } from 'pinia' import { defineStore } from 'pinia'
import { trpc } from '../utils/trpc' import { getUserProfile } from '../utils/api'
interface IProgress { interface IProgress {
quest: string quest: string
@@ -23,7 +23,7 @@ const useUserProfile = defineStore('userProfile', {
}), }),
actions: { actions: {
async loadUserProfile() { async loadUserProfile() {
const res = await trpc.users.getMyProfile.query() const res = await getUserProfile()
this.$patch(res) this.$patch(res)
}, },
async resetUserProfile() { async resetUserProfile() {
+6 -6
View File
@@ -1,5 +1,6 @@
import type { FileGraph } from '../../packages/file-graph' import { FileGraph } from '@linux-odyssey/file-graph'
import type { IResponse, ITask } from '../../packages/game' // eslint-disable-next-line import/no-relative-packages
import { IResponse } from '@linux-odyssey/models'
export interface Task { export interface Task {
id: number id: number
@@ -19,8 +20,7 @@ export interface Session {
status: string status: string
graph: FileGraph graph: FileGraph
pwd: string pwd: string
tasks: ITask[] hints: string[][]
responses: IResponse[] tasks: Task[]
token: string responses: IResponse[][]
containerName: string
} }
-4
View File
@@ -1,4 +0,0 @@
declare module '*.css' {
const content: string
export default content
}
+40
View File
@@ -1,4 +1,8 @@
import axios from 'axios' import axios from 'axios'
import { IQuest } from '@linux-odyssey/models'
import { GetSessionsRequest } from '@linux-odyssey/constants'
import { Session } from '../types'
import { UserProfile } from '../store/userProfile'
const api = axios.create({ const api = axios.create({
baseURL: '/api/v1', baseURL: '/api/v1',
@@ -8,3 +12,39 @@ const api = axios.create({
withCredentials: true, withCredentials: true,
}) })
export default api export default api
export async function getUserProfile(): Promise<UserProfile> {
const res = await api.get<UserProfile>('/users/me')
return res.data
}
export async function getQuest(id: string): Promise<IQuest> {
const res = await api.get<IQuest>(`/quests/${id}`)
return res.data
}
export async function createSession(questId: string): Promise<Session> {
const res = await api.post<Session>('/sessions', { questId })
return res.data
}
export async function getSessions(
request: GetSessionsRequest
): Promise<Session[]> {
const res = await api.get<Session[]>('/sessions', { params: request })
return res.data
}
export async function getActiveSession(
questId: string
): Promise<Session | null> {
const res = await api.get<Session | null>('/sessions/active', {
params: { questId },
})
return res.data
}
export async function getQuests(): Promise<IQuest[]> {
const res = await api.get<IQuest[]>('/quests')
return res.data
}
+1 -1
View File
@@ -1,5 +1,5 @@
import formbricks from '@formbricks/js/website' import formbricks from '@formbricks/js/website'
import { useSession } from '../store/session' import useSession from '../store/session'
import useUserProfile from '../store/userProfile' import useUserProfile from '../store/userProfile'
if (typeof window !== 'undefined') { if (typeof window !== 'undefined') {
+4 -2
View File
@@ -1,5 +1,5 @@
import { io, Socket } from 'socket.io-client' import { io, Socket } from 'socket.io-client'
import type { Session } from '../types' import { Session } from '../types'
type SocketCallback = (data: any) => void type SocketCallback = (data: any) => void
@@ -23,9 +23,11 @@ class SocketWrapper {
this.socket.on('connect', () => { this.socket.on('connect', () => {
console.log('Socket connected to session', session._id) console.log('Socket connected to session', session._id)
resolve()
}) })
this.socket.once('terminal', () => {
resolve()
})
this.socket.on('connect_error', reject) this.socket.on('connect_error', reject)
this.bindListeners() this.bindListeners()
}) })
+23 -59
View File
@@ -1,72 +1,47 @@
import { Terminal } from 'xterm' import { Terminal } from 'xterm'
import { FitAddon } from 'xterm-addon-fit' import { FitAddon } from 'xterm-addon-fit'
import ReconnectingWebSocket from 'reconnecting-websocket'
import 'xterm/css/xterm.css' import 'xterm/css/xterm.css'
class SocketTerminal { class SocketTerminal {
private term: Terminal private term: Terminal
private fitAddon: FitAddon private fitAddon: FitAddon
private socket: ReconnectingWebSocket | null = null
constructor() { constructor(rows: number, cols: number) {
this.term = new Terminal({ this.term = new Terminal({
convertEol: true, rows, // 行数
scrollback: 1000, cols, // 不指定行数,自动回车后光标从下一行开始
fontSize: 16, convertEol: true, // 启用时,光标将设置为下一行的开头
// scrollback: 50, //终端中的回滚量
disableStdin: false, // 是否应禁用输入
// cursorStyle: "underline", //光标样式
cursorBlink: true, // 光标闪烁
theme: { theme: {
foreground: '#ECECEC', foreground: '#ECECEC', // 字体
background: '#000000', background: '#000000', // 背景色
cursor: 'help', cursor: 'help', // 设置光标
}, },
fontSize: 18,
fontWeight: 'normal',
}) })
this.fitAddon = new FitAddon() this.fitAddon = new FitAddon()
this.term.loadAddon(this.fitAddon) this.term.loadAddon(this.fitAddon)
this.term.onData((data) => { this.fitAddon.fit()
if (this.socket) {
this.socket.send(data)
}
})
} }
connect(url: string): Promise<void> { write(message: string): void {
return new Promise((resolve, reject) => { this.term.write(message)
console.log('Connecting to terminal', url) }
this.term.write('\r\n\x1B[1;32mConnecting to terminal\x1B[0m\r\n')
this.socket = new ReconnectingWebSocket(url, [], {
connectionTimeout: 5000,
maxRetries: 10,
})
this.socket.onopen = () => {
console.log('Connected to terminal')
this.term.reset()
this.term.write('\r\n\x1B[1;32mConnected to terminal\x1B[0m\r\n')
resolve()
}
this.socket.onclose = () => { onData(callback: any): void {
console.log('Disconnected from terminal') this.term.onData(callback)
} }
this.socket.onerror = (error) => { focus(): void {
console.error('WebSocket error:', error) this.term.focus()
}
this.socket.onmessage = (event) => {
console.log('Received message from terminal')
const data =
typeof event.data === 'string'
? event.data
: new TextDecoder().decode(event.data)
this.term.write(data)
}
})
} }
resizeScreen(): void { resizeScreen(): void {
try { this.fitAddon.fit()
this.fitAddon.fit()
} catch (e) {
console.error('Failed to resize terminal:', e)
}
} }
mount(reference: HTMLElement): void { mount(reference: HTMLElement): void {
@@ -74,19 +49,8 @@ class SocketTerminal {
this.resizeScreen() this.resizeScreen()
} }
send(message: string): void {
if (this.socket) {
this.socket.send(message)
}
}
focus(): void {
this.term.focus()
}
reset(): void { reset(): void {
this.term.reset() this.term.reset()
this.socket?.close()
} }
} }
-16
View File
@@ -1,16 +0,0 @@
import { createTRPCClient, httpBatchLink } from '@trpc/client'
import type { AppRouter } from '../../../server/src/routers'
export const trpc = createTRPCClient<AppRouter>({
links: [
httpBatchLink({
url: '/trpc',
// You can pass any HTTP headers you wish here
// async headers() {
// return {
// authorization: getAuthCookie(),
// }
// },
}),
],
})
+1 -1
View File
@@ -1,5 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { isValidUsername } from '../../../packages/utils' import { isValidUsername } from '@linux-odyssey/utils'
import AuthForm from '../components/AuthForm.vue' import AuthForm from '../components/AuthForm.vue'
import Background from '../components/DynamicBackground.vue' import Background from '../components/DynamicBackground.vue'
import { TooManyRequestsError, ValidationError } from '../utils/errors' import { TooManyRequestsError, ValidationError } from '../utils/errors'
+1 -1
View File
@@ -1,6 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, onMounted, watch } from 'vue' import { computed, onMounted, watch } from 'vue'
import { useSession } from '../store/session' import useSession from '../store/session'
import useUserProfile from '../store/userProfile' import useUserProfile from '../store/userProfile'
import HeaderPart from '../components/header/HeaderPart.vue' import HeaderPart from '../components/header/HeaderPart.vue'
import GameHeaderComponents from '../components/header/GameHeaderComponents.vue' import GameHeaderComponents from '../components/header/GameHeaderComponents.vue'
+2 -2
View File
@@ -1,7 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import validator from 'validator' import validator from 'validator'
import { isValidUsername } from '../../../packages/utils' import { isValidUsername } from '@linux-odyssey/utils'
import { passwordPolicy } from '../../../packages/constants' import { passwordPolicy } from '@linux-odyssey/constants'
import AuthForm from '../components/AuthForm.vue' import AuthForm from '../components/AuthForm.vue'
import DynamicBackground from '../components/DynamicBackground.vue' import DynamicBackground from '../components/DynamicBackground.vue'
import HeaderPart from '../components/header/HeaderPart.vue' import HeaderPart from '../components/header/HeaderPart.vue'
-14
View File
@@ -1,14 +0,0 @@
{
"extends": "@vue/tsconfig/tsconfig.dom.json",
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"]
}
+26 -3
View File
@@ -1,7 +1,30 @@
{ {
"files": [], "compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"module": "ESNext",
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "preserve",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src/**/*.ts", "src/**/*.vue"],
"references": [ "references": [
{ "path": "./tsconfig.app.json" }, { "path": "../packages/utils" },
{ "path": "./tsconfig.node.json" } { "path": "../packages/constants" },
{ "path": "../packages/file-graph" },
{ "path": "../packages/models" }
] ]
} }
-24
View File
@@ -1,24 +0,0 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "ES2022",
"lib": ["ES2023"],
"module": "ESNext",
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["vite.config.ts"]
}
-16
View File
@@ -1,27 +1,11 @@
import { defineConfig } from 'vite' import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue' import vue from '@vitejs/plugin-vue'
import path from 'path'
// https://vitejs.dev/config/ // https://vitejs.dev/config/
export default defineConfig({ export default defineConfig({
plugins: [vue()], plugins: [vue()],
resolve: {
alias: {
'@game': path.resolve(__dirname, '../packages/game'),
'@models': path.resolve(__dirname, '../packages/models'),
'@utils': path.resolve(__dirname, '../packages/utils'),
'@constants': path.resolve(__dirname, '../packages/constants'),
'@file-graph': path.resolve(__dirname, '../packages/file-graph'),
},
},
server: { server: {
host: '0.0.0.0',
proxy: { proxy: {
'/trpc': {
target: process.env.API_TARGET || 'http://localhost:3000',
changeOrigin: false,
xfwd: true,
},
'/api': { '/api': {
target: process.env.API_TARGET || 'http://localhost:3000', target: process.env.API_TARGET || 'http://localhost:3000',
changeOrigin: false, changeOrigin: false,
+18 -1
View File
@@ -1,10 +1,27 @@
server { server {
listen 80; listen 80;
error_log /var/log/nginx/error.log debug;
location / { location / {
root /usr/share/nginx/html; root /usr/share/nginx/html;
index index.html; index index.html;
try_files $uri $uri/ /index.html; try_files $uri $uri/ /index.html;
} }
location /api/ {
proxy_pass http://backend:3000;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
location /api-docs/ {
proxy_pass http://backend:3000;
}
location /socket.io/ {
proxy_pass http://backend:3000;
proxy_http_version 1.1;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
}
} }
+11 -47
View File
@@ -1,23 +1,6 @@
services: services:
traefik:
image: traefik:v3.6.9
ports:
- 8000:8000
- 8080:8080
networks:
- players
- internal
command:
- '--api.insecure=true'
- '--providers.docker=true'
- '--providers.docker.exposedbydefault=false'
- '--entrypoints.web.address=:8000'
- '--entrypoints.web.forwardedHeaders.insecure=true' # trust all X-Forwarded headers
volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro
app: app:
image: linuxodyssey/frontend:${IMAGE_TAG:-latest} image: linuxodyssey/frontend
depends_on: depends_on:
- backend - backend
restart: always restart: always
@@ -26,16 +9,12 @@ services:
dockerfile: app/Dockerfile dockerfile: app/Dockerfile
target: prod target: prod
networks: networks:
- internal internal:
labels: ports:
- 'traefik.enable=true' - 8080:80
- 'traefik.http.routers.app.entrypoints=web'
- 'traefik.http.routers.app.rule=PathPrefix(`/`)'
- 'traefik.http.routers.app.service=app'
- 'traefik.http.services.app.loadbalancer.server.port=80'
backend: backend:
image: linuxodyssey/backend:${IMAGE_TAG:-latest} image: linuxodyssey/backend
depends_on: depends_on:
- db - db
restart: always restart: always
@@ -52,32 +31,17 @@ services:
- ./config:/app/config - ./config:/app/config
- ./quests:/app/quests:ro - ./quests:/app/quests:ro
env_file: env_file:
- .env.prod - .env
environment: environment:
- DOCKER_NETWORK=linux-odyssey-players - DOCKER_NETWORK=linux-odyssey-players
- BACKEND_URL=http://backend:3000 - BACKEND_URL=http://backend:3000
- HOST_PROJECT_ROOT=${PWD} - HOST_PROJECT_ROOT=${PWD}
labels:
- 'traefik.enable=true'
- 'traefik.http.routers.backend-api.entrypoints=web'
- 'traefik.http.routers.backend-api.rule=PathPrefix(`/api`)'
- 'traefik.http.routers.backend-api.service=backend'
- 'traefik.http.routers.backend-socket.entrypoints=web'
- 'traefik.http.routers.backend-socket.rule=PathPrefix(`/socket.io`)'
- 'traefik.http.routers.backend-socket.service=backend'
- 'traefik.http.routers.backend-trpc.entrypoints=web'
- 'traefik.http.routers.backend-trpc.rule=PathPrefix(`/trpc`)'
- 'traefik.http.routers.backend-trpc.service=backend'
- 'traefik.http.routers.backend-api-docs.entrypoints=web'
- 'traefik.http.routers.backend-api-docs.rule=PathPrefix(`/api-docs`)'
- 'traefik.http.routers.backend-api-docs.service=backend'
- 'traefik.http.services.backend.loadbalancer.server.port=3000'
analytics: analytics:
image: linuxodyssey/analytics:${IMAGE_TAG:-latest} image: linuxodyssey/analytics
build: build:
context: . context: .
dockerfile: services/analytics/Dockerfile dockerfile: packages/analytics/Dockerfile
target: prod target: prod
restart: always restart: always
depends_on: depends_on:
@@ -87,7 +51,7 @@ services:
ports: ports:
- '3001:3001' - '3001:3001'
env_file: env_file:
- .env.prod - .env
db: db:
image: mongo:6 image: mongo:6
@@ -100,10 +64,10 @@ services:
- mongo:/data/db - mongo:/data/db
- ./mongo-init.js:/docker-entrypoint-initdb.d/mongo-init.js:ro - ./mongo-init.js:/docker-entrypoint-initdb.d/mongo-init.js:ro
env_file: env_file:
- .env.prod - .env
base: base:
image: linuxodyssey/quest-base:${IMAGE_TAG:-latest} image: linuxodyssey/quest-base
build: build:
context: . context: .
dockerfile: ./quests/Dockerfile dockerfile: ./quests/Dockerfile
-27
View File
@@ -1,25 +1,5 @@
version: '3' version: '3'
services: services:
traefik:
image: traefik:v3.6.9
networks:
- players-dev
command:
- '--api.insecure=true'
- '--providers.docker=true'
- '--providers.docker.exposedbydefault=false'
- '--providers.file.directory=/etc/traefik/conf'
- '--providers.file.watch=true'
- '--entrypoints.web.address=:8000'
ports:
- '8000:8000'
- '8080:8080'
extra_hosts:
- 'host.docker.internal:host-gateway'
volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro
- ./traefik/conf:/etc/traefik/conf
db: db:
image: mongo:6 image: mongo:6
restart: always restart: always
@@ -39,13 +19,6 @@ services:
dockerfile: ./quests/Dockerfile dockerfile: ./quests/Dockerfile
extra_hosts: extra_hosts:
- 'host.docker.internal:host-gateway' - 'host.docker.internal:host-gateway'
labels:
- 'traefik.enable=true'
- 'traefik.http.routers.base.rule=PathPrefix(`/terminal/base`)'
- 'traefik.http.middlewares.base-stripprefix.stripprefix.prefixes=/terminal/base'
- 'traefik.http.routers.base.middlewares=base-stripprefix'
- 'traefik.http.routers.base.entrypoints=web'
- 'traefik.http.services.base.loadbalancer.server.port=9090'
networks: networks:
internal: internal:
+10 -11
View File
@@ -9,7 +9,6 @@
"app", "app",
"server", "server",
"packages/*", "packages/*",
"services/*",
"tests" "tests"
], ],
"scripts": { "scripts": {
@@ -17,6 +16,7 @@
"build": "yarn workspace server build && yarn workspace app build && yarn workspace analytics build", "build": "yarn workspace server build && yarn workspace app build && yarn workspace analytics build",
"dev": "concurrently -n \"App,Server\" -c \"bgBlue.bold,bgGreen.bold\" \"yarn workspace app dev\" \"yarn workspace server dev\"", "dev": "concurrently -n \"App,Server\" -c \"bgBlue.bold,bgGreen.bold\" \"yarn workspace app dev\" \"yarn workspace server dev\"",
"server": "yarn workspace server dev", "server": "yarn workspace server dev",
"cli": "yarn workspace client start",
"lint": "eslint .", "lint": "eslint .",
"format": "prettier --ignore-path .prettierignore --write app server", "format": "prettier --ignore-path .prettierignore --write app server",
"test": "yarn node --experimental-vm-modules --no-warnings $(yarn bin jest)", "test": "yarn node --experimental-vm-modules --no-warnings $(yarn bin jest)",
@@ -34,29 +34,28 @@
"@jest/globals": "^29.7.0", "@jest/globals": "^29.7.0",
"@types/jest": "^29.5.12", "@types/jest": "^29.5.12",
"@types/node": "^20.10.4", "@types/node": "^20.10.4",
"@typescript-eslint/eslint-plugin": "^6.21.0", "@typescript-eslint/eslint-plugin": "^6.14.0",
"@typescript-eslint/parser": "^6.21.0", "@typescript-eslint/parser": "^6.14.0",
"@vue/eslint-config-prettier": "^7.1.0", "@vue/eslint-config-prettier": "^7.1.0",
"babel-jest": "^29.7.0", "babel-jest": "^29.7.0",
"concurrently": "^8.2.2", "concurrently": "^8.2.2",
"eslint": "^8.56.0", "eslint": "^8.44.0",
"eslint-config-airbnb-base": "^15.0.0", "eslint-config-airbnb-base": "^15.0.0",
"eslint-config-prettier": "^9.1.0", "eslint-config-prettier": "^8.8.0",
"eslint-import-resolver-typescript": "^3.6.1", "eslint-import-resolver-typescript": "^3.6.1",
"eslint-plugin-cypress": "^2.15.1", "eslint-plugin-cypress": "^2.15.1",
"eslint-plugin-import": "^2.29.0", "eslint-plugin-import": "^2.29.0",
"eslint-plugin-prettier": "^5.1.3", "eslint-plugin-node": "^11.1.0",
"eslint-plugin-prettier": "^5.0.0",
"eslint-plugin-vue": "^9.15.1", "eslint-plugin-vue": "^9.15.1",
"globals": "^16.0.0",
"jest": "^29.7.0", "jest": "^29.7.0",
"prettier": "^3.0.0", "prettier": "^3.0.0",
"ts-node": "^10.9.2", "ts-node": "^10.9.2",
"typescript": "^5.3.3", "typescript": "^5.3.3"
"vue-eslint-parser": "^9.4.2",
"zod-to-json-schema": "^3.24.5"
}, },
"dependencies": { "dependencies": {
"latest": "^0.2.0" "latest": "^0.2.0",
"typescript-eslint": "^7.4.0"
}, },
"packageManager": "yarn@1.22.22+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e" "packageManager": "yarn@1.22.22+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e"
} }
File renamed without changes.
+19
View File
@@ -0,0 +1,19 @@
FROM node:20-alpine as build
WORKDIR /app
COPY ./package.json yarn.lock tsconfig.json ./
COPY packages/analytics/package.json ./packages/analytics/package.json
COPY packages/models/package.json ./packages/models/package.json
RUN yarn install --frozen-lockfile
ENV HOST=0.0.0.0
ENV PORT=3001
EXPOSE 3001
# Production stage
FROM build as prod
COPY packages/analytics ./packages/analytics
COPY packages/models ./packages/models
ENV NODE_ENV=production
WORKDIR /app/packages/analytics
RUN yarn build
CMD [ "yarn", "start" ]
File renamed without changes.
+6
View File
@@ -0,0 +1,6 @@
{
"watch": ["src", "views"],
"ext": "ts,handlebars,js",
"ignore": ["src/**/*.spec.ts"],
"exec": "node --loader ts-node/esm src/index.ts"
}
@@ -3,27 +3,30 @@
"version": "1.0.0", "version": "1.0.0",
"main": "src/index.js", "main": "src/index.js",
"private": true, "private": true,
"type": "module",
"license": "GPL", "license": "GPL",
"scripts": { "scripts": {
"start": "node dist/services/analytics/src/index.js", "start": "node dist/index.js",
"build": "tsc", "build": "tsc -b -f",
"type-check": "tsc --noEmit", "watch": "tsc -b --watch",
"dev": "tsx watch src/index.ts" "dev": "nodemon"
}, },
"dependencies": { "dependencies": {
"@linux-odyssey/models": "*",
"dotenv": "^16.4.5", "dotenv": "^16.4.5",
"express": "^4.20.0", "express": "^4.19.2",
"express-basic-auth": "^1.2.1", "express-basic-auth": "^1.2.1",
"express-handlebars": "^7.1.2", "express-handlebars": "^7.1.2",
"mongoose": "^8.9.5" "mongoose": "^8.0.3"
}, },
"devDependencies": { "devDependencies": {
"@swc/core": "^1.4.2", "@swc/core": "^1.4.2",
"@swc/helpers": "^0.5.6", "@swc/helpers": "^0.5.6",
"@types/express": "^4.17.21", "@types/express": "^4.17.21",
"@types/node": "^20.11.20", "@types/node": "^20.11.20",
"nodemon": "^3.1.0",
"regenerator-runtime": "^0.14.1", "regenerator-runtime": "^0.14.1",
"tsx": "^4.19.3", "ts-node": "^10.9.2",
"typescript": "^5.3.3" "typescript": "^5.3.3"
} }
} }
File renamed without changes.

Before

Width:  |  Height:  |  Size: 630 KiB

After

Width:  |  Height:  |  Size: 630 KiB

File renamed without changes.
View File
Whitespace-only changes.
@@ -1,6 +1,6 @@
import type { Request, Response } from 'express' import type { Request, Response } from 'express'
import Pagination from '../models/pagination' import Pagination from '../models/pagination.js'
import { errorCommands } from '../models/commands' import { errorCommands } from '../models/commands.js'
export async function commandListController( export async function commandListController(
req: Request, req: Request,
@@ -1,6 +1,6 @@
import { Request, Response } from 'express' import { Request, Response } from 'express'
import { userCount } from '../models/users' import { userCount } from '../models/users.js'
import { questList } from '../models/quests' import { questList } from '../models/quests.js'
export async function homeController(req: Request, res: Response) { export async function homeController(req: Request, res: Response) {
const quests = await questList() const quests = await questList()
@@ -1,5 +1,5 @@
import { Request, Response } from 'express' import { Request, Response } from 'express'
import { questList } from '../models/quests' import { questList } from '../models/quests.js'
export async function questListController(req: Request, res: Response) { export async function questListController(req: Request, res: Response) {
const quests = await questList() const quests = await questList()
@@ -1,6 +1,6 @@
import { Request, Response } from 'express' import { Request, Response } from 'express'
import Pagination from '../models/pagination' import Pagination from '../models/pagination.js'
import { sessionList, sessionDetail } from '../models/sessions' import { sessionList, sessionDetail } from '../models/sessions.js'
export async function sessionListController(req: Request, res: Response) { export async function sessionListController(req: Request, res: Response) {
const { nextKey, order } = req.query as { nextKey: string; order: string } const { nextKey, order } = req.query as { nextKey: string; order: string }
@@ -1,7 +1,7 @@
import { Request, Response } from 'express' import { Request, Response } from 'express'
import mongoose from 'mongoose' import mongoose from 'mongoose'
import Pagination from '../models/pagination' import Pagination from '../models/pagination.js'
import { userList, userDetail, idToUser } from '../models/users' import { userList, userDetail, idToUser } from '../models/users.js'
export async function userListController(req: Request, res: Response) { export async function userListController(req: Request, res: Response) {
const { nextKey, order } = req.query as { nextKey: string; order: string } const { nextKey, order } = req.query as { nextKey: string; order: string }
@@ -1,9 +1,9 @@
import connectDB from '@linux-odyssey/models'
import express from 'express' import express from 'express'
import { engine } from 'express-handlebars' import { engine } from 'express-handlebars'
import basicAuth from 'express-basic-auth' import basicAuth from 'express-basic-auth'
import { connectDB } from '../../../packages/models' import config from './config.js'
import config from './config' import router from './router.js'
import router from './router'
async function main() { async function main() {
await connectDB(config.db) await connectDB(config.db)
File renamed without changes.
@@ -1,5 +1,5 @@
import { Command } from '../../../../packages/models' import { Command } from '@linux-odyssey/models'
import Pagination from './pagination' import Pagination from './pagination.js'
// eslint-disable-next-line import/prefer-default-export // eslint-disable-next-line import/prefer-default-export
export async function errorCommands(pagination: Pagination) { export async function errorCommands(pagination: Pagination) {
@@ -1,5 +1,5 @@
/* eslint-disable class-methods-use-this */ /* eslint-disable class-methods-use-this */
/* eslint-disable max-classes-per-file */ // eslint-disable-next-line max-classes-per-file
import mongoose, { Types } from 'mongoose' import mongoose, { Types } from 'mongoose'
export const DESC: string = 'desc' export const DESC: string = 'desc'
@@ -1,14 +1,8 @@
import { UserProfile } from '../../../../packages/models' /* eslint-disable object-shorthand */
/* eslint-disable func-names */
import { UserProfile } from '@linux-odyssey/models'
export interface QuestProgress { export function questList() {
_id: string
started: number
completed: number
completedRate: number
failed: number
}
export function questList(): Promise<QuestProgress[]> {
return UserProfile.aggregate([ return UserProfile.aggregate([
{ {
$project: { $project: {
@@ -1,5 +1,5 @@
import { Command, Session } from '../../../../packages/models' import { Command, Session } from '@linux-odyssey/models'
import Pagination from './pagination' import Pagination from './pagination.js'
import { import {
CommandObject, CommandObject,
SessionDetail, SessionDetail,
@@ -1,7 +1,7 @@
import { HydratedDocument, Types } from 'mongoose' import { Session, User } from '@linux-odyssey/models'
import { Session, User, IUser, ISession } from '../../../../packages/models' import { Types } from 'mongoose'
import Pagination from './pagination' import Pagination from './pagination.js'
import { UserSessionDetail } from '../interface' import { IUser, UserSessionDetail } from '../interface.js'
function loginMethods(user: IUser): string[] { function loginMethods(user: IUser): string[] {
const methods: string[] = [] const methods: string[] = []
@@ -23,11 +23,7 @@ export function userCount() {
} }
export async function userList(pagination: Pagination) { export async function userList(pagination: Pagination) {
const users: { const users = await Session.aggregate([
userData: HydratedDocument<IUser>
count: number
lastActivityAt: Date
}[] = await Session.aggregate([
pagination.match('user'), pagination.match('user'),
{ {
$group: { $group: {
@@ -66,7 +62,7 @@ export async function userList(pagination: Pagination) {
export async function userDetail( export async function userDetail(
id: Types.ObjectId id: Types.ObjectId
): Promise<UserSessionDetail[]> { ): Promise<UserSessionDetail[]> {
const sessions: HydratedDocument<ISession>[] = await Session.find({ const sessions = await Session.find({
user: id, user: id,
}) })
return sessions.map((session): UserSessionDetail => { return sessions.map((session): UserSessionDetail => {
@@ -1,13 +1,16 @@
import { Router } from 'express' import { Router } from 'express'
import { homeController } from './controllers/index' import { homeController } from './controllers/index.js'
import { userListController, userDetailController } from './controllers/users' import {
import { questListController } from './controllers/quests' userListController,
import { commandListController } from './controllers/commands' userDetailController,
} from './controllers/users.js'
import { questListController } from './controllers/quests.js'
import { commandListController } from './controllers/commands.js'
import { import {
sessionDetailController, sessionDetailController,
sessionListController, sessionListController,
} from './controllers/sessions' } from './controllers/sessions.js'
const router = Router() const router = Router()
@@ -1,9 +1,11 @@
{ {
"extends": "../../tsconfig.json", "extends": "../../tsconfig.json",
"compilerOptions": { "compilerOptions": {
"rootDir": "src",
"outDir": "./dist" "outDir": "./dist"
}, },
"include": ["src", "../models"], "include": ["src"],
"references": [{ "path": "../models" }],
"ts-node": { "ts-node": {
"swc": true "swc": true
} }
+17
View File
@@ -0,0 +1,17 @@
{
"name": "@linux-odyssey/constants",
"type": "module",
"version": "1.0.0",
"private": true,
"main": "dist/index.js",
"exports": "./dist/index.js",
"types": "dist/index.d.ts",
"license": "GPL",
"scripts": {
"build": "tsc",
"dev": "tsc -w"
},
"dependencies": {
"zod": "^3.23.8"
}
}
+9
View File
@@ -0,0 +1,9 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"composite": true,
"outDir": "./dist"
},
"include": ["*.js", "*.ts"],
"exclude": ["node_modules", "dist"]
}
-1
View File
@@ -1 +0,0 @@
export const toArray = (val: unknown) => (typeof val === 'string' ? [val] : val)
+98
View File
@@ -0,0 +1,98 @@
/* eslint-disable no-restricted-syntax */
const fs = require('fs').promises
const { exit } = require('process')
const minimist = require('minimist')
const axios = require('axios')
const { colorize, printResponses, printHints } = require('./print.js')
const discoverFiles = require('./discover.js')
const {
API_ENDPOINT,
TOKEN,
PWD,
CMD_NAME,
CMD_OUTPUT_FILE,
CMD_ERROR_FILE,
CMD_EXIT_CODE,
} = process.env
const api = axios.create({
baseURL: `${API_ENDPOINT}/api/v1`,
headers: {
Authorization: `Bearer ${TOKEN}`,
},
})
async function readOrNone(file) {
try {
return await fs.readFile(file, 'utf8')
} catch (_) {
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 handleResponse({ responses, hints, end }) {
if (responses) {
await printResponses(responses, 90)
}
if (hints) {
await printHints(hints, 90)
}
if (end) {
console.log(colorize('Quest completed!', 'blue'))
}
await api.post('/commands/completed')
}
async function main() {
if (!CMD_NAME) exit(0)
const params = 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,
pwd: PWD,
params,
}
try {
const res = await api.post('/commands', payload)
if (!res.data || !res.data.responses) return
await handleResponse(res.data)
} catch (err) {
if (err.response) {
console.error(err.response.data)
return
}
console.error(err.message)
}
}
main()
.then(exit)
.catch((err) => {
console.log(
'發生預期外的錯誤,請嘗試重新啟動 Quest,如果問題持續發生,請幫我們填寫右上角的錯誤回報表單。'
)
console.error(err)
exit(1)
})
+49
View File
@@ -0,0 +1,49 @@
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 (_) {
// ignore
}
}
return files
}
async function discoverFiles(argv) {
const targetPath = argv._.length < 2 ? ['.'] : argv._.slice(1)
try {
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() }
} catch (_) {
return {}
}
}
module.exports = discoverFiles
+14
View File
@@ -0,0 +1,14 @@
{
"name": "container",
"version": "1.0.0",
"main": "index.js",
"license": "MIT",
"scripts": {
"build": "",
"watch": ""
},
"dependencies": {
"axios": "^1.6.0",
"minimist": "^1.2.8"
}
}
+115
View File
@@ -0,0 +1,115 @@
/* eslint-disable no-restricted-syntax */
/* eslint-disable no-await-in-loop */
const readline = require('readline')
const RETURN_SYMBOL = '\u21B5'
// ANSI color map
const ANSI_COLORS = {
default: 0,
black: 30,
red: 31,
green: 32,
yellow: 33,
blue: 34,
magenta: 35,
cyan: 36,
white: 37,
}
function colorCode(color) {
return `\x1b[${ANSI_COLORS[color]}m`
}
function colorize(content, color) {
if (!color) return content
return `${colorCode(color)}${content}${colorCode('default')}`
}
process.stdin.setRawMode(true)
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout,
terminal: false,
})
function waitForEnter() {
return new Promise((resolve) => {
rl.once('line', resolve)
})
}
function formatLine(content, color) {
let result = colorCode(color)
let codeBlock = false
for (const c of content) {
switch (c) {
case '`':
result += colorCode(codeBlock ? color : 'green')
codeBlock = !codeBlock
break
default:
result += c
}
}
return result + colorCode('default') + RETURN_SYMBOL
}
function printLine(content, delay, color) {
const printContent = formatLine(content, color)
return new Promise((resolve) => {
let i = 0
const timer = setInterval(() => {
process.stdout.write(printContent[i])
i += 1
if (i >= printContent.length) {
clearInterval(timer)
process.stdout.write('\n')
resolve()
}
}, delay)
waitForEnter().then(() => {
clearInterval(timer)
if (i >= printContent.length) return
process.stdout.write(printContent.slice(i))
process.stdout.write('\n')
resolve()
})
})
}
async function printResponses(responses, delay = 100) {
console.log()
console.log(colorize('==============================', 'green'))
for (const response of responses) {
const { type, content, speaker, color } = response
if (type === 'narrative') {
for (const line of content) {
await printLine(line, delay, color || 'cyan')
}
}
if (type === 'dialogue') {
console.log(colorize(`${speaker}:`, 'green'))
for (const line of content) {
await printLine(` ${line}`, delay, color || 'cyan')
}
}
console.log(colorize('------------------------------', 'green'))
console.log()
}
}
async function printHints(hints, delay = 100) {
for (const hint of hints) {
await printLine(hint, delay, 'yellow')
}
}
module.exports = {
printResponses,
printHints,
colorize,
}
-6
View File
@@ -1,6 +0,0 @@
export { default as File } from './file'
export type { FileObject } from './file'
export { default as FileNode } from './fileNode'
export type { IFileNode } from './fileNode'
export { default as FileGraph } from './fileGraph'
export type { FileGraphUpdateEvent } from './fileGraph'
+14
View File
@@ -0,0 +1,14 @@
{
"name": "@linux-odyssey/file-graph",
"version": "1.0.0",
"type": "module",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"exports": "./dist/index.js",
"license": "MIT",
"scripts": {
"build": "tsc",
"dev": "tsc -w",
"test": "yarn build && jest"
}
}
File renamed without changes.
@@ -2,28 +2,25 @@ import { basename } from './utils.js'
export interface FileObject { export interface FileObject {
path: string path: string
type: 'file' | 'directory' type: string
discovered: boolean discovered: boolean
empty?: boolean
} }
export default class File implements FileObject { export default class File implements FileObject {
path: string path: string
name: string name: string
type: 'file' | 'directory' type: string
discovered: boolean discovered: boolean
empty?: boolean
constructor(obj: FileObject) { constructor(obj: FileObject) {
this.path = obj.path.trimEnd() this.path = obj.path.trimEnd()
this.name = basename(obj.path) this.name = basename(obj.path)
this.type = obj.type this.type = obj.type
this.discovered = obj.discovered this.discovered = obj.discovered
this.empty = obj.empty && obj.type === 'directory'
} }
isDirectory() { isDirectory() {
return this.type === 'directory' return this.type === 'folder'
} }
contains(file: FileObject) { contains(file: FileObject) {
@@ -1,11 +1,10 @@
import { FileObject } from './file.js' import { FileObject } from './file.js'
import FileNode from './fileNode.js' import FileNode from './fileNode.js'
export interface FileGraphUpdateEvent { export interface Event {
add?: FileObject[] add?: FileObject[]
remove?: FileObject[] remove?: FileObject[]
discover?: FileObject[] discover?: FileObject[]
pwd?: string
} }
export default class FileGraph extends FileNode { export default class FileGraph extends FileNode {
@@ -21,7 +20,7 @@ export default class FileGraph extends FileNode {
remove(files: FileObject[]) { remove(files: FileObject[]) {
// Logic to remove a file node from the graph // Logic to remove a file node from the graph
// If the file node is a directory, also remove all children // If the file node is a folder, also remove all children
// 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) => {
@@ -34,10 +33,6 @@ export default class FileGraph extends FileNode {
// Overwrite the file graph with the provided files // Overwrite the file graph with the provided files
// Remove file nodes that don't exist anymore and add new ones // Remove file nodes that don't exist anymore and add new ones
if (eventFiles.length === 0) {
return
}
const newNode = new FileNode(eventFiles[0]) const newNode = new FileNode(eventFiles[0])
eventFiles.slice(1).forEach((file) => { eventFiles.slice(1).forEach((file) => {
@@ -47,16 +42,19 @@ export default class FileGraph extends FileNode {
this.merge(newNode) this.merge(newNode)
} }
handleEvent(event: FileGraphUpdateEvent) { handleEvent(event: Event) {
// eslint-disable-next-line default-case // eslint-disable-next-line default-case
if (event.add) { switch (true) {
this.add(event.add) case !!event.add:
} this.add(event.add)
if (event.remove) { break
this.remove(event.remove) case !!event.remove:
} this.remove(event.remove)
if (event.discover) { break
this.discover(event.discover) case !!event.discover:
this.discover(event.discover)
break
// Implement other cases if needed
} }
} }
} }
@@ -1,18 +1,15 @@
import File, { FileObject } from './file.js' import File, { FileObject } from './file.js'
import { ParentNotExistsError, FileNotExistsError } from './errors.js' import {
DuplicateItemError,
ParentNotExistsError,
FileNotExistsError,
} from './errors.js'
import { dirname } from './utils.js' import { dirname } from './utils.js'
export interface IFileNode { export default class FileNode extends File {
path: string
type: 'file' | 'directory'
discovered: boolean
children?: IFileNode[]
}
export default class FileNode extends File implements IFileNode {
children: FileNode[] children: FileNode[]
constructor(file: FileObject | IFileNode) { constructor(file: FileObject | FileNode) {
super(file) super(file)
this.children = [] this.children = []
if ((<FileNode>file).children) if ((<FileNode>file).children)
@@ -25,24 +22,15 @@ export default class FileNode extends File implements IFileNode {
) { ) {
const file = new FileNode(fileInput) const file = new FileNode(fileInput)
if (file.path === this.path) { if (file.path === this.path) {
if (!this.discovered) { throw new DuplicateItemError(
this.discovered = file.discovered `File ${file.path} already exists in the tree: ${this.toString()}`
} )
return
} }
let existing = false if (this.children.some((child) => child.path === file.path)) {
for (const child of this.children) { throw new DuplicateItemError(
if (child.path === file.path) { `File ${file.path} already exists in the tree: ${this.toString()}`
existing = true )
if (!child.discovered) {
child.discovered = file.discovered
}
break
}
}
if (existing) {
return
} }
const parentPath = dirname(file.path) const parentPath = dirname(file.path)
@@ -53,11 +41,11 @@ export default class FileNode extends File implements IFileNode {
// Recursively call addChild on the correct child node // Recursively call addChild on the correct child node
const childNode = this.children.find((child) => child.contains(file)) const childNode = this.children.find((child) => child.contains(file))
if (childNode) { if (childNode) {
childNode.addChild(file, { makeParents }) childNode.addChild(file)
} else if (makeParents) { } else if (makeParents) {
const parent = new FileNode({ const parent = new FileNode({
path: parentPath, path: parentPath,
type: 'directory', type: 'folder',
discovered: false, discovered: false,
children: [file], children: [file],
}) })
@@ -100,12 +88,6 @@ export default class FileNode extends File implements IFileNode {
// Update the state of this node // Update the state of this node
this.discovered = this.discovered || fileNode.discovered this.discovered = this.discovered || fileNode.discovered
// the provided directory is empty, remove all children
if (fileNode.empty) {
this.children = []
return
}
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) {
@@ -151,11 +133,4 @@ export default class FileNode extends File implements IFileNode {
.join('') .join('')
return `${name}\n${childrenString}` return `${name}\n${childrenString}`
} }
include(filePath: string, type?: 'file' | 'directory'): boolean {
if (this.path === filePath) {
return type ? this.type === type : true
}
return this.children.some((child) => child.include(filePath, type))
}
} }
+3
View File
@@ -0,0 +1,3 @@
export { default as File, FileObject } from './file.js'
export { default as FileNode } from './fileNode.js'
export { default as FileGraph, Event } from './fileGraph.js'
File renamed without changes.
+2 -2
View File
@@ -1,10 +1,10 @@
import { describe, test, expect } from '@jest/globals' import { describe, test, expect } from '@jest/globals'
import File from '../file.js' import File from '../src/file.js'
describe('File', () => { describe('File', () => {
let file: File let file: File
beforeEach(() => { beforeEach(() => {
file = new File({ path: '/home/user', type: 'directory', discovered: true }) file = new File({ path: '/home/user', type: 'folder', discovered: true })
}) })
const filesToCompare = [ const filesToCompare = [
+19 -84
View File
@@ -1,7 +1,6 @@
import { describe, test, expect } from '@jest/globals' import { describe, test, expect } from '@jest/globals'
import FileGraph from '../fileGraph.js' import FileGraph from '../src/fileGraph.js'
import FileNode from '../fileNode.js' import FileNode from '../src/fileNode.js'
import { FileObject } from '../file.js'
describe('FileGraph', () => { describe('FileGraph', () => {
let root: FileNode let root: FileNode
@@ -9,7 +8,7 @@ describe('FileGraph', () => {
beforeEach(() => { beforeEach(() => {
root = new FileNode({ root = new FileNode({
path: '/', path: '/',
type: 'directory', type: 'folder',
discovered: true, discovered: true,
}) })
}) })
@@ -17,7 +16,7 @@ describe('FileGraph', () => {
test('add files to graph', () => { test('add files to graph', () => {
const fileGraph = new FileGraph(root) const fileGraph = new FileGraph(root)
const filesToAdd: FileObject[] = [ const filesToAdd = [
{ {
path: '/home/user/file1.txt', path: '/home/user/file1.txt',
type: 'file', type: 'file',
@@ -25,12 +24,7 @@ describe('FileGraph', () => {
}, },
{ {
path: '/home/user/folder1', path: '/home/user/folder1',
type: 'directory', type: 'folder',
discovered: true,
},
{
path: '/home/user/folder2/file2.txt',
type: 'file',
discovered: true, discovered: true,
}, },
] ]
@@ -38,34 +32,7 @@ describe('FileGraph', () => {
fileGraph.add(filesToAdd) fileGraph.add(filesToAdd)
expect(fileGraph.children.length).toBe(1) expect(fileGraph.children.length).toBe(1)
expect(fileGraph.children[0].children[0].children.length).toBe(3) expect(fileGraph.children[0].children[0].children.length).toBe(2)
})
test('ignore files that already exist', () => {
const fileGraph = new FileGraph(root)
const filesToAdd: FileObject[] = [
{
path: '/home/user/folder1',
type: 'directory',
discovered: true,
},
{
path: '/home/user/folder1/file1.txt',
type: 'file',
discovered: true,
},
]
fileGraph.add(filesToAdd)
expect(fileGraph.children.length).toBe(1)
expect(fileGraph.children[0].children[0].children.length).toBe(1)
fileGraph.add([filesToAdd[1]])
expect(fileGraph.children.length).toBe(1)
expect(fileGraph.children[0].children[0].children.length).toBe(1)
}) })
test('remove files from graph', () => { test('remove files from graph', () => {
@@ -74,12 +41,12 @@ describe('FileGraph', () => {
const filesToAdd = [ const filesToAdd = [
{ {
path: '/home/user/folder1', path: '/home/user/folder1',
type: 'directory', type: 'folder',
discovered: true, discovered: true,
}, },
{ {
path: '/home/user/folder2', path: '/home/user/folder2',
type: 'directory', type: 'folder',
discovered: true, discovered: true,
}, },
{ {
@@ -94,22 +61,22 @@ describe('FileGraph', () => {
}, },
] ]
fileGraph.add(filesToAdd as FileObject[]) fileGraph.add(filesToAdd)
const filesToRemove = [ const filesToRemove = [
{ {
path: '/home/user/folder1/file1.txt', path: '/home/user/folder1/file1.txt',
type: 'directory', type: 'folder',
discovered: true, discovered: true,
}, },
{ {
path: '/home/user/folder2', path: '/home/user/folder2',
type: 'directory', type: 'folder',
discovered: true, discovered: true,
}, },
] ]
fileGraph.remove(filesToRemove as FileObject[]) fileGraph.remove(filesToRemove)
expect(fileGraph.children[0].children[0].children.length).toBe(1) expect(fileGraph.children[0].children[0].children.length).toBe(1)
expect(fileGraph.children[0].children[0].children[0].name).toBe('folder1') expect(fileGraph.children[0].children[0].children[0].name).toBe('folder1')
@@ -123,7 +90,7 @@ describe('FileGraph', () => {
const discoverFiles = [ const discoverFiles = [
{ {
path: '/home/user/folder1', path: '/home/user/folder1',
type: 'directory', type: 'folder',
discovered: true, discovered: true,
}, },
{ {
@@ -143,7 +110,7 @@ describe('FileGraph', () => {
}, },
] ]
fileGraph.discover(discoverFiles as FileObject[]) fileGraph.discover(discoverFiles)
expect(fileGraph.children[0].children[0].children[0].children.length).toBe( expect(fileGraph.children[0].children[0].children[0].children.length).toBe(
3 3
) )
@@ -155,12 +122,12 @@ describe('FileGraph', () => {
const filesToAdd = [ const filesToAdd = [
{ {
path: '/home/user/folder1', path: '/home/user/folder1',
type: 'directory', type: 'folder',
discovered: true, discovered: true,
}, },
{ {
path: '/home/user/folder2', path: '/home/user/folder2',
type: 'directory', type: 'folder',
discovered: true, discovered: true,
}, },
{ {
@@ -175,12 +142,12 @@ describe('FileGraph', () => {
}, },
] ]
fileGraph.add(filesToAdd as FileObject[]) fileGraph.add(filesToAdd)
const newFiles = [ const newFiles = [
{ {
path: '/home/user/folder1', path: '/home/user/folder1',
type: 'directory', type: 'folder',
discovered: true, discovered: true,
}, },
{ {
@@ -200,43 +167,11 @@ describe('FileGraph', () => {
}, },
] ]
fileGraph.discover(newFiles as FileObject[]) fileGraph.discover(newFiles)
expect(fileGraph.children[0].children[0].children.length).toBe(2) expect(fileGraph.children[0].children[0].children.length).toBe(2)
expect(fileGraph.children[0].children[0].children[0].children.length).toBe( expect(fileGraph.children[0].children[0].children[0].children.length).toBe(
3 3
) )
}) })
test('discover a empty directory which children just being deleted', () => {
const fileGraph = new FileGraph(root)
const discoverFiles = [
{
path: '/home/user/folder1',
type: 'directory',
discovered: true,
},
{
path: '/home/user/folder1/file1.txt',
type: 'file',
discovered: true,
},
]
fileGraph.add(discoverFiles as FileObject[])
expect(fileGraph.include('/home/user/folder1/file1.txt', 'file')).toBe(true)
fileGraph.discover([
{
path: '/home/user/folder1',
type: 'directory',
discovered: true,
empty: true,
},
])
expect(fileGraph.include('/home/user/folder1/file1.txt', 'file')).toBe(
false
)
})
}) })
+91 -90
View File
@@ -1,7 +1,7 @@
import { describe, test, expect } from '@jest/globals' import { describe, test, expect } from '@jest/globals'
import { ParentNotExistsError } from '../errors.js' import { DuplicateItemError, ParentNotExistsError } from '../src/errors.js'
import FileNode, { IFileNode } from '../fileNode.js' import FileNode from '../src/fileNode.js'
import { FileObject } from '../file.js' import { FileObject } from '../src/file.js'
function createNodeFromFiles(files: FileObject[]) { function createNodeFromFiles(files: FileObject[]) {
const node = new FileNode(files[0]) const node = new FileNode(files[0])
@@ -42,16 +42,17 @@ describe('FileNode', () => {
beforeEach(() => { beforeEach(() => {
rootNode = new FileNode({ rootNode = new FileNode({
path: '/home/rudeus', path: '/home/rudeus',
type: 'directory', name: 'rudeus',
type: 'folder',
discovered: true, discovered: true,
}) })
}) })
test('should override discovered when adding duplicate item', () => { test('should throw error when adding duplicate item', () => {
const child: FileObject = { const child = {
path: '/home/rudeus/test', path: '/home/rudeus/test',
type: 'directory', type: 'folder',
discovered: false, discovered: true,
} }
// Adding the child for the first time // Adding the child for the first time
@@ -60,16 +61,16 @@ describe('FileNode', () => {
expect(rootNode.children.length).toBe(1) expect(rootNode.children.length).toBe(1)
// Trying to add the same child again should throw an error // Trying to add the same child again should throw an error
child.discovered = true expect(() => {
rootNode.addChild(child) rootNode.addChild(child)
}).toThrow(DuplicateItemError)
expect(rootNode.children[0].discovered).toBe(true)
}) })
test('should throw error when parent does not exist', () => { test('should throw error when parent does not exist', () => {
const child: FileObject = { const child = {
path: '/home/rudeus/alphabet/test', path: '/home/rudeus/alphabet/test',
type: 'directory', name: 'test',
type: 'folder',
discovered: true, discovered: true,
} }
@@ -80,19 +81,22 @@ describe('FileNode', () => {
}) })
test('adds nested children to the tree', () => { test('adds nested children to the tree', () => {
const files: FileObject[] = [ const files = [
{ {
path: '/home/rudeus/forgotten_scroll.txt', path: '/home/rudeus/forgotten_scroll.txt',
name: 'forgotten_scroll.txt',
type: 'file', type: 'file',
discovered: true, discovered: true,
}, },
{ {
path: '/home/rudeus/test', path: '/home/rudeus/test',
type: 'directory', name: 'test',
type: 'folder',
discovered: true, discovered: true,
}, },
{ {
path: '/home/rudeus/test/a', path: '/home/rudeus/test/a',
name: 'a',
type: 'file', type: 'file',
discovered: false, discovered: false,
}, },
@@ -107,8 +111,9 @@ describe('FileNode', () => {
}) })
test('adds nested children to the tree with makeParents option', () => { test('adds nested children to the tree with makeParents option', () => {
const file: FileObject = { const file = {
path: '/home/rudeus/test/a/b/c/d/e', path: '/home/rudeus/test/a/b/c/d/e',
name: 'a',
type: 'file', type: 'file',
discovered: false, discovered: false,
} }
@@ -124,34 +129,34 @@ describe('FileNode', () => {
}) })
test('merge-subtree', () => { test('merge-subtree', () => {
const root: FileObject = { const root = {
path: '/', path: '/',
type: 'directory', type: 'folder',
discovered: true, discovered: true,
} }
const files1: FileObject[] = [ const files1 = [
root, root,
{ path: '/a', type: 'file' as const, discovered: true }, { path: '/a', type: 'file', discovered: true },
{ path: '/b', type: 'directory' as const, discovered: true }, { path: '/b', type: 'folder', discovered: true },
{ path: '/b/c', type: 'directory' as const, discovered: true }, { path: '/b/c', type: 'folder', discovered: true },
{ path: '/b/d', type: 'file' as const, discovered: true }, { path: '/b/d', type: 'file', discovered: true },
{ path: '/b/c/f', type: 'file' as const, discovered: true }, { path: '/b/c/f', type: 'file', discovered: true },
] ]
const files2: FileObject[] = [ const files2 = [
{ path: '/b', type: 'directory' as const, discovered: true }, { path: '/b', type: 'folder', discovered: true },
{ path: '/b/c', type: 'directory' as const, discovered: true }, { path: '/b/c', type: 'folder', discovered: true },
{ path: '/b/e', type: 'file' as const, discovered: true }, { path: '/b/e', type: 'file', discovered: true },
{ path: '/b/c/g', type: 'file' as const, discovered: true }, { path: '/b/c/g', type: 'file', discovered: true },
] ]
const expected: FileObject[] = [ const expected = [
root, root,
{ path: '/a', type: 'file' as const, discovered: true }, { path: '/a', type: 'file', discovered: true },
{ path: '/b', type: 'directory' as const, discovered: true }, { path: '/b', type: 'folder', discovered: true },
{ path: '/b/c', type: 'directory' as const, discovered: true }, { path: '/b/c', type: 'folder', discovered: true },
{ path: '/b/e', type: 'file' as const, discovered: true }, { path: '/b/e', type: 'file', discovered: true },
{ path: '/b/c/g', type: 'file' as const, discovered: true }, { path: '/b/c/g', type: 'file', discovered: true },
] ]
mergeTest(files1, files2, expected) mergeTest(files1, files2, expected)
@@ -161,94 +166,90 @@ describe('FileNode', () => {
// Test if the node to merge is only the middle part of the tree // Test if the node to merge is only the middle part of the tree
// (i.e. the node to merge doesn't contains all level of children) // (i.e. the node to merge doesn't contains all level of children)
// The original tree should keep the children that are not in the node to merge // The original tree should keep the children that are not in the node to merge
const root: FileObject = { const root = {
path: '/', path: '/',
type: 'directory', type: 'folder',
discovered: true, discovered: true,
} }
const files1: FileObject[] = [ const files1 = [
root, root,
{ path: '/a', type: 'file' as const, discovered: true }, { path: '/a', type: 'file', discovered: true },
{ path: '/b', type: 'directory' as const, discovered: true }, { path: '/b', type: 'folder', discovered: true },
{ path: '/b/c', type: 'directory' as const, discovered: true }, { path: '/b/c', type: 'folder', discovered: true },
{ path: '/b/d', type: 'file' as const, discovered: true }, { path: '/b/d', type: 'file', discovered: true },
{ path: '/b/c/f', type: 'file' as const, discovered: true }, { path: '/b/c/f', type: 'file', discovered: true },
] ]
const files2: FileObject[] = [ const files2 = [
{ path: '/b', type: 'directory' as const, discovered: true }, { path: '/b', type: 'folder', discovered: true },
{ path: '/b/c', type: 'directory' as const, discovered: true }, { path: '/b/c', type: 'folder', discovered: true },
{ path: '/b/e', type: 'file' as const, discovered: true }, { path: '/b/e', type: 'file', discovered: true },
] ]
const expected: FileObject[] = [ const expected = [
root, root,
{ path: '/a', type: 'file' as const, discovered: true }, { path: '/a', type: 'file', discovered: true },
{ path: '/b', type: 'directory' as const, discovered: true }, { path: '/b', type: 'folder', discovered: true },
{ path: '/b/c', type: 'directory' as const, discovered: true }, { path: '/b/c', type: 'folder', discovered: true },
{ path: '/b/e', type: 'file' as const, discovered: true }, { path: '/b/e', type: 'file', discovered: true },
{ path: '/b/c/f', type: 'file' as const, discovered: true }, { path: '/b/c/f', type: 'file', discovered: true },
] ]
mergeTest(files1, files2, expected) mergeTest(files1, files2, expected)
}) })
test('merge-hidden', () => { test('merge-hidden', () => {
const root: FileObject = { const root = {
path: '/', path: '/',
type: 'directory', type: 'folder',
discovered: true, discovered: true,
} }
const files1: FileObject[] = [ const files1 = [
root, root,
{ path: '/a', type: 'file' as const, discovered: true }, { path: '/a', type: 'file', discovered: true },
{ path: '/.b', type: 'file' as const, discovered: false }, { path: '/.b', type: 'file', discovered: false },
{ path: '/c', type: 'directory' as const, discovered: true }, { path: '/c', type: 'folder', discovered: true },
{ path: '/c/.d', type: 'file' as const, discovered: false }, { path: '/c/.d', type: 'file', discovered: false },
] ]
const files2: FileObject[] = [ const files2 = [
{ path: '/c', type: 'directory' as const, discovered: true }, { path: '/c', type: 'folder', discovered: true },
{ path: '/c/.d', type: 'file' as const, discovered: true }, { path: '/c/.d', type: 'file', discovered: true },
] ]
const expected: FileObject[] = [ const expected = [
root, root,
{ path: '/a', type: 'file' as const, discovered: true }, { path: '/a', type: 'file', discovered: true },
{ path: '/.b', type: 'file' as const, discovered: false }, { path: '/.b', type: 'file', discovered: false },
{ path: '/c', type: 'directory' as const, discovered: true }, { path: '/c', type: 'folder', discovered: true },
{ path: '/c/.d', type: 'file' as const, discovered: true }, { path: '/c/.d', type: 'file', discovered: true },
] ]
mergeTest(files1, files2, expected) mergeTest(files1, files2, expected)
}) })
test('remove-child', () => { test('remove-child', () => {
const root: FileObject = { const root = {
path: '/', path: '/',
type: 'directory', type: 'folder',
discovered: true, discovered: true,
} }
const files: FileObject[] = [ const files = [
root, root,
{ path: '/a', type: 'file' as const, discovered: true }, { path: '/a', type: 'file', discovered: true },
{ path: '/b', type: 'directory' as const, discovered: true }, { path: '/b', type: 'folder', discovered: true },
{ path: '/b/c', type: 'directory' as const, discovered: true }, { path: '/b/c', type: 'folder', discovered: true },
{ path: '/b/d', type: 'file' as const, discovered: true }, { path: '/b/d', type: 'file', discovered: true },
{ path: '/b/c/f', type: 'file' as const, discovered: true }, { path: '/b/c/f', type: 'file', discovered: true },
] ]
const fileToRemove: FileObject = { const fileToRemove = { path: '/b/c', type: 'folder', discovered: true }
path: '/b/c',
type: 'directory',
discovered: true,
}
const expected: FileObject[] = [ const expected = [
root, root,
{ path: '/a', type: 'file' as const, discovered: true }, { path: '/a', type: 'file', discovered: true },
{ path: '/b', type: 'directory' as const, discovered: true }, { path: '/b', type: 'folder', discovered: true },
{ path: '/b/d', type: 'file' as const, discovered: true }, { path: '/b/d', type: 'file', discovered: true },
] ]
removeChildTest(files, fileToRemove, expected) removeChildTest(files, fileToRemove, expected)
@@ -256,9 +257,9 @@ describe('FileNode', () => {
}) })
test('load nested fileNode', () => { test('load nested fileNode', () => {
const data: IFileNode = { const data = {
path: '/', path: '/',
type: 'directory', type: 'folder',
discovered: true, discovered: true,
children: [ children: [
{ {
@@ -268,12 +269,12 @@ test('load nested fileNode', () => {
}, },
{ {
path: '/b', path: '/b',
type: 'directory', type: 'folder',
discovered: true, discovered: true,
children: [ children: [
{ {
path: '/b/c', path: '/b/c',
type: 'directory', type: 'folder',
discovered: true, discovered: true,
children: [ children: [
{ {
+10
View File
@@ -0,0 +1,10 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"rootDir": "src",
"composite": true,
"outDir": "./dist"
},
"include": ["src"],
"exclude": ["node_modules", "dist"]
}
-48
View File
@@ -1,48 +0,0 @@
import { Condition } from './condition/Condition'
import {
ICommand,
ICondition,
IFileExistenceChecker,
IRequirements,
IResponse,
} from './schema'
export class Event {
id: string
private condition: Condition
private requirements: string[]
constructor(
id: string,
condition: ICondition,
private response: IResponse,
requirements?: IRequirements
) {
this.id = id
this.condition = new Condition(condition)
this.requirements = requirements || []
}
async satisfies(
command: ICommand,
checker: IFileExistenceChecker
): Promise<boolean> {
return this.condition.satisfies(command, checker)
}
private checkRequirements(completed: string[]): boolean {
return this.requirements.every((req) => completed.includes(req))
}
private finished(completed: string[]): boolean {
return completed.includes(this.id)
}
active(completed: string[]): boolean {
return !this.finished(completed) && this.checkRequirements(completed)
}
getResponse(): IResponse {
return this.response
}
}
-18
View File
@@ -1,18 +0,0 @@
/* eslint-disable max-classes-per-file */
import { Event } from './Event'
import { IStageException, IGlobalException } from './schema/exception'
export class StageException extends Event {
readonly catchAll: boolean
constructor({ id, condition, response, catchAll }: IStageException) {
super(id, condition, response)
this.catchAll = catchAll
}
}
export class GlobalException extends Event {
constructor({ id, condition, response, requirements }: IGlobalException) {
super(id, condition, response, requirements)
}
}
Loaded 100 of 248 files, more files were not shown because too many files have changed in this diff. Show more