mirror of
https://github.com/linux-odyssey/linux-odyssey.git
synced 2026-11-05 19:20:12 +08:00
Compare commits
119
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3b77d91796 | ||
|
|
f0a09ac4d9 | ||
|
|
a732c1d119 | ||
|
|
da473ceb8f | ||
|
|
01d60e259c | ||
|
|
0aeb9fe47e | ||
|
|
fd4f3bf8f5 | ||
|
|
39c81e6b6e | ||
|
|
ef24270e7e | ||
|
|
13b90f83c4 | ||
|
|
cb77a3e266 | ||
|
|
61cb36f357 | ||
|
|
58f3bf7817 | ||
|
|
ccbc4ff4b0 | ||
|
|
aef760478c | ||
|
|
bda68ad293 | ||
|
|
af2f1ef707 | ||
|
|
3b4891412e | ||
|
|
ef588414db | ||
|
|
bc43184adc | ||
|
|
13fb8737a3 | ||
|
|
eaced63c68 | ||
|
|
b64cc6028c | ||
|
|
dbf26bb490 | ||
|
|
698dc368e6 | ||
|
|
0a8ef9a37e | ||
|
|
aeed963a75 | ||
|
|
128aae33ec | ||
|
|
b86697b6db | ||
|
|
cb1e395579 | ||
|
|
fbe1319183 | ||
|
|
94389d4e1c | ||
|
|
a66b4f5adb | ||
|
|
9498bb77ef | ||
|
|
6fe49c4f38 | ||
|
|
3985d5d089 | ||
|
|
253564376d | ||
|
|
f7e2b00358 | ||
|
|
d450613816 | ||
|
|
129d2ff49b | ||
|
|
ad0cb3e4b2 | ||
|
|
66365b106e | ||
|
|
f6ee9f91d4 | ||
|
|
67e84d8cf9 | ||
|
|
fda0357a73 | ||
|
|
e9e924165d | ||
|
|
937c5de6b8 | ||
|
|
53c5391f61 | ||
|
|
876cae6947 | ||
|
|
beb58e233e | ||
|
|
f7860c6197 | ||
|
|
5077f9c677 | ||
|
|
9be288d2de | ||
|
|
cd6f062556 | ||
|
|
c61f844e42 | ||
|
|
26e79690f8 | ||
|
|
fba92dad46 | ||
|
|
b3ed0e646e | ||
|
|
87b8ca5d04 | ||
|
|
2d95ca382f | ||
|
|
cdd22634e0 | ||
|
|
7e50c8e06c | ||
|
|
0685d71299 | ||
|
|
56049a0a29 | ||
|
|
9b5da4e818 | ||
|
|
ab63595fba | ||
|
|
52e9a13645 | ||
|
|
cfbc386c16 | ||
|
|
92173bb7e9 | ||
|
|
b4b8f8baee | ||
|
|
2fcca0c4b9 | ||
|
|
66434ff897 | ||
|
|
a711e08328 | ||
|
|
1616f62755 | ||
|
|
1d843f7355 | ||
|
|
d2cdd1304a | ||
|
|
254e4f75ed | ||
|
|
0f0f43b838 | ||
|
|
2dc40a2254 | ||
|
|
6e804f0ed4 | ||
|
|
3656868b66 | ||
|
|
cfce5ececa | ||
|
|
fb823814e0 | ||
|
|
f7780c3d98 | ||
|
|
dae9e841ca | ||
|
|
e622011b21 | ||
|
|
471b781d85 | ||
|
|
57d8f24930 | ||
|
|
6043226142 | ||
|
|
61d1871e95 | ||
|
|
ed4d04b658 | ||
|
|
963655c1f7 | ||
|
|
423548804a | ||
|
|
ac92950177 | ||
|
|
55a41dd3b5 | ||
|
|
529869671c | ||
|
|
6326b84a60 | ||
|
|
19c44fbe52 | ||
|
|
c254d6c948 | ||
|
|
e66598f1a7 | ||
|
|
2a868ead48 | ||
|
|
b449ea455a | ||
|
|
5bea2ea78c | ||
|
|
77fe2d37b3 | ||
|
|
65ad72223b | ||
|
|
d612b2d6af | ||
|
|
b5e20766a3 | ||
|
|
e5f5590a67 | ||
|
|
e83d913852 | ||
|
|
8280f7073d | ||
|
|
4f3752060d | ||
|
|
cb26e1dcb0 | ||
|
|
9eb08b45f4 | ||
|
|
3b4a808131 | ||
|
|
a8a19ca902 | ||
|
|
6967e230d0 | ||
|
|
6c111a4475 | ||
|
|
15278dfab2 | ||
|
|
0644783cc4 |
No files matched your search
+3
-3
@@ -24,6 +24,9 @@ ADMIN_PORT=3001
|
||||
# TRUSTED_PROXIES=1.2.3.4
|
||||
# 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_SECRET=
|
||||
# GITHUB_CLIENT_ID=
|
||||
@@ -31,9 +34,6 @@ ADMIN_PORT=3001
|
||||
# SURVEY_URL=
|
||||
# BUG_REPORT_URL=
|
||||
|
||||
# SURVEY_URL=
|
||||
# BUG_REPORT_URL=
|
||||
|
||||
# Docker related config
|
||||
# DOCKER_NETWORK=host
|
||||
# QUEST_IMAGE=linuxodyssey/quest-base
|
||||
|
||||
+3
-3
@@ -12,6 +12,9 @@ DOCKER_NETWORK=linux-odyssey-players-dev
|
||||
# TRUSTED_PROXIES=1.2.3.4
|
||||
# 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_SECRET=
|
||||
# GITHUB_CLIENT_ID=
|
||||
@@ -19,9 +22,6 @@ DOCKER_NETWORK=linux-odyssey-players-dev
|
||||
# SURVEY_URL=
|
||||
# BUG_REPORT_URL=
|
||||
|
||||
# SURVEY_URL=
|
||||
# BUG_REPORT_URL=
|
||||
|
||||
# Docker related config
|
||||
# DOCKER_NETWORK=host
|
||||
# QUEST_IMAGE=linuxodyssey/quest-base
|
||||
|
||||
+33
-7
@@ -20,9 +20,12 @@ module.exports = {
|
||||
settings: {
|
||||
'import/resolver': {
|
||||
node: {
|
||||
extensions: ['.js', '.jsx', '.ts', '.tsx'],
|
||||
extensions: ['.js', '.ts'],
|
||||
moduleDirectory: ['node_modules', 'packages', 'server/src'],
|
||||
},
|
||||
typescript: {
|
||||
alwaysTryTypes: true,
|
||||
},
|
||||
typescript: {},
|
||||
},
|
||||
vue: {
|
||||
version: '3.0', // Explicitly specify Vue 3
|
||||
@@ -49,7 +52,7 @@ module.exports = {
|
||||
|
||||
'@typescript-eslint/explicit-module-boundary-types': 'off',
|
||||
'@typescript-eslint/no-unused-vars': [
|
||||
'error',
|
||||
'warn',
|
||||
{
|
||||
args: 'after-used',
|
||||
argsIgnorePattern: '^_',
|
||||
@@ -63,21 +66,44 @@ module.exports = {
|
||||
|
||||
// Allow missing file extensions
|
||||
'import/extensions': [
|
||||
'warn',
|
||||
'off',
|
||||
'ignorePackages',
|
||||
{
|
||||
js: 'off',
|
||||
js: 'never',
|
||||
jsx: 'never',
|
||||
ts: '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: [
|
||||
{
|
||||
files: ['**/tests/**/*.js', '**/tests/**/*.ts', '**/*.test.ts'], // Adjust the pattern to match your test files
|
||||
files: ['**/tests/**/*.js', '**/tests/**/*.ts', '**/*.test.ts'],
|
||||
rules: {
|
||||
'import/extensions': 'off', // Turn off the rule for test files
|
||||
'import/extensions': 'off',
|
||||
'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',
|
||||
},
|
||||
},
|
||||
|
||||
@@ -18,9 +18,9 @@ on:
|
||||
|
||||
secrets:
|
||||
DOCKERHUB_USERNAME:
|
||||
required: true
|
||||
required: false
|
||||
DOCKERHUB_TOKEN:
|
||||
required: true
|
||||
required: false
|
||||
|
||||
jobs:
|
||||
build-and-push:
|
||||
@@ -42,6 +42,7 @@ jobs:
|
||||
type=semver,pattern={{version}}
|
||||
|
||||
- name: Login to Docker Hub
|
||||
if: always()
|
||||
uses: docker/login-action@v1
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
@@ -53,5 +54,5 @@ jobs:
|
||||
context: .
|
||||
file: ${{ inputs.dockerfilePath }}
|
||||
target: ${{ inputs.target }}
|
||||
push: true
|
||||
push: ${{ secrets.DOCKERHUB_USERNAME != '' }}
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
@@ -2,15 +2,6 @@ name: CI Checks
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
secrets:
|
||||
GOOGLE_CLIENT_ID:
|
||||
required: true
|
||||
GOOGLE_CLIENT_SECRET:
|
||||
required: true
|
||||
GH_CLIENT_ID:
|
||||
required: true
|
||||
GH_CLIENT_SECRET:
|
||||
required: true
|
||||
|
||||
jobs:
|
||||
ci:
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
name: E2E Tests
|
||||
|
||||
on: [push, pull_request]
|
||||
on:
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
e2e:
|
||||
|
||||
@@ -12,7 +12,6 @@ concurrency:
|
||||
jobs:
|
||||
ci-checks:
|
||||
uses: ./.github/workflows/ci-checks.yml
|
||||
secrets: inherit
|
||||
|
||||
build:
|
||||
needs: ci-checks
|
||||
|
||||
@@ -10,7 +10,6 @@ concurrency:
|
||||
jobs:
|
||||
ci-checks:
|
||||
uses: ./.github/workflows/ci-checks.yml
|
||||
secrets: inherit
|
||||
|
||||
build:
|
||||
uses: ./.github/workflows/build.yml
|
||||
|
||||
@@ -12,7 +12,6 @@ concurrency:
|
||||
jobs:
|
||||
ci-checks:
|
||||
uses: ./.github/workflows/ci-checks.yml
|
||||
secrets: inherit
|
||||
|
||||
build:
|
||||
needs: ci-checks
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
config
|
||||
.quest
|
||||
|
||||
# Logs
|
||||
logs
|
||||
|
||||
Vendored
+4
-1
@@ -1,5 +1,8 @@
|
||||
{
|
||||
"editor.tabSize": 2,
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode",
|
||||
"editor.formatOnSave": true
|
||||
"editor.formatOnSave": true,
|
||||
"yaml.schemas": {
|
||||
"packages/game/quest.json": "quests/**/game.yml"
|
||||
}
|
||||
}
|
||||
@@ -121,12 +121,6 @@ You can edit the files on your host, and the changes will be reflected in the co
|
||||
|
||||
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):
|
||||
|
||||
yarn dev
|
||||
@@ -163,6 +157,17 @@ Run Cypress in container, create brand-new containers along with it:
|
||||
|
||||
## 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)
|
||||
docker compose -f docker-compose.prod.yml build
|
||||
docker compose -f docker-compose.prod.yml push
|
||||
@@ -180,3 +185,7 @@ You can copy from `.env.sample`
|
||||
**YOU MUST CHANGE THE SECRET_KEY** in `.env` file.
|
||||
|
||||
SECRET_KEY=your-secret-key
|
||||
|
||||
You can generate a random key by running
|
||||
|
||||
yarn setup
|
||||
+6
-17
@@ -1,26 +1,15 @@
|
||||
# Base stage
|
||||
FROM node:20-alpine as base
|
||||
FROM node:20-alpine as build
|
||||
|
||||
WORKDIR /app
|
||||
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 package.json yarn.lock ./
|
||||
COPY app/package.json ./app/package.json
|
||||
COPY server/package.json ./server/package.json
|
||||
RUN yarn install --frozen-lockfile
|
||||
|
||||
# Dev stage
|
||||
FROM base as dev
|
||||
WORKDIR /app/app
|
||||
CMD [ "yarn", "dev:docker", "--host", "0.0.0.0" ]
|
||||
|
||||
# Build stage
|
||||
FROM base as build
|
||||
COPY packages/file-graph ./packages/file-graph
|
||||
COPY packages/utils ./packages/utils
|
||||
COPY packages/constants ./packages/constants
|
||||
COPY packages/models ./packages/models
|
||||
COPY tsconfig.json ./tsconfig.json
|
||||
COPY server ./server
|
||||
COPY packages ./packages
|
||||
COPY app ./app
|
||||
RUN yarn workspace app build
|
||||
|
||||
|
||||
+10
-12
@@ -5,8 +5,8 @@
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"dev:docker": "API_TARGET=http://backend:3000 vite",
|
||||
"build": "vue-tsc -b && vite build",
|
||||
"type-check": "vue-tsc -b",
|
||||
"build": "vue-tsc && vite build",
|
||||
"type-check": "vue-tsc",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
@@ -16,13 +16,10 @@
|
||||
"@fortawesome/free-regular-svg-icons": "^6.4.0",
|
||||
"@fortawesome/free-solid-svg-icons": "^6.4.0",
|
||||
"@fortawesome/vue-fontawesome": "^3.0.3",
|
||||
"@linux-odyssey/constants": "*",
|
||||
"@linux-odyssey/file-graph": "*",
|
||||
"@linux-odyssey/models": "*",
|
||||
"@linux-odyssey/utils": "*",
|
||||
"@trpc/client": "^11.1.0",
|
||||
"@types/dompurify": "^3.0.5",
|
||||
"axios": "^1.6.0",
|
||||
"dompurify": "^3.0.6",
|
||||
"axios": "^1.8.2",
|
||||
"dompurify": "^3.2.4",
|
||||
"marked": "^10.0.0",
|
||||
"pinia": "^2.1.7",
|
||||
"socket.io-client": "^4.7.1",
|
||||
@@ -37,13 +34,14 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/validator": "^13.11.9",
|
||||
"@vitejs/plugin-vue": "^4.5.2",
|
||||
"@vue/compiler-sfc": "^3.3.13",
|
||||
"autoprefixer": "^10.4.14",
|
||||
"postcss": "^8.4.31",
|
||||
"tailwindcss": "^3.3.3",
|
||||
"typescript": "^5.3.3",
|
||||
"vite": "^4.5.3",
|
||||
"vue-tsc": "^2.0.10"
|
||||
"@vitejs/plugin-vue": "^5.2.2",
|
||||
"@vue/tsconfig": "^0.7.0",
|
||||
"typescript": "~5.7.2",
|
||||
"vite": "^6.3.1",
|
||||
"vue-tsc": "^2.2.8"
|
||||
}
|
||||
}
|
||||
@@ -22,7 +22,7 @@ function nodeClass() {
|
||||
<template>
|
||||
<a href="#" :class="nodeClass()">
|
||||
<font-awesome-icon
|
||||
v-if="node.type === 'folder'"
|
||||
v-if="node.type === 'directory'"
|
||||
:icon="['far', 'folder']"
|
||||
class="text-text-primary size-8"
|
||||
/>
|
||||
|
||||
@@ -31,11 +31,31 @@ const html = computed(() => {
|
||||
.markdown-content :deep(ol) {
|
||||
list-style: decimal;
|
||||
padding-left: 1em;
|
||||
line-height: 1.2;
|
||||
}
|
||||
.markdown-content :deep(code) {
|
||||
background-color: #282a33;
|
||||
.markdown-content :deep(ul) {
|
||||
list-style: disc;
|
||||
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;
|
||||
padding: 0.25rem;
|
||||
font-size: 0.875rem;
|
||||
padding: 0.2rem 0.4rem;
|
||||
font-size: 0.9rem;
|
||||
color: #d8dee9;
|
||||
font-family: 'monospace';
|
||||
}
|
||||
</style>
|
||||
@@ -2,8 +2,8 @@
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useToast } from 'vue-toastification'
|
||||
import { ref, defineProps, onMounted } from 'vue'
|
||||
import { getQuest } from '../utils/api'
|
||||
import MarkdownText from './MarkdownText.vue'
|
||||
import { trpc } from '../utils/trpc'
|
||||
|
||||
const props = defineProps({
|
||||
questTitle: {
|
||||
@@ -49,7 +49,7 @@ const handleColour = () => {
|
||||
}
|
||||
}
|
||||
onMounted(async () => {
|
||||
const quest = await getQuest(questId)
|
||||
const quest = await trpc.quests.getQuestDetail.query(questId)
|
||||
questInstruction.value = quest.instruction
|
||||
handleColour()
|
||||
})
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, watch, computed } from 'vue'
|
||||
import { useToast } from 'vue-toastification'
|
||||
import { DAG } from '@linux-odyssey/utils'
|
||||
import { getQuests } from '../utils/api'
|
||||
import { DAG } from '../../../packages/utils'
|
||||
import { trpc } from '../utils/trpc'
|
||||
import useUserProfile from '../store/userProfile'
|
||||
import QuestIntro from './QuestIntro.vue'
|
||||
|
||||
@@ -64,19 +64,19 @@ async function computeGraphData() {
|
||||
if (!store.progress) return
|
||||
|
||||
try {
|
||||
const quests = await getQuests()
|
||||
const quests = await trpc.quests.getQuests.query()
|
||||
const dag = new DAG(quests)
|
||||
const nodesValues = dag.getNodes()
|
||||
|
||||
const nodes = nodesValues.map((node) => ({
|
||||
id: node._id,
|
||||
id: node.id,
|
||||
title: node.title,
|
||||
x: marginX * node.layer * 2,
|
||||
y:
|
||||
marginY * node.index -
|
||||
(marginX * dag.getLayer(node._id)) / 2 +
|
||||
(marginX * dag.getLayer(node.id)) / 2 +
|
||||
svgHeight.value / 2,
|
||||
completed: store.progress[node._id]?.completed || false,
|
||||
completed: store.progress[node.id]?.completed || false,
|
||||
unlocked: node.requirements.every(
|
||||
(req: string) => store.progress[req]?.completed
|
||||
),
|
||||
|
||||
@@ -5,8 +5,9 @@ import MarkdownText from '../MarkdownText.vue'
|
||||
|
||||
const sessionStore = useSession()
|
||||
const current = ref(-1)
|
||||
const hintDetails = ref<HTMLDetailsElement | null>(null)
|
||||
const length = computed(() => {
|
||||
return sessionStore.session?.responses.length ?? 0
|
||||
return sessionStore.session?.responses?.length ?? 0
|
||||
})
|
||||
|
||||
const disabled = computed(() => {
|
||||
@@ -14,6 +15,7 @@ const disabled = computed(() => {
|
||||
})
|
||||
|
||||
watch(length, () => {
|
||||
console.log('length update', length.value)
|
||||
current.value = length.value - 1
|
||||
})
|
||||
|
||||
@@ -28,6 +30,19 @@ const right = () => {
|
||||
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>
|
||||
|
||||
<template>
|
||||
@@ -62,27 +77,30 @@ const right = () => {
|
||||
@click="right"
|
||||
:disabled="disabled"
|
||||
>
|
||||
<font-awesome-icon :icon="['fas', 'arrow-right']" class="text-text" />
|
||||
<font-awesome-icon
|
||||
:icon="['fas', 'arrow-right']"
|
||||
:class="disabled ? 'text-gray-600' : 'text-text'"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="hint" class="bg-bg p-8 overflow-y-auto">
|
||||
<ul>
|
||||
<li
|
||||
v-for="response in sessionStore.session?.responses[current]"
|
||||
v-if="response"
|
||||
:key="response.type"
|
||||
class="text-text font-xl whitespace-pre-wrap"
|
||||
>
|
||||
<div v-for="content in response.content" :key="content">
|
||||
<MarkdownText :content="content" />
|
||||
</div>
|
||||
<MarkdownText :content="response.content" />
|
||||
</li>
|
||||
<li
|
||||
v-for="hint in sessionStore.session?.hints[current]"
|
||||
:key="hint"
|
||||
v-if="response?.hint"
|
||||
class="text-text-primary font-xl whitespace-pre-wrap"
|
||||
>
|
||||
<MarkdownText :content="hint" />
|
||||
<details ref="hintDetails">
|
||||
<summary class="cursor-pointer">提示</summary>
|
||||
<MarkdownText :content="response.hint" />
|
||||
</details>
|
||||
</li>
|
||||
</ul>
|
||||
<br />
|
||||
|
||||
@@ -2,11 +2,11 @@
|
||||
import { computed } from 'vue'
|
||||
import MarkdownText from '../MarkdownText.vue'
|
||||
import useSession from '../../store/session'
|
||||
import { Task } from '../../types'
|
||||
|
||||
const sessionStore = useSession()
|
||||
const tasks = computed(() => {
|
||||
return sessionStore.session?.tasks.map((task: Task) => {
|
||||
console.log('session', sessionStore.session)
|
||||
return sessionStore.session?.tasks.map((task) => {
|
||||
const prefix = task.completed ? '✓' : '➤'
|
||||
const color = task.completed ? 'text-text-primary' : 'text-text'
|
||||
return {
|
||||
|
||||
@@ -1,23 +1,34 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { onMounted, onUnmounted, ref } from 'vue'
|
||||
import { useTerminal } from '../../store/session'
|
||||
|
||||
const terminal = ref<HTMLElement | null>(null)
|
||||
const term = useTerminal()
|
||||
|
||||
onMounted(() => {
|
||||
const term = useTerminal()
|
||||
if (!terminal.value) return
|
||||
term.mount(terminal.value)
|
||||
window.addEventListener('resize', () => term.resizeScreen())
|
||||
|
||||
const handleResize = () => term.resizeScreen()
|
||||
window.addEventListener('resize', handleResize)
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('resize', handleResize)
|
||||
})
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<font-awesome-icon :icon="['fas', 'terminal']" class="text-text p-1" />
|
||||
<button id="Terminal" class="text-text h-[5%]">終端機</button>
|
||||
<div
|
||||
id="terminal"
|
||||
ref="terminal"
|
||||
class="bg-bg-secondary h-[calc(100%-5%)] p-0.5"
|
||||
></div>
|
||||
<div class="flex flex-col h-full">
|
||||
<div class="flex items-center gap-2 p-1">
|
||||
<font-awesome-icon :icon="['fas', 'terminal']" class="text-text" />
|
||||
<button id="Terminal" class="text-text">終端機</button>
|
||||
</div>
|
||||
<div
|
||||
id="terminal"
|
||||
ref="terminal"
|
||||
class="bg-bg-secondary flex-grow overflow-hidden px-2"
|
||||
style="height: calc(100vh - 40px)"
|
||||
></div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -3,7 +3,7 @@ import { onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useToast } from 'vue-toastification'
|
||||
import { logout } from '../../utils/auth'
|
||||
import { surveyUrl } from '../../config'
|
||||
import { trpc } from '../../utils/trpc'
|
||||
import { openBugReport } from '../../utils/formbricks'
|
||||
import useSession from '../../store/session'
|
||||
import useUserProfile from '../../store/userProfile'
|
||||
@@ -26,6 +26,11 @@ const handleLogout = async () => {
|
||||
}
|
||||
}
|
||||
|
||||
const openSurvey = async () => {
|
||||
const surveyUrl = await trpc.links.surveyLink.query()
|
||||
window.open(surveyUrl, '_blank')
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
await userStore.loadUserProfile()
|
||||
@@ -61,18 +66,18 @@ onMounted(async () => {
|
||||
>
|
||||
{{ userStore.username }}
|
||||
</p>
|
||||
<a
|
||||
<button
|
||||
title="Survey"
|
||||
:href="surveyUrl"
|
||||
target="_blank"
|
||||
class="md:h-5 md:w-5 px-1.5 w-auto"
|
||||
@click="openSurvey"
|
||||
>
|
||||
<font-awesome-icon
|
||||
:icon="['fas', 'file-invoice']"
|
||||
class="text-text-primary inline"
|
||||
/>
|
||||
<span class="text-text sm:hidden px-2">Survey</span>
|
||||
</a>
|
||||
</button>
|
||||
<RouterLink
|
||||
title="LeaderBoard"
|
||||
to="/leaderboard"
|
||||
|
||||
+59
-38
@@ -1,19 +1,37 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { FileGraph, FileObject } from '@linux-odyssey/file-graph'
|
||||
import { IQuest } from '@linux-odyssey/models'
|
||||
import { createSession, getActiveSession, getQuest } from '../utils/api'
|
||||
import {
|
||||
FileGraph,
|
||||
type FileGraphUpdateEvent,
|
||||
} 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 SocketTerminal from '../utils/terminal'
|
||||
import { Session, StageResponse } from '../types'
|
||||
import type { Session } from '../types'
|
||||
import { trpc } from '../utils/trpc'
|
||||
|
||||
const socket = new Socket()
|
||||
const term = new SocketTerminal(40, 80)
|
||||
const term = new SocketTerminal()
|
||||
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 {
|
||||
session: Session | null
|
||||
questId: string
|
||||
quest: IQuest | null
|
||||
quest: QuestDetailResponse | null
|
||||
}
|
||||
|
||||
const useSession = defineStore('session', {
|
||||
@@ -24,52 +42,51 @@ const useSession = defineStore('session', {
|
||||
}),
|
||||
actions: {
|
||||
async setQuest(questId: string) {
|
||||
this.quest = await getQuest(questId)
|
||||
this.quest = await trpc.quests.getQuestDetail.query(questId)
|
||||
this.questId = questId
|
||||
},
|
||||
async setSession(session: Session) {
|
||||
this.session = session
|
||||
this.session.graph = new FileGraph(session.graph)
|
||||
async setSession(session: SessionDetail) {
|
||||
this.session = {
|
||||
...session,
|
||||
graph: new FileGraph(session.graph),
|
||||
pwd: '/home/commander',
|
||||
}
|
||||
term.reset()
|
||||
await socket.connect(session)
|
||||
await socket.connect(this.session)
|
||||
term.focus()
|
||||
},
|
||||
async createSession() {
|
||||
const session = await createSession(this.questId)
|
||||
const session = await trpc.session.createSession.mutate({
|
||||
questId: this.questId,
|
||||
})
|
||||
await this.setSession(session)
|
||||
socket.emit('terminal', 'echo start\n')
|
||||
},
|
||||
async getActiveSession() {
|
||||
const session = await getActiveSession(this.questId)
|
||||
const session = await trpc.session.getActiveSession.query({
|
||||
questId: this.questId,
|
||||
})
|
||||
if (session) {
|
||||
this.setSession(session)
|
||||
}
|
||||
},
|
||||
updateGraph(event: { discover?: FileObject[]; pwd?: string }) {
|
||||
newUpdate(update: SessionUpdate) {
|
||||
if (!this.session) return
|
||||
if (event.discover) {
|
||||
this.session.graph.discover(event.discover)
|
||||
}
|
||||
if (event.pwd) {
|
||||
this.session.pwd = event.pwd
|
||||
}
|
||||
this.session.responses = update.responses
|
||||
this.session.tasks = update.tasks
|
||||
this.session.status = update.status
|
||||
// if (
|
||||
// response.status === 'finished' &&
|
||||
// this.session.status !== 'finished'
|
||||
// ) {
|
||||
// this.finish()
|
||||
// }
|
||||
// this.session.status = response.status
|
||||
},
|
||||
newResponse(response: StageResponse) {
|
||||
finish() {
|
||||
if (!this.session) return
|
||||
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
|
||||
this.session.status = 'finished'
|
||||
},
|
||||
finish() {},
|
||||
reset() {
|
||||
socket.reset()
|
||||
term.reset()
|
||||
@@ -83,11 +100,15 @@ const useSession = defineStore('session', {
|
||||
term.onData((data: string) => {
|
||||
socket.emit('terminal', data)
|
||||
})
|
||||
socket.on('graph', (event: { discover: FileObject[]; pwd: string }) => {
|
||||
this.updateGraph(event)
|
||||
socket.on('graph', (event: FileGraphUpdateEvent) => {
|
||||
if (!this.session) return
|
||||
this.session.graph.handleEvent(event)
|
||||
if (event.pwd) {
|
||||
this.session.pwd = event.pwd
|
||||
}
|
||||
})
|
||||
socket.on('response', (response: StageResponse) => {
|
||||
this.newResponse(response)
|
||||
socket.on('update', (update: SessionUpdate) => {
|
||||
this.newUpdate(update)
|
||||
})
|
||||
hasSetup = true
|
||||
},
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { getUserProfile } from '../utils/api'
|
||||
import { trpc } from '../utils/trpc'
|
||||
|
||||
interface IProgress {
|
||||
quest: string
|
||||
@@ -23,7 +23,7 @@ const useUserProfile = defineStore('userProfile', {
|
||||
}),
|
||||
actions: {
|
||||
async loadUserProfile() {
|
||||
const res = await getUserProfile()
|
||||
const res = await trpc.users.getMyProfile.query()
|
||||
this.$patch(res)
|
||||
},
|
||||
async resetUserProfile() {
|
||||
|
||||
+4
-6
@@ -1,6 +1,5 @@
|
||||
import { FileGraph } from '@linux-odyssey/file-graph'
|
||||
// eslint-disable-next-line import/no-relative-packages
|
||||
import { IResponse } from '@linux-odyssey/models'
|
||||
import type { FileGraph } from '../../packages/file-graph'
|
||||
import type { IResponse, ITask } from '../../packages/game'
|
||||
|
||||
export interface Task {
|
||||
id: number
|
||||
@@ -20,7 +19,6 @@ export interface Session {
|
||||
status: string
|
||||
graph: FileGraph
|
||||
pwd: string
|
||||
hints: string[][]
|
||||
tasks: Task[]
|
||||
responses: IResponse[][]
|
||||
tasks: ITask[]
|
||||
responses: IResponse[]
|
||||
}
|
||||
@@ -1,8 +1,4 @@
|
||||
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({
|
||||
baseURL: '/api/v1',
|
||||
@@ -12,39 +8,3 @@ const api = axios.create({
|
||||
withCredentials: true,
|
||||
})
|
||||
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,5 +1,5 @@
|
||||
import { io, Socket } from 'socket.io-client'
|
||||
import { Session } from '../types'
|
||||
import type { Session } from '../types'
|
||||
|
||||
type SocketCallback = (data: any) => void
|
||||
|
||||
|
||||
+21
-24
@@ -6,33 +6,39 @@ class SocketTerminal {
|
||||
private term: Terminal
|
||||
private fitAddon: FitAddon
|
||||
|
||||
constructor(rows: number, cols: number) {
|
||||
constructor() {
|
||||
this.term = new Terminal({
|
||||
rows, // 行数
|
||||
cols, // 不指定行数,自动回车后光标从下一行开始
|
||||
convertEol: true, // 启用时,光标将设置为下一行的开头
|
||||
// scrollback: 50, //终端中的回滚量
|
||||
disableStdin: false, // 是否应禁用输入
|
||||
// cursorStyle: "underline", //光标样式
|
||||
cursorBlink: true, // 光标闪烁
|
||||
convertEol: true,
|
||||
scrollback: 1000,
|
||||
fontSize: 16,
|
||||
theme: {
|
||||
foreground: '#ECECEC', // 字体
|
||||
background: '#000000', // 背景色
|
||||
cursor: 'help', // 设置光标
|
||||
foreground: '#ECECEC',
|
||||
background: '#000000',
|
||||
cursor: 'help',
|
||||
},
|
||||
fontSize: 18,
|
||||
fontWeight: 'normal',
|
||||
})
|
||||
this.fitAddon = new FitAddon()
|
||||
this.term.loadAddon(this.fitAddon)
|
||||
this.fitAddon.fit()
|
||||
}
|
||||
|
||||
resizeScreen(): void {
|
||||
try {
|
||||
this.fitAddon.fit()
|
||||
} catch (e) {
|
||||
console.error('Failed to resize terminal:', e)
|
||||
}
|
||||
}
|
||||
|
||||
mount(reference: HTMLElement): void {
|
||||
this.term.open(reference)
|
||||
this.resizeScreen()
|
||||
}
|
||||
|
||||
write(message: string): void {
|
||||
this.term.write(message)
|
||||
}
|
||||
|
||||
onData(callback: any): void {
|
||||
onData(callback: (data: string) => void): void {
|
||||
this.term.onData(callback)
|
||||
}
|
||||
|
||||
@@ -40,15 +46,6 @@ class SocketTerminal {
|
||||
this.term.focus()
|
||||
}
|
||||
|
||||
resizeScreen(): void {
|
||||
this.fitAddon.fit()
|
||||
}
|
||||
|
||||
mount(reference: HTMLElement): void {
|
||||
this.term.open(reference)
|
||||
this.resizeScreen()
|
||||
}
|
||||
|
||||
reset(): void {
|
||||
this.term.reset()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
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,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { isValidUsername } from '@linux-odyssey/utils'
|
||||
import { isValidUsername } from '../../../packages/utils'
|
||||
import AuthForm from '../components/AuthForm.vue'
|
||||
import Background from '../components/DynamicBackground.vue'
|
||||
import { TooManyRequestsError, ValidationError } from '../utils/errors'
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import validator from 'validator'
|
||||
import { isValidUsername } from '@linux-odyssey/utils'
|
||||
import { passwordPolicy } from '@linux-odyssey/constants'
|
||||
import { isValidUsername } from '../../../packages/utils'
|
||||
import { passwordPolicy } from '../../../packages/constants'
|
||||
import AuthForm from '../components/AuthForm.vue'
|
||||
import DynamicBackground from '../components/DynamicBackground.vue'
|
||||
import HeaderPart from '../components/header/HeaderPart.vue'
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"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"]
|
||||
}
|
||||
+3
-26
@@ -1,30 +1,7 @@
|
||||
{
|
||||
"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"],
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "../packages/utils" },
|
||||
{ "path": "../packages/constants" },
|
||||
{ "path": "../packages/file-graph" },
|
||||
{ "path": "../packages/models" }
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"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"]
|
||||
}
|
||||
@@ -1,11 +1,26 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
import path from 'path'
|
||||
|
||||
// https://vitejs.dev/config/
|
||||
export default defineConfig({
|
||||
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: {
|
||||
proxy: {
|
||||
'/trpc': {
|
||||
target: process.env.API_TARGET || 'http://localhost:3000',
|
||||
changeOrigin: false,
|
||||
xfwd: true,
|
||||
},
|
||||
'/api': {
|
||||
target: process.env.API_TARGET || 'http://localhost:3000',
|
||||
changeOrigin: false,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
server {
|
||||
listen 80;
|
||||
error_log /var/log/nginx/error.log debug;
|
||||
|
||||
location / {
|
||||
root /usr/share/nginx/html;
|
||||
@@ -10,10 +11,29 @@ server {
|
||||
location /api/ {
|
||||
proxy_pass http://backend:3000;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_connect_timeout 60s;
|
||||
proxy_send_timeout 60s;
|
||||
proxy_read_timeout 60s;
|
||||
proxy_buffering off;
|
||||
}
|
||||
|
||||
location /trpc/ {
|
||||
proxy_pass http://backend:3000;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_connect_timeout 60s;
|
||||
proxy_send_timeout 60s;
|
||||
proxy_read_timeout 60s;
|
||||
proxy_buffering off;
|
||||
}
|
||||
|
||||
location /api-docs/ {
|
||||
proxy_pass http://backend:3000;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
}
|
||||
|
||||
location /socket.io/ {
|
||||
@@ -23,5 +43,9 @@ server {
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
proxy_set_header Host $host;
|
||||
proxy_connect_timeout 60s;
|
||||
proxy_send_timeout 60s;
|
||||
proxy_read_timeout 60s;
|
||||
proxy_buffering off;
|
||||
}
|
||||
}
|
||||
@@ -9,7 +9,7 @@ services:
|
||||
dockerfile: app/Dockerfile
|
||||
target: prod
|
||||
networks:
|
||||
internal:
|
||||
- internal
|
||||
ports:
|
||||
- 8080:80
|
||||
|
||||
@@ -31,7 +31,7 @@ services:
|
||||
- ./config:/app/config
|
||||
- ./quests:/app/quests:ro
|
||||
env_file:
|
||||
- .env
|
||||
- .env.prod
|
||||
environment:
|
||||
- DOCKER_NETWORK=linux-odyssey-players
|
||||
- BACKEND_URL=http://backend:3000
|
||||
@@ -51,7 +51,7 @@ services:
|
||||
ports:
|
||||
- '3001:3001'
|
||||
env_file:
|
||||
- .env
|
||||
- .env.prod
|
||||
|
||||
db:
|
||||
image: mongo:6
|
||||
@@ -64,7 +64,7 @@ services:
|
||||
- mongo:/data/db
|
||||
- ./mongo-init.js:/docker-entrypoint-initdb.d/mongo-init.js:ro
|
||||
env_file:
|
||||
- .env
|
||||
- .env.prod
|
||||
|
||||
base:
|
||||
image: linuxodyssey/quest-base
|
||||
|
||||
+10
-10
@@ -16,7 +16,6 @@
|
||||
"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\"",
|
||||
"server": "yarn workspace server dev",
|
||||
"cli": "yarn workspace client start",
|
||||
"lint": "eslint .",
|
||||
"format": "prettier --ignore-path .prettierignore --write app server",
|
||||
"test": "yarn node --experimental-vm-modules --no-warnings $(yarn bin jest)",
|
||||
@@ -34,28 +33,29 @@
|
||||
"@jest/globals": "^29.7.0",
|
||||
"@types/jest": "^29.5.12",
|
||||
"@types/node": "^20.10.4",
|
||||
"@typescript-eslint/eslint-plugin": "^6.14.0",
|
||||
"@typescript-eslint/parser": "^6.14.0",
|
||||
"@typescript-eslint/eslint-plugin": "^6.21.0",
|
||||
"@typescript-eslint/parser": "^6.21.0",
|
||||
"@vue/eslint-config-prettier": "^7.1.0",
|
||||
"babel-jest": "^29.7.0",
|
||||
"concurrently": "^8.2.2",
|
||||
"eslint": "^8.44.0",
|
||||
"eslint": "^8.56.0",
|
||||
"eslint-config-airbnb-base": "^15.0.0",
|
||||
"eslint-config-prettier": "^8.8.0",
|
||||
"eslint-config-prettier": "^9.1.0",
|
||||
"eslint-import-resolver-typescript": "^3.6.1",
|
||||
"eslint-plugin-cypress": "^2.15.1",
|
||||
"eslint-plugin-import": "^2.29.0",
|
||||
"eslint-plugin-node": "^11.1.0",
|
||||
"eslint-plugin-prettier": "^5.0.0",
|
||||
"eslint-plugin-prettier": "^5.1.3",
|
||||
"eslint-plugin-vue": "^9.15.1",
|
||||
"globals": "^16.0.0",
|
||||
"jest": "^29.7.0",
|
||||
"prettier": "^3.0.0",
|
||||
"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": {
|
||||
"latest": "^0.2.0",
|
||||
"typescript-eslint": "^7.4.0"
|
||||
"latest": "^0.2.0"
|
||||
},
|
||||
"packageManager": "yarn@1.22.22+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e"
|
||||
}
|
||||
@@ -1,19 +1,26 @@
|
||||
FROM node:20-alpine as build
|
||||
|
||||
WORKDIR /app
|
||||
COPY ./package.json yarn.lock tsconfig.json ./
|
||||
COPY ./package.json yarn.lock ./
|
||||
COPY packages/analytics/package.json ./packages/analytics/package.json
|
||||
COPY packages/models/package.json ./packages/models/package.json
|
||||
RUN yarn install --frozen-lockfile
|
||||
|
||||
COPY tsconfig.json ./tsconfig.json
|
||||
COPY packages ./packages
|
||||
|
||||
RUN yarn workspace analytics build
|
||||
|
||||
# Production stage
|
||||
FROM node:20-alpine as prod
|
||||
|
||||
WORKDIR /app
|
||||
COPY --from=build /app/node_modules ./node_modules
|
||||
COPY --from=build /app/packages/analytics ./packages/analytics
|
||||
|
||||
ENV NODE_ENV=production
|
||||
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" ]
|
||||
@@ -1,6 +0,0 @@
|
||||
{
|
||||
"watch": ["src", "views"],
|
||||
"ext": "ts,handlebars,js",
|
||||
"ignore": ["src/**/*.spec.ts"],
|
||||
"exec": "node --loader ts-node/esm src/index.ts"
|
||||
}
|
||||
@@ -3,30 +3,27 @@
|
||||
"version": "1.0.0",
|
||||
"main": "src/index.js",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"license": "GPL",
|
||||
"scripts": {
|
||||
"start": "node dist/index.js",
|
||||
"build": "tsc -b -f",
|
||||
"watch": "tsc -b --watch",
|
||||
"dev": "nodemon"
|
||||
"start": "node dist/analytics/src/index.js",
|
||||
"build": "tsc",
|
||||
"type-check": "tsc --noEmit",
|
||||
"dev": "tsx watch src/index.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@linux-odyssey/models": "*",
|
||||
"dotenv": "^16.4.5",
|
||||
"express": "^4.19.2",
|
||||
"express": "^4.20.0",
|
||||
"express-basic-auth": "^1.2.1",
|
||||
"express-handlebars": "^7.1.2",
|
||||
"mongoose": "^8.0.3"
|
||||
"mongoose": "^8.9.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@swc/core": "^1.4.2",
|
||||
"@swc/helpers": "^0.5.6",
|
||||
"@types/express": "^4.17.21",
|
||||
"@types/node": "^20.11.20",
|
||||
"nodemon": "^3.1.0",
|
||||
"regenerator-runtime": "^0.14.1",
|
||||
"ts-node": "^10.9.2",
|
||||
"tsx": "^4.19.3",
|
||||
"typescript": "^5.3.3"
|
||||
}
|
||||
}
|
||||
Whitespace-only changes.
@@ -1,6 +1,6 @@
|
||||
import type { Request, Response } from 'express'
|
||||
import Pagination from '../models/pagination.js'
|
||||
import { errorCommands } from '../models/commands.js'
|
||||
import Pagination from '../models/pagination'
|
||||
import { errorCommands } from '../models/commands'
|
||||
|
||||
export async function commandListController(
|
||||
req: Request,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Request, Response } from 'express'
|
||||
import { userCount } from '../models/users.js'
|
||||
import { questList } from '../models/quests.js'
|
||||
import { userCount } from '../models/users'
|
||||
import { questList } from '../models/quests'
|
||||
|
||||
export async function homeController(req: Request, res: Response) {
|
||||
const quests = await questList()
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Request, Response } from 'express'
|
||||
import { questList } from '../models/quests.js'
|
||||
import { questList } from '../models/quests'
|
||||
|
||||
export async function questListController(req: Request, res: Response) {
|
||||
const quests = await questList()
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Request, Response } from 'express'
|
||||
import Pagination from '../models/pagination.js'
|
||||
import { sessionList, sessionDetail } from '../models/sessions.js'
|
||||
import Pagination from '../models/pagination'
|
||||
import { sessionList, sessionDetail } from '../models/sessions'
|
||||
|
||||
export async function sessionListController(req: Request, res: Response) {
|
||||
const { nextKey, order } = req.query as { nextKey: string; order: string }
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Request, Response } from 'express'
|
||||
import mongoose from 'mongoose'
|
||||
import Pagination from '../models/pagination.js'
|
||||
import { userList, userDetail, idToUser } from '../models/users.js'
|
||||
import Pagination from '../models/pagination'
|
||||
import { userList, userDetail, idToUser } from '../models/users'
|
||||
|
||||
export async function userListController(req: Request, res: Response) {
|
||||
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 { engine } from 'express-handlebars'
|
||||
import basicAuth from 'express-basic-auth'
|
||||
import config from './config.js'
|
||||
import router from './router.js'
|
||||
import { connectDB } from '../../models'
|
||||
import config from './config'
|
||||
import router from './router'
|
||||
|
||||
async function main() {
|
||||
await connectDB(config.db)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Command } from '@linux-odyssey/models'
|
||||
import Pagination from './pagination.js'
|
||||
import { Command } from '../../../models'
|
||||
import Pagination from './pagination'
|
||||
|
||||
// eslint-disable-next-line import/prefer-default-export
|
||||
export async function errorCommands(pagination: Pagination) {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/* eslint-disable class-methods-use-this */
|
||||
// eslint-disable-next-line max-classes-per-file
|
||||
/* eslint-disable max-classes-per-file */
|
||||
import mongoose, { Types } from 'mongoose'
|
||||
|
||||
export const DESC: string = 'desc'
|
||||
|
||||
@@ -1,8 +1,14 @@
|
||||
/* eslint-disable object-shorthand */
|
||||
/* eslint-disable func-names */
|
||||
import { UserProfile } from '@linux-odyssey/models'
|
||||
import { UserProfile } from '../../../models'
|
||||
|
||||
export function questList() {
|
||||
export interface QuestProgress {
|
||||
_id: string
|
||||
started: number
|
||||
completed: number
|
||||
completedRate: number
|
||||
failed: number
|
||||
}
|
||||
|
||||
export function questList(): Promise<QuestProgress[]> {
|
||||
return UserProfile.aggregate([
|
||||
{
|
||||
$project: {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Command, Session } from '@linux-odyssey/models'
|
||||
import Pagination from './pagination.js'
|
||||
import { Command, Session } from '../../../models'
|
||||
import Pagination from './pagination'
|
||||
import {
|
||||
CommandObject,
|
||||
SessionDetail,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Session, User } from '@linux-odyssey/models'
|
||||
import { Types } from 'mongoose'
|
||||
import Pagination from './pagination.js'
|
||||
import { IUser, UserSessionDetail } from '../interface.js'
|
||||
import { HydratedDocument, Types } from 'mongoose'
|
||||
import { Session, User, IUser, ISession } from '../../../models'
|
||||
import Pagination from './pagination'
|
||||
import { UserSessionDetail } from '../interface'
|
||||
|
||||
function loginMethods(user: IUser): string[] {
|
||||
const methods: string[] = []
|
||||
@@ -23,7 +23,11 @@ export function userCount() {
|
||||
}
|
||||
|
||||
export async function userList(pagination: Pagination) {
|
||||
const users = await Session.aggregate([
|
||||
const users: {
|
||||
userData: HydratedDocument<IUser>
|
||||
count: number
|
||||
lastActivityAt: Date
|
||||
}[] = await Session.aggregate([
|
||||
pagination.match('user'),
|
||||
{
|
||||
$group: {
|
||||
@@ -62,7 +66,7 @@ export async function userList(pagination: Pagination) {
|
||||
export async function userDetail(
|
||||
id: Types.ObjectId
|
||||
): Promise<UserSessionDetail[]> {
|
||||
const sessions = await Session.find({
|
||||
const sessions: HydratedDocument<ISession>[] = await Session.find({
|
||||
user: id,
|
||||
})
|
||||
return sessions.map((session): UserSessionDetail => {
|
||||
|
||||
@@ -1,16 +1,13 @@
|
||||
import { Router } from 'express'
|
||||
|
||||
import { homeController } from './controllers/index.js'
|
||||
import {
|
||||
userListController,
|
||||
userDetailController,
|
||||
} from './controllers/users.js'
|
||||
import { questListController } from './controllers/quests.js'
|
||||
import { commandListController } from './controllers/commands.js'
|
||||
import { homeController } from './controllers/index'
|
||||
import { userListController, userDetailController } from './controllers/users'
|
||||
import { questListController } from './controllers/quests'
|
||||
import { commandListController } from './controllers/commands'
|
||||
import {
|
||||
sessionDetailController,
|
||||
sessionListController,
|
||||
} from './controllers/sessions.js'
|
||||
} from './controllers/sessions'
|
||||
|
||||
const router = Router()
|
||||
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "./dist"
|
||||
},
|
||||
"include": ["src"],
|
||||
"references": [{ "path": "../models" }],
|
||||
"include": ["src", "../models"],
|
||||
"ts-node": {
|
||||
"swc": true
|
||||
}
|
||||
|
||||
@@ -1,17 +0,0 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"outDir": "./dist"
|
||||
},
|
||||
"include": ["*.js", "*.ts"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export const toArray = (val: unknown) => (typeof val === 'string' ? [val] : val)
|
||||
+20
-49
@@ -1,10 +1,6 @@
|
||||
/* 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 handleCommand = require('./handleCommand.js')
|
||||
|
||||
const {
|
||||
API_ENDPOINT,
|
||||
@@ -16,13 +12,6 @@ const {
|
||||
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')
|
||||
@@ -31,34 +20,6 @@ async function readOrNone(file) {
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
|
||||
@@ -75,15 +36,21 @@ async function main() {
|
||||
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
|
||||
const response = await fetch(`${API_ENDPOINT}/api/v1/commands`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${TOKEN}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
if (!response.ok) {
|
||||
console.error(`Failed to send command: ${response.statusText}`)
|
||||
const responseBody = await response.text()
|
||||
console.error(`Response Body: ${responseBody}`)
|
||||
}
|
||||
console.error(err.message)
|
||||
} catch (e) {
|
||||
console.error('Error occurred while sending command:', e)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -93,6 +60,10 @@ main()
|
||||
console.log(
|
||||
'發生預期外的錯誤,請嘗試重新啟動 Quest,如果問題持續發生,請幫我們填寫右上角的錯誤回報表單。'
|
||||
)
|
||||
console.error(err)
|
||||
if (err.response && err.response.data) {
|
||||
console.log(err.response.data)
|
||||
} else {
|
||||
console.log(err)
|
||||
}
|
||||
exit(1)
|
||||
})
|
||||
@@ -1,21 +1,33 @@
|
||||
const fs = require('fs').promises
|
||||
const path = require('path')
|
||||
const { resolvePath } = require('./utils')
|
||||
|
||||
async function collectFilesInfo(inputPath, level = 0, hiddenFiles = false) {
|
||||
const stats = await fs.stat(inputPath)
|
||||
let stats
|
||||
try {
|
||||
stats = await fs.stat(inputPath)
|
||||
} catch (e) {
|
||||
if (e.code === 'ENOENT') {
|
||||
return []
|
||||
}
|
||||
throw e
|
||||
}
|
||||
const name = path.basename(inputPath)
|
||||
const discovered = level <= 1 && (hiddenFiles || !name.startsWith('.'))
|
||||
const files = [
|
||||
{
|
||||
path: inputPath,
|
||||
name,
|
||||
type: stats.isDirectory() ? 'folder' : 'file',
|
||||
discovered,
|
||||
},
|
||||
]
|
||||
const self = {
|
||||
path: inputPath,
|
||||
name,
|
||||
type: stats.isDirectory() ? 'directory' : 'file',
|
||||
discovered,
|
||||
}
|
||||
const files = [self]
|
||||
if (stats.isDirectory() && level < 2) {
|
||||
try {
|
||||
let entries = await fs.readdir(inputPath)
|
||||
if (entries.length === 0) {
|
||||
self.empty = true
|
||||
return files
|
||||
}
|
||||
if (level > 0) {
|
||||
entries = entries.slice(0, 5)
|
||||
}
|
||||
@@ -35,15 +47,23 @@ async function collectFilesInfo(inputPath, level = 0, hiddenFiles = false) {
|
||||
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() }
|
||||
const result = (
|
||||
await Promise.all(
|
||||
targetPath
|
||||
.map((p) => resolvePath(p))
|
||||
.map((p) => collectFilesInfo(p, 0, argv.a || argv.all))
|
||||
)
|
||||
).flat()
|
||||
if (result.length === 0) {
|
||||
return {}
|
||||
}
|
||||
return { discover: result }
|
||||
} catch (_) {
|
||||
return {}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = discoverFiles
|
||||
module.exports = {
|
||||
discoverFiles,
|
||||
collectFilesInfo,
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
const fs = require('fs').promises
|
||||
const path = require('path')
|
||||
const minimist = require('minimist')
|
||||
const { discoverFiles, collectFilesInfo } = require('./discover')
|
||||
const { resolvePath } = require('./utils')
|
||||
|
||||
const commandListeners = {
|
||||
ls: {
|
||||
opts: {
|
||||
boolean: ['a', 'l', 'h'],
|
||||
},
|
||||
handler: discoverFiles,
|
||||
},
|
||||
cd: {
|
||||
handler: changeDirectory,
|
||||
},
|
||||
touch: {
|
||||
handler: createFiles,
|
||||
},
|
||||
mkdir: {
|
||||
opts: {
|
||||
boolean: ['p'],
|
||||
},
|
||||
handler: createFiles,
|
||||
},
|
||||
cp: {
|
||||
opts: {
|
||||
boolean: ['r'],
|
||||
},
|
||||
handler: copyFiles,
|
||||
},
|
||||
rm: {
|
||||
opts: {
|
||||
boolean: ['r', 'f', 'v'],
|
||||
},
|
||||
handler: removeFiles,
|
||||
},
|
||||
rmdir: {
|
||||
opts: {
|
||||
boolean: ['p', 'v'],
|
||||
},
|
||||
handler: removeFiles,
|
||||
},
|
||||
mv: {
|
||||
handler: moveFiles,
|
||||
},
|
||||
pwd: {
|
||||
handler: showCurrentPath,
|
||||
},
|
||||
}
|
||||
|
||||
async function handleCommand(command) {
|
||||
const name = command.split(' ')[0]
|
||||
const listener = commandListeners[name]
|
||||
if (!listener) return {}
|
||||
const argv = minimist(command.split(' '), listener.opts)
|
||||
return listener.handler(argv)
|
||||
}
|
||||
|
||||
function changeDirectory() {
|
||||
return {
|
||||
pwd: process.cwd(),
|
||||
}
|
||||
}
|
||||
|
||||
async function createFiles(argv) {
|
||||
const { _ } = argv
|
||||
const files = _.slice(1)
|
||||
const results = []
|
||||
for (const file of files) {
|
||||
const filePath = resolvePath(file)
|
||||
try {
|
||||
const exists = await fs.stat(filePath)
|
||||
if (exists) {
|
||||
results.push({
|
||||
path: filePath,
|
||||
type: exists.isFile() ? 'file' : 'directory',
|
||||
discovered: true,
|
||||
})
|
||||
}
|
||||
} catch (err) {
|
||||
if (err.code !== 'ENOENT') {
|
||||
throw err
|
||||
}
|
||||
}
|
||||
}
|
||||
return {
|
||||
add: results,
|
||||
}
|
||||
}
|
||||
|
||||
async function copyFiles(argv) {
|
||||
const { _ } = argv
|
||||
const files = _.slice(1)
|
||||
if (files.length === 0) {
|
||||
return undefined
|
||||
}
|
||||
const dest = files.pop()
|
||||
const destPath = resolvePath(dest)
|
||||
return {
|
||||
discover: await collectFilesInfo(destPath, 0, true),
|
||||
}
|
||||
}
|
||||
|
||||
async function removeFiles(argv) {
|
||||
const files = argv._.slice(1)
|
||||
const results = []
|
||||
for (const file of files) {
|
||||
const filePath = resolvePath(file)
|
||||
try {
|
||||
await fs.access(filePath, fs.constants.F_OK)
|
||||
} catch {
|
||||
results.push({
|
||||
path: filePath,
|
||||
type: 'file',
|
||||
discovered: false,
|
||||
})
|
||||
}
|
||||
}
|
||||
return {
|
||||
remove: results,
|
||||
}
|
||||
}
|
||||
|
||||
async function moveFiles(argv) {
|
||||
const { _ } = argv
|
||||
const sources = _.slice(1)
|
||||
if (sources.length === 0) {
|
||||
return undefined
|
||||
}
|
||||
const dest = sources.pop()
|
||||
const results = {
|
||||
add: [],
|
||||
remove: [],
|
||||
}
|
||||
// check source
|
||||
for (const source of sources) {
|
||||
const sourcePath = resolvePath(source)
|
||||
try {
|
||||
await fs.access(sourcePath, fs.constants.F_OK)
|
||||
} catch {
|
||||
results.remove.push({
|
||||
path: sourcePath,
|
||||
type: 'file',
|
||||
discovered: false,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// check dest
|
||||
const destPath = resolvePath(dest)
|
||||
results.add = await collectFilesInfo(destPath, 0, true)
|
||||
return results
|
||||
}
|
||||
|
||||
function showCurrentPath() {
|
||||
const pwd = process.cwd()
|
||||
const dirs = pwd.split('/')
|
||||
let currentPath = '/'
|
||||
const result = []
|
||||
for (const dir of dirs) {
|
||||
currentPath = path.join(currentPath, dir)
|
||||
result.push({
|
||||
path: currentPath,
|
||||
type: 'directory',
|
||||
discovered: true,
|
||||
})
|
||||
}
|
||||
return {
|
||||
add: result,
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = handleCommand
|
||||
@@ -8,7 +8,6 @@
|
||||
"watch": ""
|
||||
},
|
||||
"dependencies": {
|
||||
"axios": "^1.6.0",
|
||||
"minimist": "^1.2.8"
|
||||
}
|
||||
}
|
||||
@@ -1,115 +0,0 @@
|
||||
/* 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,
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
const os = require('os')
|
||||
const path = require('path')
|
||||
|
||||
function resolvePath(p) {
|
||||
return path.resolve(process.cwd(), p.replace('~', os.homedir()))
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
resolvePath,
|
||||
}
|
||||
File renamed without changes.
@@ -2,25 +2,28 @@ import { basename } from './utils.js'
|
||||
|
||||
export interface FileObject {
|
||||
path: string
|
||||
type: string
|
||||
type: 'file' | 'directory'
|
||||
discovered: boolean
|
||||
empty?: boolean
|
||||
}
|
||||
|
||||
export default class File implements FileObject {
|
||||
path: string
|
||||
name: string
|
||||
type: string
|
||||
type: 'file' | 'directory'
|
||||
discovered: boolean
|
||||
empty?: boolean
|
||||
|
||||
constructor(obj: FileObject) {
|
||||
this.path = obj.path.trimEnd()
|
||||
this.name = basename(obj.path)
|
||||
this.type = obj.type
|
||||
this.discovered = obj.discovered
|
||||
this.empty = obj.empty && obj.type === 'directory'
|
||||
}
|
||||
|
||||
isDirectory() {
|
||||
return this.type === 'folder'
|
||||
return this.type === 'directory'
|
||||
}
|
||||
|
||||
contains(file: FileObject) {
|
||||
@@ -1,10 +1,11 @@
|
||||
import { FileObject } from './file.js'
|
||||
import FileNode from './fileNode.js'
|
||||
|
||||
export interface Event {
|
||||
export interface FileGraphUpdateEvent {
|
||||
add?: FileObject[]
|
||||
remove?: FileObject[]
|
||||
discover?: FileObject[]
|
||||
pwd?: string
|
||||
}
|
||||
|
||||
export default class FileGraph extends FileNode {
|
||||
@@ -20,7 +21,7 @@ export default class FileGraph extends FileNode {
|
||||
|
||||
remove(files: FileObject[]) {
|
||||
// Logic to remove a file node from the graph
|
||||
// If the file node is a folder, also remove all children
|
||||
// If the file node is a directory, also remove all children
|
||||
// If the file node doesn't exist, throw an error
|
||||
|
||||
files.forEach((file) => {
|
||||
@@ -33,6 +34,10 @@ export default class FileGraph extends FileNode {
|
||||
// Overwrite the file graph with the provided files
|
||||
// Remove file nodes that don't exist anymore and add new ones
|
||||
|
||||
if (eventFiles.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
const newNode = new FileNode(eventFiles[0])
|
||||
|
||||
eventFiles.slice(1).forEach((file) => {
|
||||
@@ -42,19 +47,16 @@ export default class FileGraph extends FileNode {
|
||||
this.merge(newNode)
|
||||
}
|
||||
|
||||
handleEvent(event: Event) {
|
||||
handleEvent(event: FileGraphUpdateEvent) {
|
||||
// eslint-disable-next-line default-case
|
||||
switch (true) {
|
||||
case !!event.add:
|
||||
this.add(event.add)
|
||||
break
|
||||
case !!event.remove:
|
||||
this.remove(event.remove)
|
||||
break
|
||||
case !!event.discover:
|
||||
this.discover(event.discover)
|
||||
break
|
||||
// Implement other cases if needed
|
||||
if (event.add) {
|
||||
this.add(event.add)
|
||||
}
|
||||
if (event.remove) {
|
||||
this.remove(event.remove)
|
||||
}
|
||||
if (event.discover) {
|
||||
this.discover(event.discover)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,15 +1,18 @@
|
||||
import File, { FileObject } from './file.js'
|
||||
import {
|
||||
DuplicateItemError,
|
||||
ParentNotExistsError,
|
||||
FileNotExistsError,
|
||||
} from './errors.js'
|
||||
import { ParentNotExistsError, FileNotExistsError } from './errors.js'
|
||||
import { dirname } from './utils.js'
|
||||
|
||||
export default class FileNode extends File {
|
||||
export interface IFileNode {
|
||||
path: string
|
||||
type: 'file' | 'directory'
|
||||
discovered: boolean
|
||||
children?: IFileNode[]
|
||||
}
|
||||
|
||||
export default class FileNode extends File implements IFileNode {
|
||||
children: FileNode[]
|
||||
|
||||
constructor(file: FileObject | FileNode) {
|
||||
constructor(file: FileObject | IFileNode) {
|
||||
super(file)
|
||||
this.children = []
|
||||
if ((<FileNode>file).children)
|
||||
@@ -22,15 +25,24 @@ export default class FileNode extends File {
|
||||
) {
|
||||
const file = new FileNode(fileInput)
|
||||
if (file.path === this.path) {
|
||||
throw new DuplicateItemError(
|
||||
`File ${file.path} already exists in the tree: ${this.toString()}`
|
||||
)
|
||||
if (!this.discovered) {
|
||||
this.discovered = file.discovered
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (this.children.some((child) => child.path === file.path)) {
|
||||
throw new DuplicateItemError(
|
||||
`File ${file.path} already exists in the tree: ${this.toString()}`
|
||||
)
|
||||
let existing = false
|
||||
for (const child of this.children) {
|
||||
if (child.path === file.path) {
|
||||
existing = true
|
||||
if (!child.discovered) {
|
||||
child.discovered = file.discovered
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
if (existing) {
|
||||
return
|
||||
}
|
||||
|
||||
const parentPath = dirname(file.path)
|
||||
@@ -41,11 +53,11 @@ export default class FileNode extends File {
|
||||
// Recursively call addChild on the correct child node
|
||||
const childNode = this.children.find((child) => child.contains(file))
|
||||
if (childNode) {
|
||||
childNode.addChild(file)
|
||||
childNode.addChild(file, { makeParents })
|
||||
} else if (makeParents) {
|
||||
const parent = new FileNode({
|
||||
path: parentPath,
|
||||
type: 'folder',
|
||||
type: 'directory',
|
||||
discovered: false,
|
||||
children: [file],
|
||||
})
|
||||
@@ -88,6 +100,12 @@ export default class FileNode extends File {
|
||||
// Update the state of this node
|
||||
this.discovered = this.discovered || fileNode.discovered
|
||||
|
||||
// the provided directory is empty, remove all children
|
||||
if (fileNode.empty) {
|
||||
this.children = []
|
||||
return
|
||||
}
|
||||
|
||||
fileNode.children.forEach((child) => {
|
||||
const existingChild = this.children.find((c) => c.path === child.path)
|
||||
if (existingChild) {
|
||||
@@ -133,4 +151,11 @@ export default class FileNode extends File {
|
||||
.join('')
|
||||
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))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
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'
|
||||
@@ -1,14 +0,0 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
export { default as File, FileObject } from './file.js'
|
||||
export { default as FileNode } from './fileNode.js'
|
||||
export { default as FileGraph, Event } from './fileGraph.js'
|
||||
@@ -1,10 +1,10 @@
|
||||
import { describe, test, expect } from '@jest/globals'
|
||||
import File from '../src/file.js'
|
||||
import File from '../file.js'
|
||||
|
||||
describe('File', () => {
|
||||
let file: File
|
||||
beforeEach(() => {
|
||||
file = new File({ path: '/home/user', type: 'folder', discovered: true })
|
||||
file = new File({ path: '/home/user', type: 'directory', discovered: true })
|
||||
})
|
||||
|
||||
const filesToCompare = [
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { describe, test, expect } from '@jest/globals'
|
||||
import FileGraph from '../src/fileGraph.js'
|
||||
import FileNode from '../src/fileNode.js'
|
||||
import FileGraph from '../fileGraph.js'
|
||||
import FileNode from '../fileNode.js'
|
||||
import { FileObject } from '../file.js'
|
||||
|
||||
describe('FileGraph', () => {
|
||||
let root: FileNode
|
||||
@@ -8,7 +9,7 @@ describe('FileGraph', () => {
|
||||
beforeEach(() => {
|
||||
root = new FileNode({
|
||||
path: '/',
|
||||
type: 'folder',
|
||||
type: 'directory',
|
||||
discovered: true,
|
||||
})
|
||||
})
|
||||
@@ -16,7 +17,7 @@ describe('FileGraph', () => {
|
||||
test('add files to graph', () => {
|
||||
const fileGraph = new FileGraph(root)
|
||||
|
||||
const filesToAdd = [
|
||||
const filesToAdd: FileObject[] = [
|
||||
{
|
||||
path: '/home/user/file1.txt',
|
||||
type: 'file',
|
||||
@@ -24,7 +25,12 @@ describe('FileGraph', () => {
|
||||
},
|
||||
{
|
||||
path: '/home/user/folder1',
|
||||
type: 'folder',
|
||||
type: 'directory',
|
||||
discovered: true,
|
||||
},
|
||||
{
|
||||
path: '/home/user/folder2/file2.txt',
|
||||
type: 'file',
|
||||
discovered: true,
|
||||
},
|
||||
]
|
||||
@@ -32,7 +38,34 @@ describe('FileGraph', () => {
|
||||
fileGraph.add(filesToAdd)
|
||||
|
||||
expect(fileGraph.children.length).toBe(1)
|
||||
expect(fileGraph.children[0].children[0].children.length).toBe(2)
|
||||
expect(fileGraph.children[0].children[0].children.length).toBe(3)
|
||||
})
|
||||
|
||||
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', () => {
|
||||
@@ -41,12 +74,12 @@ describe('FileGraph', () => {
|
||||
const filesToAdd = [
|
||||
{
|
||||
path: '/home/user/folder1',
|
||||
type: 'folder',
|
||||
type: 'directory',
|
||||
discovered: true,
|
||||
},
|
||||
{
|
||||
path: '/home/user/folder2',
|
||||
type: 'folder',
|
||||
type: 'directory',
|
||||
discovered: true,
|
||||
},
|
||||
{
|
||||
@@ -61,22 +94,22 @@ describe('FileGraph', () => {
|
||||
},
|
||||
]
|
||||
|
||||
fileGraph.add(filesToAdd)
|
||||
fileGraph.add(filesToAdd as FileObject[])
|
||||
|
||||
const filesToRemove = [
|
||||
{
|
||||
path: '/home/user/folder1/file1.txt',
|
||||
type: 'folder',
|
||||
type: 'directory',
|
||||
discovered: true,
|
||||
},
|
||||
{
|
||||
path: '/home/user/folder2',
|
||||
type: 'folder',
|
||||
type: 'directory',
|
||||
discovered: true,
|
||||
},
|
||||
]
|
||||
|
||||
fileGraph.remove(filesToRemove)
|
||||
fileGraph.remove(filesToRemove as FileObject[])
|
||||
|
||||
expect(fileGraph.children[0].children[0].children.length).toBe(1)
|
||||
expect(fileGraph.children[0].children[0].children[0].name).toBe('folder1')
|
||||
@@ -90,7 +123,7 @@ describe('FileGraph', () => {
|
||||
const discoverFiles = [
|
||||
{
|
||||
path: '/home/user/folder1',
|
||||
type: 'folder',
|
||||
type: 'directory',
|
||||
discovered: true,
|
||||
},
|
||||
{
|
||||
@@ -110,7 +143,7 @@ describe('FileGraph', () => {
|
||||
},
|
||||
]
|
||||
|
||||
fileGraph.discover(discoverFiles)
|
||||
fileGraph.discover(discoverFiles as FileObject[])
|
||||
expect(fileGraph.children[0].children[0].children[0].children.length).toBe(
|
||||
3
|
||||
)
|
||||
@@ -122,12 +155,12 @@ describe('FileGraph', () => {
|
||||
const filesToAdd = [
|
||||
{
|
||||
path: '/home/user/folder1',
|
||||
type: 'folder',
|
||||
type: 'directory',
|
||||
discovered: true,
|
||||
},
|
||||
{
|
||||
path: '/home/user/folder2',
|
||||
type: 'folder',
|
||||
type: 'directory',
|
||||
discovered: true,
|
||||
},
|
||||
{
|
||||
@@ -142,12 +175,12 @@ describe('FileGraph', () => {
|
||||
},
|
||||
]
|
||||
|
||||
fileGraph.add(filesToAdd)
|
||||
fileGraph.add(filesToAdd as FileObject[])
|
||||
|
||||
const newFiles = [
|
||||
{
|
||||
path: '/home/user/folder1',
|
||||
type: 'folder',
|
||||
type: 'directory',
|
||||
discovered: true,
|
||||
},
|
||||
{
|
||||
@@ -167,11 +200,43 @@ describe('FileGraph', () => {
|
||||
},
|
||||
]
|
||||
|
||||
fileGraph.discover(newFiles)
|
||||
fileGraph.discover(newFiles as FileObject[])
|
||||
|
||||
expect(fileGraph.children[0].children[0].children.length).toBe(2)
|
||||
expect(fileGraph.children[0].children[0].children[0].children.length).toBe(
|
||||
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
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -1,7 +1,7 @@
|
||||
import { describe, test, expect } from '@jest/globals'
|
||||
import { DuplicateItemError, ParentNotExistsError } from '../src/errors.js'
|
||||
import FileNode from '../src/fileNode.js'
|
||||
import { FileObject } from '../src/file.js'
|
||||
import { ParentNotExistsError } from '../errors.js'
|
||||
import FileNode, { IFileNode } from '../fileNode.js'
|
||||
import { FileObject } from '../file.js'
|
||||
|
||||
function createNodeFromFiles(files: FileObject[]) {
|
||||
const node = new FileNode(files[0])
|
||||
@@ -42,17 +42,16 @@ describe('FileNode', () => {
|
||||
beforeEach(() => {
|
||||
rootNode = new FileNode({
|
||||
path: '/home/rudeus',
|
||||
name: 'rudeus',
|
||||
type: 'folder',
|
||||
type: 'directory',
|
||||
discovered: true,
|
||||
})
|
||||
})
|
||||
|
||||
test('should throw error when adding duplicate item', () => {
|
||||
const child = {
|
||||
test('should override discovered when adding duplicate item', () => {
|
||||
const child: FileObject = {
|
||||
path: '/home/rudeus/test',
|
||||
type: 'folder',
|
||||
discovered: true,
|
||||
type: 'directory',
|
||||
discovered: false,
|
||||
}
|
||||
|
||||
// Adding the child for the first time
|
||||
@@ -61,16 +60,16 @@ describe('FileNode', () => {
|
||||
expect(rootNode.children.length).toBe(1)
|
||||
|
||||
// Trying to add the same child again should throw an error
|
||||
expect(() => {
|
||||
rootNode.addChild(child)
|
||||
}).toThrow(DuplicateItemError)
|
||||
child.discovered = true
|
||||
rootNode.addChild(child)
|
||||
|
||||
expect(rootNode.children[0].discovered).toBe(true)
|
||||
})
|
||||
|
||||
test('should throw error when parent does not exist', () => {
|
||||
const child = {
|
||||
const child: FileObject = {
|
||||
path: '/home/rudeus/alphabet/test',
|
||||
name: 'test',
|
||||
type: 'folder',
|
||||
type: 'directory',
|
||||
discovered: true,
|
||||
}
|
||||
|
||||
@@ -81,22 +80,19 @@ describe('FileNode', () => {
|
||||
})
|
||||
|
||||
test('adds nested children to the tree', () => {
|
||||
const files = [
|
||||
const files: FileObject[] = [
|
||||
{
|
||||
path: '/home/rudeus/forgotten_scroll.txt',
|
||||
name: 'forgotten_scroll.txt',
|
||||
type: 'file',
|
||||
discovered: true,
|
||||
},
|
||||
{
|
||||
path: '/home/rudeus/test',
|
||||
name: 'test',
|
||||
type: 'folder',
|
||||
type: 'directory',
|
||||
discovered: true,
|
||||
},
|
||||
{
|
||||
path: '/home/rudeus/test/a',
|
||||
name: 'a',
|
||||
type: 'file',
|
||||
discovered: false,
|
||||
},
|
||||
@@ -111,9 +107,8 @@ describe('FileNode', () => {
|
||||
})
|
||||
|
||||
test('adds nested children to the tree with makeParents option', () => {
|
||||
const file = {
|
||||
const file: FileObject = {
|
||||
path: '/home/rudeus/test/a/b/c/d/e',
|
||||
name: 'a',
|
||||
type: 'file',
|
||||
discovered: false,
|
||||
}
|
||||
@@ -129,34 +124,34 @@ describe('FileNode', () => {
|
||||
})
|
||||
|
||||
test('merge-subtree', () => {
|
||||
const root = {
|
||||
const root: FileObject = {
|
||||
path: '/',
|
||||
type: 'folder',
|
||||
type: 'directory',
|
||||
discovered: true,
|
||||
}
|
||||
const files1 = [
|
||||
const files1: FileObject[] = [
|
||||
root,
|
||||
{ path: '/a', type: 'file', discovered: true },
|
||||
{ path: '/b', type: 'folder', discovered: true },
|
||||
{ path: '/b/c', type: 'folder', discovered: true },
|
||||
{ path: '/b/d', type: 'file', discovered: true },
|
||||
{ path: '/b/c/f', type: 'file', discovered: true },
|
||||
{ path: '/a', type: 'file' as const, discovered: true },
|
||||
{ path: '/b', type: 'directory' as const, discovered: true },
|
||||
{ path: '/b/c', type: 'directory' as const, discovered: true },
|
||||
{ path: '/b/d', type: 'file' as const, discovered: true },
|
||||
{ path: '/b/c/f', type: 'file' as const, discovered: true },
|
||||
]
|
||||
|
||||
const files2 = [
|
||||
{ path: '/b', type: 'folder', discovered: true },
|
||||
{ path: '/b/c', type: 'folder', discovered: true },
|
||||
{ path: '/b/e', type: 'file', discovered: true },
|
||||
{ path: '/b/c/g', type: 'file', discovered: true },
|
||||
const files2: FileObject[] = [
|
||||
{ path: '/b', type: 'directory' as const, discovered: true },
|
||||
{ path: '/b/c', type: 'directory' as const, discovered: true },
|
||||
{ path: '/b/e', type: 'file' as const, discovered: true },
|
||||
{ path: '/b/c/g', type: 'file' as const, discovered: true },
|
||||
]
|
||||
|
||||
const expected = [
|
||||
const expected: FileObject[] = [
|
||||
root,
|
||||
{ path: '/a', type: 'file', discovered: true },
|
||||
{ path: '/b', type: 'folder', discovered: true },
|
||||
{ path: '/b/c', type: 'folder', discovered: true },
|
||||
{ path: '/b/e', type: 'file', discovered: true },
|
||||
{ path: '/b/c/g', type: 'file', discovered: true },
|
||||
{ path: '/a', type: 'file' as const, discovered: true },
|
||||
{ path: '/b', type: 'directory' as const, discovered: true },
|
||||
{ path: '/b/c', type: 'directory' as const, discovered: true },
|
||||
{ path: '/b/e', type: 'file' as const, discovered: true },
|
||||
{ path: '/b/c/g', type: 'file' as const, discovered: true },
|
||||
]
|
||||
|
||||
mergeTest(files1, files2, expected)
|
||||
@@ -166,90 +161,94 @@ describe('FileNode', () => {
|
||||
// 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)
|
||||
// The original tree should keep the children that are not in the node to merge
|
||||
const root = {
|
||||
const root: FileObject = {
|
||||
path: '/',
|
||||
type: 'folder',
|
||||
type: 'directory',
|
||||
discovered: true,
|
||||
}
|
||||
const files1 = [
|
||||
const files1: FileObject[] = [
|
||||
root,
|
||||
{ path: '/a', type: 'file', discovered: true },
|
||||
{ path: '/b', type: 'folder', discovered: true },
|
||||
{ path: '/b/c', type: 'folder', discovered: true },
|
||||
{ path: '/b/d', type: 'file', discovered: true },
|
||||
{ path: '/b/c/f', type: 'file', discovered: true },
|
||||
{ path: '/a', type: 'file' as const, discovered: true },
|
||||
{ path: '/b', type: 'directory' as const, discovered: true },
|
||||
{ path: '/b/c', type: 'directory' as const, discovered: true },
|
||||
{ path: '/b/d', type: 'file' as const, discovered: true },
|
||||
{ path: '/b/c/f', type: 'file' as const, discovered: true },
|
||||
]
|
||||
|
||||
const files2 = [
|
||||
{ path: '/b', type: 'folder', discovered: true },
|
||||
{ path: '/b/c', type: 'folder', discovered: true },
|
||||
{ path: '/b/e', type: 'file', discovered: true },
|
||||
const files2: FileObject[] = [
|
||||
{ path: '/b', type: 'directory' as const, discovered: true },
|
||||
{ path: '/b/c', type: 'directory' as const, discovered: true },
|
||||
{ path: '/b/e', type: 'file' as const, discovered: true },
|
||||
]
|
||||
|
||||
const expected = [
|
||||
const expected: FileObject[] = [
|
||||
root,
|
||||
{ path: '/a', type: 'file', discovered: true },
|
||||
{ path: '/b', type: 'folder', discovered: true },
|
||||
{ path: '/b/c', type: 'folder', discovered: true },
|
||||
{ path: '/b/e', type: 'file', discovered: true },
|
||||
{ path: '/b/c/f', type: 'file', discovered: true },
|
||||
{ path: '/a', type: 'file' as const, discovered: true },
|
||||
{ path: '/b', type: 'directory' as const, discovered: true },
|
||||
{ path: '/b/c', type: 'directory' as const, discovered: true },
|
||||
{ path: '/b/e', type: 'file' as const, discovered: true },
|
||||
{ path: '/b/c/f', type: 'file' as const, discovered: true },
|
||||
]
|
||||
|
||||
mergeTest(files1, files2, expected)
|
||||
})
|
||||
|
||||
test('merge-hidden', () => {
|
||||
const root = {
|
||||
const root: FileObject = {
|
||||
path: '/',
|
||||
type: 'folder',
|
||||
type: 'directory',
|
||||
discovered: true,
|
||||
}
|
||||
const files1 = [
|
||||
const files1: FileObject[] = [
|
||||
root,
|
||||
{ path: '/a', type: 'file', discovered: true },
|
||||
{ path: '/.b', type: 'file', discovered: false },
|
||||
{ path: '/c', type: 'folder', discovered: true },
|
||||
{ path: '/c/.d', type: 'file', discovered: false },
|
||||
{ path: '/a', type: 'file' as const, discovered: true },
|
||||
{ path: '/.b', type: 'file' as const, discovered: false },
|
||||
{ path: '/c', type: 'directory' as const, discovered: true },
|
||||
{ path: '/c/.d', type: 'file' as const, discovered: false },
|
||||
]
|
||||
|
||||
const files2 = [
|
||||
{ path: '/c', type: 'folder', discovered: true },
|
||||
{ path: '/c/.d', type: 'file', discovered: true },
|
||||
const files2: FileObject[] = [
|
||||
{ path: '/c', type: 'directory' as const, discovered: true },
|
||||
{ path: '/c/.d', type: 'file' as const, discovered: true },
|
||||
]
|
||||
|
||||
const expected = [
|
||||
const expected: FileObject[] = [
|
||||
root,
|
||||
{ path: '/a', type: 'file', discovered: true },
|
||||
{ path: '/.b', type: 'file', discovered: false },
|
||||
{ path: '/c', type: 'folder', discovered: true },
|
||||
{ path: '/c/.d', type: 'file', discovered: true },
|
||||
{ path: '/a', type: 'file' as const, discovered: true },
|
||||
{ path: '/.b', type: 'file' as const, discovered: false },
|
||||
{ path: '/c', type: 'directory' as const, discovered: true },
|
||||
{ path: '/c/.d', type: 'file' as const, discovered: true },
|
||||
]
|
||||
|
||||
mergeTest(files1, files2, expected)
|
||||
})
|
||||
|
||||
test('remove-child', () => {
|
||||
const root = {
|
||||
const root: FileObject = {
|
||||
path: '/',
|
||||
type: 'folder',
|
||||
type: 'directory',
|
||||
discovered: true,
|
||||
}
|
||||
const files = [
|
||||
const files: FileObject[] = [
|
||||
root,
|
||||
{ path: '/a', type: 'file', discovered: true },
|
||||
{ path: '/b', type: 'folder', discovered: true },
|
||||
{ path: '/b/c', type: 'folder', discovered: true },
|
||||
{ path: '/b/d', type: 'file', discovered: true },
|
||||
{ path: '/b/c/f', type: 'file', discovered: true },
|
||||
{ path: '/a', type: 'file' as const, discovered: true },
|
||||
{ path: '/b', type: 'directory' as const, discovered: true },
|
||||
{ path: '/b/c', type: 'directory' as const, discovered: true },
|
||||
{ path: '/b/d', type: 'file' as const, discovered: true },
|
||||
{ path: '/b/c/f', type: 'file' as const, discovered: true },
|
||||
]
|
||||
|
||||
const fileToRemove = { path: '/b/c', type: 'folder', discovered: true }
|
||||
const fileToRemove: FileObject = {
|
||||
path: '/b/c',
|
||||
type: 'directory',
|
||||
discovered: true,
|
||||
}
|
||||
|
||||
const expected = [
|
||||
const expected: FileObject[] = [
|
||||
root,
|
||||
{ path: '/a', type: 'file', discovered: true },
|
||||
{ path: '/b', type: 'folder', discovered: true },
|
||||
{ path: '/b/d', type: 'file', discovered: true },
|
||||
{ path: '/a', type: 'file' as const, discovered: true },
|
||||
{ path: '/b', type: 'directory' as const, discovered: true },
|
||||
{ path: '/b/d', type: 'file' as const, discovered: true },
|
||||
]
|
||||
|
||||
removeChildTest(files, fileToRemove, expected)
|
||||
@@ -257,9 +256,9 @@ describe('FileNode', () => {
|
||||
})
|
||||
|
||||
test('load nested fileNode', () => {
|
||||
const data = {
|
||||
const data: IFileNode = {
|
||||
path: '/',
|
||||
type: 'folder',
|
||||
type: 'directory',
|
||||
discovered: true,
|
||||
children: [
|
||||
{
|
||||
@@ -269,12 +268,12 @@ test('load nested fileNode', () => {
|
||||
},
|
||||
{
|
||||
path: '/b',
|
||||
type: 'folder',
|
||||
type: 'directory',
|
||||
discovered: true,
|
||||
children: [
|
||||
{
|
||||
path: '/b/c',
|
||||
type: 'folder',
|
||||
type: 'directory',
|
||||
discovered: true,
|
||||
children: [
|
||||
{
|
||||
|
||||
@@ -1,10 +0,0 @@
|
||||
{
|
||||
"extends": "../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"composite": true,
|
||||
"outDir": "./dist"
|
||||
},
|
||||
"include": ["src"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
File renamed without changes.
@@ -0,0 +1,48 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
/* 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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
/* eslint-disable no-await-in-loop */
|
||||
import { Event } from './Event'
|
||||
import { GlobalException } from './Exception'
|
||||
import {
|
||||
IQuest,
|
||||
IFileExistenceChecker,
|
||||
ICommand,
|
||||
IResponse,
|
||||
ITask,
|
||||
} from './schema'
|
||||
import { Stage } from './Stage'
|
||||
|
||||
export class Quest {
|
||||
private quest: IQuest
|
||||
private stages: Stage[]
|
||||
private exceptions: GlobalException[] = []
|
||||
private events: Map<string, Event> = new Map()
|
||||
|
||||
constructor(
|
||||
quest: IQuest,
|
||||
private checker: IFileExistenceChecker
|
||||
) {
|
||||
this.quest = quest
|
||||
this.stages = quest.stages.map((stage) => new Stage(stage))
|
||||
for (const stage of this.stages) {
|
||||
this.events.set(stage.id, stage)
|
||||
for (const exception of stage.getExceptions()) {
|
||||
this.events.set(exception.id, exception)
|
||||
}
|
||||
}
|
||||
for (const exception of quest.exceptions ?? []) {
|
||||
const globalException = new GlobalException(exception)
|
||||
this.exceptions.push(globalException)
|
||||
this.events.set(globalException.id, globalException)
|
||||
}
|
||||
}
|
||||
|
||||
getActiveStages(completed: string[]) {
|
||||
return this.stages.filter((stage) => stage.active(completed))
|
||||
}
|
||||
|
||||
private getActiveExceptions(completed: string[]) {
|
||||
return this.exceptions.filter((exception) => exception.active(completed))
|
||||
}
|
||||
|
||||
async findSatisfiedEvent(
|
||||
command: ICommand,
|
||||
completed: string[]
|
||||
): Promise<string | null> {
|
||||
const stages = this.getActiveStages(completed)
|
||||
let catchAllException: string | null = null
|
||||
for (const stage of stages) {
|
||||
const satisfies = await stage.satisfies(command, this.checker)
|
||||
if (satisfies) {
|
||||
return stage.id
|
||||
}
|
||||
}
|
||||
for (const exception of stages.flatMap((stage) => stage.getExceptions())) {
|
||||
if (exception.catchAll && !catchAllException) {
|
||||
catchAllException = exception.id
|
||||
} else if (await exception.satisfies(command, this.checker)) {
|
||||
return exception.id
|
||||
}
|
||||
}
|
||||
for (const exception of this.getActiveExceptions(completed)) {
|
||||
if (await exception.satisfies(command, this.checker)) {
|
||||
return exception.id
|
||||
}
|
||||
}
|
||||
return catchAllException
|
||||
}
|
||||
|
||||
getResponses(completed: string[]): IResponse[] {
|
||||
return completed.map((id) => this.events.get(id)!.getResponse())
|
||||
}
|
||||
|
||||
getTasks(completed: string[]): ITask[] {
|
||||
const completedSet = new Set(completed)
|
||||
const completedStages = this.stages
|
||||
.filter((stage) => completedSet.has(stage.id))
|
||||
.map((stage) => ({
|
||||
id: stage.id,
|
||||
name: stage.name,
|
||||
completed: true,
|
||||
}))
|
||||
const activeStages = this.getActiveStages(completed).map((stage) => ({
|
||||
id: stage.id,
|
||||
name: stage.name,
|
||||
completed: false,
|
||||
}))
|
||||
return [...completedStages, ...activeStages]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import { FileGraph, IFileNode } from '../file-graph'
|
||||
import { Quest } from './Quest'
|
||||
import {
|
||||
IQuest,
|
||||
ICommand,
|
||||
ISession,
|
||||
IFileExistenceChecker,
|
||||
IResponse,
|
||||
ITask,
|
||||
} from './schema'
|
||||
|
||||
export class GameSession {
|
||||
private completed: string[] = []
|
||||
private quest: Quest
|
||||
private graph: FileGraph
|
||||
|
||||
constructor(
|
||||
{ completedEvents, graph }: Partial<ISession>,
|
||||
quest: IQuest,
|
||||
checker: IFileExistenceChecker
|
||||
) {
|
||||
this.completed = completedEvents || []
|
||||
this.quest = new Quest(quest, checker)
|
||||
this.graph = new FileGraph(
|
||||
graph ?? { path: '/', type: 'directory', discovered: false }
|
||||
)
|
||||
}
|
||||
|
||||
get completedEvents() {
|
||||
return this.completed
|
||||
}
|
||||
|
||||
complete(stageId: string) {
|
||||
this.completed.push(stageId)
|
||||
}
|
||||
|
||||
getActiveStages(): string[] {
|
||||
return this.quest.getActiveStages(this.completed).map((stage) => stage.id)
|
||||
}
|
||||
|
||||
async runCommand(command: ICommand) {
|
||||
if (command.params) {
|
||||
this.graph.handleEvent(command.params)
|
||||
}
|
||||
const stageId = await this.quest.findSatisfiedEvent(command, this.completed)
|
||||
if (stageId) {
|
||||
this.complete(stageId)
|
||||
}
|
||||
return stageId
|
||||
}
|
||||
|
||||
isFinished() {
|
||||
return this.getActiveStages().length === 0
|
||||
}
|
||||
|
||||
getResponses(): IResponse[] {
|
||||
return this.quest.getResponses(this.completed)
|
||||
}
|
||||
|
||||
getTasks(): ITask[] {
|
||||
return this.quest.getTasks(this.completed)
|
||||
}
|
||||
|
||||
getGraph(): IFileNode {
|
||||
return this.graph
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { IStage } from './schema'
|
||||
import { StageException } from './Exception'
|
||||
import { Event } from './Event'
|
||||
|
||||
export class Stage extends Event {
|
||||
name: string
|
||||
private exceptions: StageException[]
|
||||
|
||||
constructor({
|
||||
id,
|
||||
name,
|
||||
condition,
|
||||
requirements,
|
||||
exceptions,
|
||||
response,
|
||||
}: IStage) {
|
||||
super(id, condition, response, requirements)
|
||||
this.name = name || ''
|
||||
this.exceptions =
|
||||
exceptions?.map((exception) => new StageException(exception)) || []
|
||||
}
|
||||
|
||||
getExceptions(): StageException[] {
|
||||
return this.exceptions
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
// eslint-disable-next-line import/no-extraneous-dependencies
|
||||
import { zodToJsonSchema } from 'zod-to-json-schema'
|
||||
import fs from 'fs'
|
||||
import { questSchema } from './schema'
|
||||
|
||||
const jsonSchema = zodToJsonSchema(questSchema)
|
||||
|
||||
fs.writeFileSync('quest.json', JSON.stringify(jsonSchema, null, 2))
|
||||
@@ -0,0 +1,13 @@
|
||||
import { ICommand } from '../schema'
|
||||
import { Matcher } from './Matcher.js'
|
||||
|
||||
export class CommandMatcher implements Matcher {
|
||||
private matcher: RegExp
|
||||
constructor(command: string, flags?: string) {
|
||||
this.matcher = new RegExp(command, flags)
|
||||
}
|
||||
|
||||
match(command: ICommand): boolean {
|
||||
return !!command.command && this.matcher.test(command.command)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import {
|
||||
ICommand,
|
||||
IFileExistenceChecker,
|
||||
ICondition,
|
||||
IFileExistenceInput,
|
||||
} from '../schema'
|
||||
import { PwdMatcher } from './PwdMatcher.js'
|
||||
import { CommandMatcher } from './CommandMatcher.js'
|
||||
import { ErrorMatcher } from './ErrorMatcher.js'
|
||||
import { Matcher } from './Matcher.js'
|
||||
import { OutputMatcher } from './OutputMatcher.js'
|
||||
import { OrMatcher } from './OrMatcher.js'
|
||||
import { NotMatcher } from './NotMatcher.js'
|
||||
import { checkFiles } from './FileMatcher.js'
|
||||
|
||||
export class Condition {
|
||||
private files: IFileExistenceInput[] = []
|
||||
private matchers: Matcher[] = []
|
||||
|
||||
constructor({ command, output, error, pwd, or, not, files }: ICondition) {
|
||||
if (command) this.matchers.push(new CommandMatcher(command))
|
||||
if (output) this.matchers.push(new OutputMatcher(output))
|
||||
if (pwd) this.matchers.push(new PwdMatcher(pwd))
|
||||
if (or) {
|
||||
const conditions = or.map((condition) => new Condition(condition))
|
||||
this.matchers.push(new OrMatcher(conditions))
|
||||
}
|
||||
if (not) this.matchers.push(new NotMatcher(new Condition(not)))
|
||||
if (files) this.files = files
|
||||
this.matchers.push(new ErrorMatcher(error)) // always check unexpected error
|
||||
}
|
||||
|
||||
match(command: ICommand): boolean {
|
||||
return this.matchers.every((matcher) => {
|
||||
return matcher.match(command)
|
||||
})
|
||||
}
|
||||
|
||||
async check(checker: IFileExistenceChecker): Promise<boolean> {
|
||||
if (this.matchers.length === 0) return true
|
||||
return checkFiles(checker, this.files)
|
||||
}
|
||||
|
||||
async satisfies(
|
||||
command: ICommand,
|
||||
checker: IFileExistenceChecker
|
||||
): Promise<boolean> {
|
||||
// Error matcher is always true
|
||||
if (this.matchers.length === 1 && this.files.length === 0) return false
|
||||
// eslint-disable-next-line no-return-await
|
||||
return this.match(command) && (await this.check(checker))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { Matcher } from './Matcher.js'
|
||||
import { ICommand } from '../schema'
|
||||
|
||||
export class ErrorMatcher implements Matcher {
|
||||
private matcher?: RegExp
|
||||
constructor(errorPattern?: string) {
|
||||
if (errorPattern) {
|
||||
this.matcher = new RegExp(errorPattern)
|
||||
}
|
||||
}
|
||||
|
||||
match(command: ICommand): boolean {
|
||||
if (!this.matcher) {
|
||||
return !command.error
|
||||
}
|
||||
return this.matcher.test(command.error || '')
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { IFileExistenceInput, IFileExistenceChecker } from '../schema'
|
||||
|
||||
export async function checkFiles(
|
||||
checker: IFileExistenceChecker,
|
||||
files: IFileExistenceInput[]
|
||||
) {
|
||||
const existences = await Promise.all(
|
||||
files.map((file) => checker.exists(file))
|
||||
)
|
||||
return existences.every((exists, index) => exists === files[index].exists)
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { ICommand } from '../schema'
|
||||
|
||||
export interface Matcher {
|
||||
match(command: ICommand): boolean
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { ICommand } from '../schema'
|
||||
import { Matcher } from './Matcher.js'
|
||||
|
||||
export class NotMatcher implements Matcher {
|
||||
constructor(private condition: Matcher) {}
|
||||
|
||||
match(command: ICommand): boolean {
|
||||
return !this.condition.match(command)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { ICommand } from '../schema'
|
||||
import { Matcher } from './Matcher.js'
|
||||
|
||||
export class OrMatcher implements Matcher {
|
||||
constructor(private conditions: Matcher[]) {}
|
||||
|
||||
match(command: ICommand): boolean {
|
||||
return this.conditions.some((condition) => condition.match(command))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { ICommand } from '../schema'
|
||||
import { Matcher } from './Matcher.js'
|
||||
|
||||
export class OutputMatcher implements Matcher {
|
||||
private matcher: RegExp
|
||||
constructor(pattern: string, args?: string) {
|
||||
this.matcher = new RegExp(pattern, args)
|
||||
}
|
||||
|
||||
match(command: ICommand): boolean {
|
||||
return !!command.output && this.matcher.test(command.output.trim())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { ICommand } from '../schema'
|
||||
import { Matcher } from './Matcher.js'
|
||||
|
||||
export class PwdMatcher implements Matcher {
|
||||
constructor(private pwd: string) {}
|
||||
|
||||
match(command: ICommand): boolean {
|
||||
return command.pwd === this.pwd
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export * from './schema'
|
||||
export * from './Quest'
|
||||
export * from './Session'
|
||||
export * from './Event'
|
||||
export * from './Stage'
|
||||
export * from './Exception'
|
||||
@@ -0,0 +1,159 @@
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "string"
|
||||
},
|
||||
"title": {
|
||||
"type": "string"
|
||||
},
|
||||
"image": {
|
||||
"type": "string"
|
||||
},
|
||||
"instruction": {
|
||||
"type": "string"
|
||||
},
|
||||
"requirements": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"default": []
|
||||
},
|
||||
"stages": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "string",
|
||||
"pattern": "^[a-zA-Z0-9_-]+$"
|
||||
},
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"requirements": {
|
||||
"$ref": "#/properties/requirements",
|
||||
"default": []
|
||||
},
|
||||
"condition": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"command": {
|
||||
"type": "string"
|
||||
},
|
||||
"output": {
|
||||
"type": "string"
|
||||
},
|
||||
"error": {
|
||||
"type": "string"
|
||||
},
|
||||
"pwd": {
|
||||
"type": "string"
|
||||
},
|
||||
"files": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"path": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": {
|
||||
"type": "string",
|
||||
"enum": ["file", "directory"]
|
||||
},
|
||||
"exists": {
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
"required": ["path", "type", "exists"],
|
||||
"additionalProperties": false
|
||||
}
|
||||
},
|
||||
"or": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/properties/stages/items/properties/condition"
|
||||
}
|
||||
},
|
||||
"not": {
|
||||
"$ref": "#/properties/stages/items/properties/condition"
|
||||
}
|
||||
},
|
||||
"additionalProperties": false
|
||||
},
|
||||
"response": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": {
|
||||
"type": "string",
|
||||
"enum": ["narrative", "dialogue"],
|
||||
"default": "narrative"
|
||||
},
|
||||
"content": {
|
||||
"type": "string"
|
||||
},
|
||||
"hint": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": ["content"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"exceptions": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "string"
|
||||
},
|
||||
"condition": {
|
||||
"$ref": "#/properties/stages/items/properties/condition"
|
||||
},
|
||||
"response": {
|
||||
"$ref": "#/properties/stages/items/properties/response"
|
||||
},
|
||||
"catchAll": {
|
||||
"type": "boolean",
|
||||
"default": false
|
||||
}
|
||||
},
|
||||
"required": ["id", "condition", "response"],
|
||||
"additionalProperties": false
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": ["id", "condition", "response"],
|
||||
"additionalProperties": false
|
||||
}
|
||||
},
|
||||
"exceptions": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {
|
||||
"$ref": "#/properties/stages/items/properties/exceptions/items/properties/id"
|
||||
},
|
||||
"condition": {
|
||||
"$ref": "#/properties/stages/items/properties/condition"
|
||||
},
|
||||
"response": {
|
||||
"$ref": "#/properties/stages/items/properties/response"
|
||||
},
|
||||
"requirements": {
|
||||
"$ref": "#/properties/requirements",
|
||||
"default": []
|
||||
}
|
||||
},
|
||||
"required": ["id", "condition", "response"],
|
||||
"additionalProperties": false
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": ["id", "title", "image", "instruction", "stages"],
|
||||
"additionalProperties": false,
|
||||
"$schema": "http://json-schema.org/draft-07/schema#"
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { z } from 'zod'
|
||||
|
||||
const fileSchema = z.object({
|
||||
path: z.string(),
|
||||
type: z.enum(['file', 'directory']),
|
||||
discovered: z.boolean(),
|
||||
empty: z.boolean().optional(),
|
||||
})
|
||||
|
||||
export const commandParamsSchema = z.object({
|
||||
discover: fileSchema.array().optional(),
|
||||
add: fileSchema.array().optional(),
|
||||
remove: fileSchema.array().optional(),
|
||||
pwd: z.string().optional(),
|
||||
})
|
||||
|
||||
export type ICommandParams = z.infer<typeof commandParamsSchema>
|
||||
|
||||
export const commandSchema = z.object({
|
||||
command: z.string().optional(),
|
||||
output: z.string().optional(),
|
||||
error: z.string().optional(),
|
||||
pwd: z.string().optional(),
|
||||
params: commandParamsSchema.optional(),
|
||||
})
|
||||
|
||||
export type ICommand = z.infer<typeof commandSchema>
|
||||
@@ -0,0 +1,44 @@
|
||||
import { z } from 'zod'
|
||||
|
||||
export const fileInputSchema = z.object({
|
||||
path: z.string(),
|
||||
type: z.enum(['file', 'directory']),
|
||||
})
|
||||
|
||||
export type IFileInput = z.infer<typeof fileInputSchema>
|
||||
|
||||
export const fileExistenceInputSchema = fileInputSchema.extend({
|
||||
exists: z.boolean(),
|
||||
})
|
||||
|
||||
export type IFileExistenceInput = z.infer<typeof fileExistenceInputSchema>
|
||||
|
||||
const baseConditionSchema = z.object({
|
||||
command: z.string().optional(),
|
||||
output: z.string().optional(),
|
||||
error: z.string().optional(),
|
||||
pwd: z.string().optional(),
|
||||
files: z.array(fileExistenceInputSchema).optional(),
|
||||
})
|
||||
|
||||
export type ICondition = z.infer<typeof baseConditionSchema> & {
|
||||
or?: ICondition[]
|
||||
not?: ICondition
|
||||
}
|
||||
|
||||
export const conditionSchema: z.ZodType<ICondition> =
|
||||
baseConditionSchema.extend({
|
||||
or: z.lazy(() => z.array(conditionSchema).optional()),
|
||||
not: z.lazy(() => conditionSchema.optional()),
|
||||
})
|
||||
|
||||
export interface IFileExistenceChecker {
|
||||
exists(file: IFileInput): Promise<boolean>
|
||||
}
|
||||
|
||||
export class VoidFileExistenceChecker implements IFileExistenceChecker {
|
||||
// eslint-disable-next-line class-methods-use-this
|
||||
async exists(): Promise<boolean> {
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { z } from 'zod'
|
||||
import { conditionSchema } from './condition.js'
|
||||
import { responseSchema } from './response.js'
|
||||
import { requirementsSchema } from './requirements.js'
|
||||
|
||||
export const exceptionSchema = z.object({
|
||||
id: z.string(),
|
||||
condition: conditionSchema,
|
||||
response: responseSchema,
|
||||
})
|
||||
|
||||
export type IException = z.infer<typeof exceptionSchema>
|
||||
|
||||
export const stageExceptionSchema = exceptionSchema.extend({
|
||||
catchAll: z.boolean().default(false),
|
||||
})
|
||||
|
||||
export type IStageException = z.infer<typeof stageExceptionSchema>
|
||||
|
||||
export const globalExceptionSchema = exceptionSchema.extend({
|
||||
requirements: requirementsSchema.default([]),
|
||||
})
|
||||
|
||||
export type IGlobalException = z.infer<typeof globalExceptionSchema>
|
||||
@@ -0,0 +1,20 @@
|
||||
import { IFileNode } from '../../file-graph'
|
||||
|
||||
export * from './condition.js'
|
||||
export * from './stage.js'
|
||||
export * from './requirements.js'
|
||||
export * from './response.js'
|
||||
export * from './quest.js'
|
||||
export * from './exception.js'
|
||||
export * from './command.js'
|
||||
|
||||
export interface ISession {
|
||||
completedEvents: string[]
|
||||
graph: IFileNode
|
||||
}
|
||||
|
||||
export interface ITask {
|
||||
id: string
|
||||
name: string
|
||||
completed: boolean
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { z } from 'zod'
|
||||
import { stageSchema } from './stage.js'
|
||||
import { requirementsSchema } from './requirements.js'
|
||||
import { globalExceptionSchema } from './exception.js'
|
||||
|
||||
export const questSchema = z.object({
|
||||
id: z.string(),
|
||||
title: z.string(),
|
||||
image: z.string(),
|
||||
instruction: z.string(),
|
||||
requirements: requirementsSchema.default([]),
|
||||
stages: z.array(stageSchema),
|
||||
exceptions: z.array(globalExceptionSchema).optional(),
|
||||
})
|
||||
|
||||
export type IQuest = z.infer<typeof questSchema>
|
||||
@@ -0,0 +1,5 @@
|
||||
import { z } from 'zod'
|
||||
|
||||
export const requirementsSchema = z.array(z.string())
|
||||
|
||||
export type IRequirements = z.infer<typeof requirementsSchema>
|
||||
@@ -0,0 +1,9 @@
|
||||
import { z } from 'zod'
|
||||
|
||||
export const responseSchema = z.object({
|
||||
type: z.enum(['narrative', 'dialogue']).default('narrative'),
|
||||
content: z.string(),
|
||||
hint: z.string().optional(),
|
||||
})
|
||||
|
||||
export type IResponse = z.infer<typeof responseSchema>
|
||||
Loaded 100 of 203 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user