Compare commits

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

No files matched your search

+3 -3
View File
@@ -24,9 +24,6 @@ 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=
@@ -34,6 +31,9 @@ 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
View File
@@ -12,9 +12,6 @@ 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=
@@ -22,6 +19,9 @@ 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
+7 -33
View File
@@ -20,12 +20,9 @@ module.exports = {
settings: {
'import/resolver': {
node: {
extensions: ['.js', '.ts'],
moduleDirectory: ['node_modules', 'packages', 'server/src'],
},
typescript: {
alwaysTryTypes: true,
extensions: ['.js', '.jsx', '.ts', '.tsx'],
},
typescript: {},
},
vue: {
version: '3.0', // Explicitly specify Vue 3
@@ -52,7 +49,7 @@ module.exports = {
'@typescript-eslint/explicit-module-boundary-types': 'off',
'@typescript-eslint/no-unused-vars': [
'warn',
'error',
{
args: 'after-used',
argsIgnorePattern: '^_',
@@ -66,44 +63,21 @@ module.exports = {
// Allow missing file extensions
'import/extensions': [
'off',
'warn',
'ignorePackages',
{
js: 'never',
js: 'off',
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'],
files: ['**/tests/**/*.js', '**/tests/**/*.ts', '**/*.test.ts'], // Adjust the pattern to match your test files
rules: {
'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/extensions': 'off', // Turn off the rule for test files
'import/no-extraneous-dependencies': 'off',
},
},
+3 -4
View File
@@ -18,9 +18,9 @@ on:
secrets:
DOCKERHUB_USERNAME:
required: false
required: true
DOCKERHUB_TOKEN:
required: false
required: true
jobs:
build-and-push:
@@ -42,7 +42,6 @@ jobs:
type=semver,pattern={{version}}
- name: Login to Docker Hub
if: always()
uses: docker/login-action@v1
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
@@ -54,5 +53,5 @@ jobs:
context: .
file: ${{ inputs.dockerfilePath }}
target: ${{ inputs.target }}
push: ${{ secrets.DOCKERHUB_USERNAME != '' }}
push: true
tags: ${{ steps.meta.outputs.tags }}
+9
View File
@@ -2,6 +2,15 @@ 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 -2
View File
@@ -1,7 +1,6 @@
name: E2E Tests
on:
workflow_dispatch:
on: [push, pull_request]
jobs:
e2e:
+1
View File
@@ -12,6 +12,7 @@ concurrency:
jobs:
ci-checks:
uses: ./.github/workflows/ci-checks.yml
secrets: inherit
build:
needs: ci-checks
+1
View File
@@ -10,6 +10,7 @@ concurrency:
jobs:
ci-checks:
uses: ./.github/workflows/ci-checks.yml
secrets: inherit
build:
uses: ./.github/workflows/build.yml
+1
View File
@@ -12,6 +12,7 @@ concurrency:
jobs:
ci-checks:
uses: ./.github/workflows/ci-checks.yml
secrets: inherit
build:
needs: ci-checks
-1
View File
@@ -1,5 +1,4 @@
config
.quest
# Logs
logs
+1 -4
View File
@@ -1,8 +1,5 @@
{
"editor.tabSize": 2,
"editor.defaultFormatter": "esbenp.prettier-vscode",
"editor.formatOnSave": true,
"yaml.schemas": {
"packages/game/quest.json": "quests/**/game.yml"
}
"editor.formatOnSave": true
}
+6 -15
View File
@@ -121,6 +121,12 @@ 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
@@ -157,17 +163,6 @@ 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
@@ -185,7 +180,3 @@ 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
+17 -6
View File
@@ -1,15 +1,26 @@
# Base stage
FROM node:20-alpine as build
FROM node:20-alpine as base
WORKDIR /app
COPY package.json yarn.lock ./
COPY package.json yarn.lock tsconfig.json ./
COPY packages/file-graph/package.json ./packages/file-graph/package.json
COPY packages/utils/package.json ./packages/utils/package.json
COPY packages/constants/package.json ./packages/constants/package.json
COPY packages/models/package.json ./packages/models/package.json
COPY app/package.json ./app/package.json
COPY server/package.json ./server/package.json
RUN yarn install --frozen-lockfile
COPY tsconfig.json ./tsconfig.json
COPY server ./server
COPY packages ./packages
# 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 app ./app
RUN yarn workspace app build
+12 -10
View File
@@ -5,8 +5,8 @@
"scripts": {
"dev": "vite",
"dev:docker": "API_TARGET=http://backend:3000 vite",
"build": "vue-tsc && vite build",
"type-check": "vue-tsc",
"build": "vue-tsc -b && vite build",
"type-check": "vue-tsc -b",
"preview": "vite preview"
},
"dependencies": {
@@ -16,10 +16,13 @@
"@fortawesome/free-regular-svg-icons": "^6.4.0",
"@fortawesome/free-solid-svg-icons": "^6.4.0",
"@fortawesome/vue-fontawesome": "^3.0.3",
"@trpc/client": "^11.1.0",
"@linux-odyssey/constants": "*",
"@linux-odyssey/file-graph": "*",
"@linux-odyssey/models": "*",
"@linux-odyssey/utils": "*",
"@types/dompurify": "^3.0.5",
"axios": "^1.8.2",
"dompurify": "^3.2.4",
"axios": "^1.6.0",
"dompurify": "^3.0.6",
"marked": "^10.0.0",
"pinia": "^2.1.7",
"socket.io-client": "^4.7.1",
@@ -34,14 +37,13 @@
},
"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",
"@vitejs/plugin-vue": "^5.2.2",
"@vue/tsconfig": "^0.7.0",
"typescript": "~5.7.2",
"vite": "^6.3.1",
"vue-tsc": "^2.2.8"
"typescript": "^5.3.3",
"vite": "^4.5.3",
"vue-tsc": "^2.0.10"
}
}
+1 -1
View File
@@ -22,7 +22,7 @@ function nodeClass() {
<template>
<a href="#" :class="nodeClass()">
<font-awesome-icon
v-if="node.type === 'directory'"
v-if="node.type === 'folder'"
:icon="['far', 'folder']"
class="text-text-primary size-8"
/>
+4 -24
View File
@@ -31,31 +31,11 @@ const html = computed(() => {
.markdown-content :deep(ol) {
list-style: decimal;
padding-left: 1em;
line-height: 1.2;
}
.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;
.markdown-content :deep(code) {
background-color: #282a33;
border-radius: 0.25rem;
padding: 0.2rem 0.4rem;
font-size: 0.9rem;
color: #d8dee9;
font-family: 'monospace';
padding: 0.25rem;
font-size: 0.875rem;
}
</style>
+2 -2
View File
@@ -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 trpc.quests.getQuestDetail.query(questId)
const quest = await getQuest(questId)
questInstruction.value = quest.instruction
handleColour()
})
+6 -6
View File
@@ -1,8 +1,8 @@
<script setup lang="ts">
import { ref, onMounted, watch, computed } from 'vue'
import { useToast } from 'vue-toastification'
import { DAG } from '../../../packages/utils'
import { trpc } from '../utils/trpc'
import { DAG } from '@linux-odyssey/utils'
import { getQuests } from '../utils/api'
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 trpc.quests.getQuests.query()
const quests = await getQuests()
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
),
+9 -27
View File
@@ -5,9 +5,8 @@ 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(() => {
@@ -15,7 +14,6 @@ const disabled = computed(() => {
})
watch(length, () => {
console.log('length update', length.value)
current.value = length.value - 1
})
@@ -30,19 +28,6 @@ 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>
@@ -77,30 +62,27 @@ watch(response, () => {
@click="right"
:disabled="disabled"
>
<font-awesome-icon
:icon="['fas', 'arrow-right']"
:class="disabled ? 'text-gray-600' : 'text-text'"
/>
<font-awesome-icon :icon="['fas', 'arrow-right']" class="text-text" />
</button>
</div>
</div>
<div id="hint" class="bg-bg p-8 overflow-y-auto">
<ul>
<li
v-if="response"
v-for="response in sessionStore.session?.responses[current]"
:key="response.type"
class="text-text font-xl whitespace-pre-wrap"
>
<MarkdownText :content="response.content" />
<div v-for="content in response.content" :key="content">
<MarkdownText :content="content" />
</div>
</li>
<li
v-if="response?.hint"
v-for="hint in sessionStore.session?.hints[current]"
:key="hint"
class="text-text-primary font-xl whitespace-pre-wrap"
>
<details ref="hintDetails">
<summary class="cursor-pointer">提示</summary>
<MarkdownText :content="response.hint" />
</details>
<MarkdownText :content="hint" />
</li>
</ul>
<br />
+2 -2
View File
@@ -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(() => {
console.log('session', sessionStore.session)
return sessionStore.session?.tasks.map((task) => {
return sessionStore.session?.tasks.map((task: Task) => {
const prefix = task.completed ? '✓' : '➤'
const color = task.completed ? 'text-text-primary' : 'text-text'
return {
+10 -21
View File
@@ -1,34 +1,23 @@
<script setup lang="ts">
import { onMounted, onUnmounted, ref } from 'vue'
import { onMounted, 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)
const handleResize = () => term.resizeScreen()
window.addEventListener('resize', handleResize)
onUnmounted(() => {
window.removeEventListener('resize', handleResize)
})
window.addEventListener('resize', () => term.resizeScreen())
})
</script>
<template>
<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>
<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>
</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 { trpc } from '../../utils/trpc'
import { surveyUrl } from '../../config'
import { openBugReport } from '../../utils/formbricks'
import useSession from '../../store/session'
import useUserProfile from '../../store/userProfile'
@@ -26,11 +26,6 @@ const handleLogout = async () => {
}
}
const openSurvey = async () => {
const surveyUrl = await trpc.links.surveyLink.query()
window.open(surveyUrl, '_blank')
}
onMounted(async () => {
try {
await userStore.loadUserProfile()
@@ -66,18 +61,18 @@ onMounted(async () => {
>
{{ userStore.username }}
</p>
<button
<a
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>
</button>
</a>
<RouterLink
title="LeaderBoard"
to="/leaderboard"
+38 -59
View File
@@ -1,37 +1,19 @@
import { defineStore } from 'pinia'
import {
FileGraph,
type FileGraphUpdateEvent,
} from '../../../packages/file-graph'
import type { IResponse, ITask } from '../../../packages/game'
import type { SessionDetail } from '../../../server/src/routers/sessionRouter'
import { FileGraph, FileObject } from '@linux-odyssey/file-graph'
import { IQuest } from '@linux-odyssey/models'
import { createSession, getActiveSession, getQuest } from '../utils/api'
import Socket from '../utils/socket'
import SocketTerminal from '../utils/terminal'
import type { Session } from '../types'
import { trpc } from '../utils/trpc'
import { Session, StageResponse } from '../types'
const socket = new Socket()
const term = new SocketTerminal()
const term = new SocketTerminal(40, 80)
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: QuestDetailResponse | null
quest: IQuest | null
}
const useSession = defineStore('session', {
@@ -42,51 +24,52 @@ const useSession = defineStore('session', {
}),
actions: {
async setQuest(questId: string) {
this.quest = await trpc.quests.getQuestDetail.query(questId)
this.quest = await getQuest(questId)
this.questId = questId
},
async setSession(session: SessionDetail) {
this.session = {
...session,
graph: new FileGraph(session.graph),
pwd: '/home/commander',
}
async setSession(session: Session) {
this.session = session
this.session.graph = new FileGraph(session.graph)
term.reset()
await socket.connect(this.session)
await socket.connect(session)
term.focus()
},
async createSession() {
const session = await trpc.session.createSession.mutate({
questId: this.questId,
})
const session = await createSession(this.questId)
await this.setSession(session)
socket.emit('terminal', 'echo start\n')
},
async getActiveSession() {
const session = await trpc.session.getActiveSession.query({
questId: this.questId,
})
const session = await getActiveSession(this.questId)
if (session) {
this.setSession(session)
}
},
newUpdate(update: SessionUpdate) {
updateGraph(event: { discover?: FileObject[]; pwd?: string }) {
if (!this.session) return
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
if (event.discover) {
this.session.graph.discover(event.discover)
}
if (event.pwd) {
this.session.pwd = event.pwd
}
},
finish() {
newResponse(response: StageResponse) {
if (!this.session) return
this.session.status = 'finished'
this.session.responses.push(response.responses)
this.session.hints.push(response.hints)
if (response.tasks) {
this.session.tasks = response.tasks
}
if (
response.status === 'finished' &&
this.session.status !== 'finished'
) {
this.finish()
}
this.session.status = response.status
},
finish() {},
reset() {
socket.reset()
term.reset()
@@ -100,15 +83,11 @@ const useSession = defineStore('session', {
term.onData((data: string) => {
socket.emit('terminal', data)
})
socket.on('graph', (event: FileGraphUpdateEvent) => {
if (!this.session) return
this.session.graph.handleEvent(event)
if (event.pwd) {
this.session.pwd = event.pwd
}
socket.on('graph', (event: { discover: FileObject[]; pwd: string }) => {
this.updateGraph(event)
})
socket.on('update', (update: SessionUpdate) => {
this.newUpdate(update)
socket.on('response', (response: StageResponse) => {
this.newResponse(response)
})
hasSetup = true
},
+2 -2
View File
@@ -1,5 +1,5 @@
import { defineStore } from 'pinia'
import { trpc } from '../utils/trpc'
import { getUserProfile } from '../utils/api'
interface IProgress {
quest: string
@@ -23,7 +23,7 @@ const useUserProfile = defineStore('userProfile', {
}),
actions: {
async loadUserProfile() {
const res = await trpc.users.getMyProfile.query()
const res = await getUserProfile()
this.$patch(res)
},
async resetUserProfile() {
+6 -4
View File
@@ -1,5 +1,6 @@
import type { FileGraph } from '../../packages/file-graph'
import type { IResponse, ITask } from '../../packages/game'
import { FileGraph } from '@linux-odyssey/file-graph'
// eslint-disable-next-line import/no-relative-packages
import { IResponse } from '@linux-odyssey/models'
export interface Task {
id: number
@@ -19,6 +20,7 @@ export interface Session {
status: string
graph: FileGraph
pwd: string
tasks: ITask[]
responses: IResponse[]
hints: string[][]
tasks: Task[]
responses: IResponse[][]
}
+40
View File
@@ -1,4 +1,8 @@
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',
@@ -8,3 +12,39 @@ 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 -1
View File
@@ -1,5 +1,5 @@
import { io, Socket } from 'socket.io-client'
import type { Session } from '../types'
import { Session } from '../types'
type SocketCallback = (data: any) => void
+24 -21
View File
@@ -6,39 +6,33 @@ class SocketTerminal {
private term: Terminal
private fitAddon: FitAddon
constructor() {
constructor(rows: number, cols: number) {
this.term = new Terminal({
convertEol: true,
scrollback: 1000,
fontSize: 16,
rows, // 行数
cols, // 不指定行数,自动回车后光标从下一行开始
convertEol: true, // 启用时,光标将设置为下一行的开头
// scrollback: 50, //终端中的回滚量
disableStdin: false, // 是否应禁用输入
// cursorStyle: "underline", //光标样式
cursorBlink: true, // 光标闪烁
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)
}
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()
this.fitAddon.fit()
}
write(message: string): void {
this.term.write(message)
}
onData(callback: (data: string) => void): void {
onData(callback: any): void {
this.term.onData(callback)
}
@@ -46,6 +40,15 @@ 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()
}
-16
View File
@@ -1,16 +0,0 @@
import { createTRPCClient, httpBatchLink } from '@trpc/client'
import type { AppRouter } from '../../../server/src/routers'
export const trpc = createTRPCClient<AppRouter>({
links: [
httpBatchLink({
url: '/trpc',
// You can pass any HTTP headers you wish here
// async headers() {
// return {
// authorization: getAuthCookie(),
// }
// },
}),
],
})
+1 -1
View File
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { isValidUsername } from '../../../packages/utils'
import { isValidUsername } from '@linux-odyssey/utils'
import AuthForm from '../components/AuthForm.vue'
import Background from '../components/DynamicBackground.vue'
import { TooManyRequestsError, ValidationError } from '../utils/errors'
+2 -2
View File
@@ -1,7 +1,7 @@
<script setup lang="ts">
import validator from 'validator'
import { isValidUsername } from '../../../packages/utils'
import { passwordPolicy } from '../../../packages/constants'
import { isValidUsername } from '@linux-odyssey/utils'
import { passwordPolicy } from '@linux-odyssey/constants'
import AuthForm from '../components/AuthForm.vue'
import DynamicBackground from '../components/DynamicBackground.vue'
import HeaderPart from '../components/header/HeaderPart.vue'
-14
View File
@@ -1,14 +0,0 @@
{
"extends": "@vue/tsconfig/tsconfig.dom.json",
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"]
}
+26 -3
View File
@@ -1,7 +1,30 @@
{
"files": [],
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"module": "ESNext",
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "preserve",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src/**/*.ts", "src/**/*.vue"],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
{ "path": "../packages/utils" },
{ "path": "../packages/constants" },
{ "path": "../packages/file-graph" },
{ "path": "../packages/models" }
]
}
-24
View File
@@ -1,24 +0,0 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "ES2022",
"lib": ["ES2023"],
"module": "ESNext",
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["vite.config.ts"]
}
-15
View File
@@ -1,26 +1,11 @@
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,
-24
View File
@@ -1,6 +1,5 @@
server {
listen 80;
error_log /var/log/nginx/error.log debug;
location / {
root /usr/share/nginx/html;
@@ -11,29 +10,10 @@ 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/ {
@@ -43,9 +23,5 @@ 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;
}
}
+4 -4
View File
@@ -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.prod
- .env
environment:
- DOCKER_NETWORK=linux-odyssey-players
- BACKEND_URL=http://backend:3000
@@ -51,7 +51,7 @@ services:
ports:
- '3001:3001'
env_file:
- .env.prod
- .env
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.prod
- .env
base:
image: linuxodyssey/quest-base
+10 -10
View File
@@ -16,6 +16,7 @@
"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)",
@@ -33,29 +34,28 @@
"@jest/globals": "^29.7.0",
"@types/jest": "^29.5.12",
"@types/node": "^20.10.4",
"@typescript-eslint/eslint-plugin": "^6.21.0",
"@typescript-eslint/parser": "^6.21.0",
"@typescript-eslint/eslint-plugin": "^6.14.0",
"@typescript-eslint/parser": "^6.14.0",
"@vue/eslint-config-prettier": "^7.1.0",
"babel-jest": "^29.7.0",
"concurrently": "^8.2.2",
"eslint": "^8.56.0",
"eslint": "^8.44.0",
"eslint-config-airbnb-base": "^15.0.0",
"eslint-config-prettier": "^9.1.0",
"eslint-config-prettier": "^8.8.0",
"eslint-import-resolver-typescript": "^3.6.1",
"eslint-plugin-cypress": "^2.15.1",
"eslint-plugin-import": "^2.29.0",
"eslint-plugin-prettier": "^5.1.3",
"eslint-plugin-node": "^11.1.0",
"eslint-plugin-prettier": "^5.0.0",
"eslint-plugin-vue": "^9.15.1",
"globals": "^16.0.0",
"jest": "^29.7.0",
"prettier": "^3.0.0",
"ts-node": "^10.9.2",
"typescript": "^5.3.3",
"vue-eslint-parser": "^9.4.2",
"zod-to-json-schema": "^3.24.5"
"typescript": "^5.3.3"
},
"dependencies": {
"latest": "^0.2.0"
"latest": "^0.2.0",
"typescript-eslint": "^7.4.0"
},
"packageManager": "yarn@1.22.22+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e"
}
+8 -15
View File
@@ -1,26 +1,19 @@
FROM node:20-alpine as build
WORKDIR /app
COPY ./package.json yarn.lock ./
COPY ./package.json yarn.lock tsconfig.json ./
COPY packages/analytics/package.json ./packages/analytics/package.json
COPY packages/models/package.json ./packages/models/package.json
RUN yarn install --frozen-lockfile
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" ]
+6
View File
@@ -0,0 +1,6 @@
{
"watch": ["src", "views"],
"ext": "ts,handlebars,js",
"ignore": ["src/**/*.spec.ts"],
"exec": "node --loader ts-node/esm src/index.ts"
}
+10 -7
View File
@@ -3,27 +3,30 @@
"version": "1.0.0",
"main": "src/index.js",
"private": true,
"type": "module",
"license": "GPL",
"scripts": {
"start": "node dist/analytics/src/index.js",
"build": "tsc",
"type-check": "tsc --noEmit",
"dev": "tsx watch src/index.ts"
"start": "node dist/index.js",
"build": "tsc -b -f",
"watch": "tsc -b --watch",
"dev": "nodemon"
},
"dependencies": {
"@linux-odyssey/models": "*",
"dotenv": "^16.4.5",
"express": "^4.20.0",
"express": "^4.19.2",
"express-basic-auth": "^1.2.1",
"express-handlebars": "^7.1.2",
"mongoose": "^8.9.5"
"mongoose": "^8.0.3"
},
"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",
"tsx": "^4.19.3",
"ts-node": "^10.9.2",
"typescript": "^5.3.3"
}
}
View File
Whitespace-only changes.
@@ -1,6 +1,6 @@
import type { Request, Response } from 'express'
import Pagination from '../models/pagination'
import { errorCommands } from '../models/commands'
import Pagination from '../models/pagination.js'
import { errorCommands } from '../models/commands.js'
export async function commandListController(
req: Request,
+2 -2
View File
@@ -1,6 +1,6 @@
import { Request, Response } from 'express'
import { userCount } from '../models/users'
import { questList } from '../models/quests'
import { userCount } from '../models/users.js'
import { questList } from '../models/quests.js'
export async function homeController(req: Request, res: Response) {
const quests = await questList()
+1 -1
View File
@@ -1,5 +1,5 @@
import { Request, Response } from 'express'
import { questList } from '../models/quests'
import { questList } from '../models/quests.js'
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'
import { sessionList, sessionDetail } from '../models/sessions'
import Pagination from '../models/pagination.js'
import { sessionList, sessionDetail } from '../models/sessions.js'
export async function sessionListController(req: Request, res: Response) {
const { nextKey, order } = req.query as { nextKey: string; order: string }
+2 -2
View File
@@ -1,7 +1,7 @@
import { Request, Response } from 'express'
import mongoose from 'mongoose'
import Pagination from '../models/pagination'
import { userList, userDetail, idToUser } from '../models/users'
import Pagination from '../models/pagination.js'
import { userList, userDetail, idToUser } from '../models/users.js'
export async function userListController(req: Request, res: Response) {
const { nextKey, order } = req.query as { nextKey: string; order: string }
+3 -3
View File
@@ -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 { connectDB } from '../../models'
import config from './config'
import router from './router'
import config from './config.js'
import router from './router.js'
async function main() {
await connectDB(config.db)
+2 -2
View File
@@ -1,5 +1,5 @@
import { Command } from '../../../models'
import Pagination from './pagination'
import { Command } from '@linux-odyssey/models'
import Pagination from './pagination.js'
// eslint-disable-next-line import/prefer-default-export
export async function errorCommands(pagination: Pagination) {
+1 -1
View File
@@ -1,5 +1,5 @@
/* eslint-disable class-methods-use-this */
/* eslint-disable max-classes-per-file */
// eslint-disable-next-line max-classes-per-file
import mongoose, { Types } from 'mongoose'
export const DESC: string = 'desc'
+4 -10
View File
@@ -1,14 +1,8 @@
import { UserProfile } from '../../../models'
/* eslint-disable object-shorthand */
/* eslint-disable func-names */
import { UserProfile } from '@linux-odyssey/models'
export interface QuestProgress {
_id: string
started: number
completed: number
completedRate: number
failed: number
}
export function questList(): Promise<QuestProgress[]> {
export function questList() {
return UserProfile.aggregate([
{
$project: {
+2 -2
View File
@@ -1,5 +1,5 @@
import { Command, Session } from '../../../models'
import Pagination from './pagination'
import { Command, Session } from '@linux-odyssey/models'
import Pagination from './pagination.js'
import {
CommandObject,
SessionDetail,
+6 -10
View File
@@ -1,7 +1,7 @@
import { HydratedDocument, Types } from 'mongoose'
import { Session, User, IUser, ISession } from '../../../models'
import Pagination from './pagination'
import { UserSessionDetail } from '../interface'
import { Session, User } from '@linux-odyssey/models'
import { Types } from 'mongoose'
import Pagination from './pagination.js'
import { IUser, UserSessionDetail } from '../interface.js'
function loginMethods(user: IUser): string[] {
const methods: string[] = []
@@ -23,11 +23,7 @@ export function userCount() {
}
export async function userList(pagination: Pagination) {
const users: {
userData: HydratedDocument<IUser>
count: number
lastActivityAt: Date
}[] = await Session.aggregate([
const users = await Session.aggregate([
pagination.match('user'),
{
$group: {
@@ -66,7 +62,7 @@ export async function userList(pagination: Pagination) {
export async function userDetail(
id: Types.ObjectId
): Promise<UserSessionDetail[]> {
const sessions: HydratedDocument<ISession>[] = await Session.find({
const sessions = await Session.find({
user: id,
})
return sessions.map((session): UserSessionDetail => {
+8 -5
View File
@@ -1,13 +1,16 @@
import { Router } from 'express'
import { homeController } from './controllers/index'
import { userListController, userDetailController } from './controllers/users'
import { questListController } from './controllers/quests'
import { commandListController } from './controllers/commands'
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 {
sessionDetailController,
sessionListController,
} from './controllers/sessions'
} from './controllers/sessions.js'
const router = Router()
+3 -1
View File
@@ -1,9 +1,11 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "./dist"
},
"include": ["src", "../models"],
"include": ["src"],
"references": [{ "path": "../models" }],
"ts-node": {
"swc": true
}
+17
View File
@@ -0,0 +1,17 @@
{
"name": "@linux-odyssey/constants",
"type": "module",
"version": "1.0.0",
"private": true,
"main": "dist/index.js",
"exports": "./dist/index.js",
"types": "dist/index.d.ts",
"license": "GPL",
"scripts": {
"build": "tsc",
"dev": "tsc -w"
},
"dependencies": {
"zod": "^3.23.8"
}
}
+9
View File
@@ -0,0 +1,9 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"composite": true,
"outDir": "./dist"
},
"include": ["*.js", "*.ts"],
"exclude": ["node_modules", "dist"]
}
-1
View File
@@ -1 +0,0 @@
export const toArray = (val: unknown) => (typeof val === 'string' ? [val] : val)
+49 -20
View File
@@ -1,6 +1,10 @@
/* eslint-disable no-restricted-syntax */
const fs = require('fs').promises
const { exit } = require('process')
const handleCommand = require('./handleCommand.js')
const minimist = require('minimist')
const axios = require('axios')
const { colorize, printResponses, printHints } = require('./print.js')
const discoverFiles = require('./discover.js')
const {
API_ENDPOINT,
@@ -12,6 +16,13 @@ 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')
@@ -20,6 +31,34 @@ 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)
@@ -36,21 +75,15 @@ async function main() {
params,
}
try {
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}`)
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
}
} catch (e) {
console.error('Error occurred while sending command:', e)
console.error(err.message)
}
}
@@ -60,10 +93,6 @@ main()
console.log(
'發生預期外的錯誤,請嘗試重新啟動 Quest,如果問題持續發生,請幫我們填寫右上角的錯誤回報表單。'
)
if (err.response && err.response.data) {
console.log(err.response.data)
} else {
console.log(err)
}
console.error(err)
exit(1)
})
+16 -36
View File
@@ -1,33 +1,21 @@
const fs = require('fs').promises
const path = require('path')
const { resolvePath } = require('./utils')
async function collectFilesInfo(inputPath, level = 0, hiddenFiles = false) {
let stats
try {
stats = await fs.stat(inputPath)
} catch (e) {
if (e.code === 'ENOENT') {
return []
}
throw e
}
const stats = await fs.stat(inputPath)
const name = path.basename(inputPath)
const discovered = level <= 1 && (hiddenFiles || !name.startsWith('.'))
const self = {
path: inputPath,
name,
type: stats.isDirectory() ? 'directory' : 'file',
discovered,
}
const files = [self]
const files = [
{
path: inputPath,
name,
type: stats.isDirectory() ? 'folder' : 'file',
discovered,
},
]
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)
}
@@ -47,23 +35,15 @@ 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) => resolvePath(p))
.map((p) => collectFilesInfo(p, 0, argv.a || argv.all))
)
).flat()
if (result.length === 0) {
return {}
}
return { discover: result }
const result = await Promise.all(
targetPath
.map((p) => path.resolve(process.cwd(), p))
.map((p) => collectFilesInfo(p, 0, argv.a || argv.all))
)
return { discover: result.flat() }
} catch (_) {
return {}
}
}
module.exports = {
discoverFiles,
collectFilesInfo,
}
module.exports = discoverFiles
-174
View File
@@ -1,174 +0,0 @@
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
+1
View File
@@ -8,6 +8,7 @@
"watch": ""
},
"dependencies": {
"axios": "^1.6.0",
"minimist": "^1.2.8"
}
}
+115
View File
@@ -0,0 +1,115 @@
/* eslint-disable no-restricted-syntax */
/* eslint-disable no-await-in-loop */
const readline = require('readline')
const RETURN_SYMBOL = '\u21B5'
// ANSI color map
const ANSI_COLORS = {
default: 0,
black: 30,
red: 31,
green: 32,
yellow: 33,
blue: 34,
magenta: 35,
cyan: 36,
white: 37,
}
function colorCode(color) {
return `\x1b[${ANSI_COLORS[color]}m`
}
function colorize(content, color) {
if (!color) return content
return `${colorCode(color)}${content}${colorCode('default')}`
}
process.stdin.setRawMode(true)
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout,
terminal: false,
})
function waitForEnter() {
return new Promise((resolve) => {
rl.once('line', resolve)
})
}
function formatLine(content, color) {
let result = colorCode(color)
let codeBlock = false
for (const c of content) {
switch (c) {
case '`':
result += colorCode(codeBlock ? color : 'green')
codeBlock = !codeBlock
break
default:
result += c
}
}
return result + colorCode('default') + RETURN_SYMBOL
}
function printLine(content, delay, color) {
const printContent = formatLine(content, color)
return new Promise((resolve) => {
let i = 0
const timer = setInterval(() => {
process.stdout.write(printContent[i])
i += 1
if (i >= printContent.length) {
clearInterval(timer)
process.stdout.write('\n')
resolve()
}
}, delay)
waitForEnter().then(() => {
clearInterval(timer)
if (i >= printContent.length) return
process.stdout.write(printContent.slice(i))
process.stdout.write('\n')
resolve()
})
})
}
async function printResponses(responses, delay = 100) {
console.log()
console.log(colorize('==============================', 'green'))
for (const response of responses) {
const { type, content, speaker, color } = response
if (type === 'narrative') {
for (const line of content) {
await printLine(line, delay, color || 'cyan')
}
}
if (type === 'dialogue') {
console.log(colorize(`${speaker}:`, 'green'))
for (const line of content) {
await printLine(` ${line}`, delay, color || 'cyan')
}
}
console.log(colorize('------------------------------', 'green'))
console.log()
}
}
async function printHints(hints, delay = 100) {
for (const hint of hints) {
await printLine(hint, delay, 'yellow')
}
}
module.exports = {
printResponses,
printHints,
colorize,
}
-10
View File
@@ -1,10 +0,0 @@
const os = require('os')
const path = require('path')
function resolvePath(p) {
return path.resolve(process.cwd(), p.replace('~', os.homedir()))
}
module.exports = {
resolvePath,
}
-6
View File
@@ -1,6 +0,0 @@
export { default as File } from './file'
export type { FileObject } from './file'
export { default as FileNode } from './fileNode'
export type { IFileNode } from './fileNode'
export { default as FileGraph } from './fileGraph'
export type { FileGraphUpdateEvent } from './fileGraph'
+14
View File
@@ -0,0 +1,14 @@
{
"name": "@linux-odyssey/file-graph",
"version": "1.0.0",
"type": "module",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"exports": "./dist/index.js",
"license": "MIT",
"scripts": {
"build": "tsc",
"dev": "tsc -w",
"test": "yarn build && jest"
}
}
File renamed without changes.
@@ -2,28 +2,25 @@ import { basename } from './utils.js'
export interface FileObject {
path: string
type: 'file' | 'directory'
type: string
discovered: boolean
empty?: boolean
}
export default class File implements FileObject {
path: string
name: string
type: 'file' | 'directory'
type: string
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 === 'directory'
return this.type === 'folder'
}
contains(file: FileObject) {
@@ -1,11 +1,10 @@
import { FileObject } from './file.js'
import FileNode from './fileNode.js'
export interface FileGraphUpdateEvent {
export interface Event {
add?: FileObject[]
remove?: FileObject[]
discover?: FileObject[]
pwd?: string
}
export default class FileGraph extends FileNode {
@@ -21,7 +20,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 directory, also remove all children
// If the file node is a folder, also remove all children
// If the file node doesn't exist, throw an error
files.forEach((file) => {
@@ -34,10 +33,6 @@ 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) => {
@@ -47,16 +42,19 @@ export default class FileGraph extends FileNode {
this.merge(newNode)
}
handleEvent(event: FileGraphUpdateEvent) {
handleEvent(event: Event) {
// eslint-disable-next-line default-case
if (event.add) {
this.add(event.add)
}
if (event.remove) {
this.remove(event.remove)
}
if (event.discover) {
this.discover(event.discover)
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
}
}
}
@@ -1,18 +1,15 @@
import File, { FileObject } from './file.js'
import { ParentNotExistsError, FileNotExistsError } from './errors.js'
import {
DuplicateItemError,
ParentNotExistsError,
FileNotExistsError,
} from './errors.js'
import { dirname } from './utils.js'
export interface IFileNode {
path: string
type: 'file' | 'directory'
discovered: boolean
children?: IFileNode[]
}
export default class FileNode extends File implements IFileNode {
export default class FileNode extends File {
children: FileNode[]
constructor(file: FileObject | IFileNode) {
constructor(file: FileObject | FileNode) {
super(file)
this.children = []
if ((<FileNode>file).children)
@@ -25,24 +22,15 @@ export default class FileNode extends File implements IFileNode {
) {
const file = new FileNode(fileInput)
if (file.path === this.path) {
if (!this.discovered) {
this.discovered = file.discovered
}
return
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
if (this.children.some((child) => child.path === file.path)) {
throw new DuplicateItemError(
`File ${file.path} already exists in the tree: ${this.toString()}`
)
}
const parentPath = dirname(file.path)
@@ -53,11 +41,11 @@ export default class FileNode extends File implements IFileNode {
// Recursively call addChild on the correct child node
const childNode = this.children.find((child) => child.contains(file))
if (childNode) {
childNode.addChild(file, { makeParents })
childNode.addChild(file)
} else if (makeParents) {
const parent = new FileNode({
path: parentPath,
type: 'directory',
type: 'folder',
discovered: false,
children: [file],
})
@@ -100,12 +88,6 @@ export default class FileNode extends File implements IFileNode {
// 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) {
@@ -151,11 +133,4 @@ export default class FileNode extends File implements IFileNode {
.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))
}
}
+3
View File
@@ -0,0 +1,3 @@
export { default as File, FileObject } from './file.js'
export { default as FileNode } from './fileNode.js'
export { default as FileGraph, Event } from './fileGraph.js'
File renamed without changes.
+2 -2
View File
@@ -1,10 +1,10 @@
import { describe, test, expect } from '@jest/globals'
import File from '../file.js'
import File from '../src/file.js'
describe('File', () => {
let file: File
beforeEach(() => {
file = new File({ path: '/home/user', type: 'directory', discovered: true })
file = new File({ path: '/home/user', type: 'folder', discovered: true })
})
const filesToCompare = [
+19 -84
View File
@@ -1,7 +1,6 @@
import { describe, test, expect } from '@jest/globals'
import FileGraph from '../fileGraph.js'
import FileNode from '../fileNode.js'
import { FileObject } from '../file.js'
import FileGraph from '../src/fileGraph.js'
import FileNode from '../src/fileNode.js'
describe('FileGraph', () => {
let root: FileNode
@@ -9,7 +8,7 @@ describe('FileGraph', () => {
beforeEach(() => {
root = new FileNode({
path: '/',
type: 'directory',
type: 'folder',
discovered: true,
})
})
@@ -17,7 +16,7 @@ describe('FileGraph', () => {
test('add files to graph', () => {
const fileGraph = new FileGraph(root)
const filesToAdd: FileObject[] = [
const filesToAdd = [
{
path: '/home/user/file1.txt',
type: 'file',
@@ -25,12 +24,7 @@ describe('FileGraph', () => {
},
{
path: '/home/user/folder1',
type: 'directory',
discovered: true,
},
{
path: '/home/user/folder2/file2.txt',
type: 'file',
type: 'folder',
discovered: true,
},
]
@@ -38,34 +32,7 @@ describe('FileGraph', () => {
fileGraph.add(filesToAdd)
expect(fileGraph.children.length).toBe(1)
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)
expect(fileGraph.children[0].children[0].children.length).toBe(2)
})
test('remove files from graph', () => {
@@ -74,12 +41,12 @@ describe('FileGraph', () => {
const filesToAdd = [
{
path: '/home/user/folder1',
type: 'directory',
type: 'folder',
discovered: true,
},
{
path: '/home/user/folder2',
type: 'directory',
type: 'folder',
discovered: true,
},
{
@@ -94,22 +61,22 @@ describe('FileGraph', () => {
},
]
fileGraph.add(filesToAdd as FileObject[])
fileGraph.add(filesToAdd)
const filesToRemove = [
{
path: '/home/user/folder1/file1.txt',
type: 'directory',
type: 'folder',
discovered: true,
},
{
path: '/home/user/folder2',
type: 'directory',
type: 'folder',
discovered: true,
},
]
fileGraph.remove(filesToRemove as FileObject[])
fileGraph.remove(filesToRemove)
expect(fileGraph.children[0].children[0].children.length).toBe(1)
expect(fileGraph.children[0].children[0].children[0].name).toBe('folder1')
@@ -123,7 +90,7 @@ describe('FileGraph', () => {
const discoverFiles = [
{
path: '/home/user/folder1',
type: 'directory',
type: 'folder',
discovered: true,
},
{
@@ -143,7 +110,7 @@ describe('FileGraph', () => {
},
]
fileGraph.discover(discoverFiles as FileObject[])
fileGraph.discover(discoverFiles)
expect(fileGraph.children[0].children[0].children[0].children.length).toBe(
3
)
@@ -155,12 +122,12 @@ describe('FileGraph', () => {
const filesToAdd = [
{
path: '/home/user/folder1',
type: 'directory',
type: 'folder',
discovered: true,
},
{
path: '/home/user/folder2',
type: 'directory',
type: 'folder',
discovered: true,
},
{
@@ -175,12 +142,12 @@ describe('FileGraph', () => {
},
]
fileGraph.add(filesToAdd as FileObject[])
fileGraph.add(filesToAdd)
const newFiles = [
{
path: '/home/user/folder1',
type: 'directory',
type: 'folder',
discovered: true,
},
{
@@ -200,43 +167,11 @@ describe('FileGraph', () => {
},
]
fileGraph.discover(newFiles as FileObject[])
fileGraph.discover(newFiles)
expect(fileGraph.children[0].children[0].children.length).toBe(2)
expect(fileGraph.children[0].children[0].children[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
)
})
})
+91 -90
View File
@@ -1,7 +1,7 @@
import { describe, test, expect } from '@jest/globals'
import { ParentNotExistsError } from '../errors.js'
import FileNode, { IFileNode } from '../fileNode.js'
import { FileObject } from '../file.js'
import { DuplicateItemError, ParentNotExistsError } from '../src/errors.js'
import FileNode from '../src/fileNode.js'
import { FileObject } from '../src/file.js'
function createNodeFromFiles(files: FileObject[]) {
const node = new FileNode(files[0])
@@ -42,16 +42,17 @@ describe('FileNode', () => {
beforeEach(() => {
rootNode = new FileNode({
path: '/home/rudeus',
type: 'directory',
name: 'rudeus',
type: 'folder',
discovered: true,
})
})
test('should override discovered when adding duplicate item', () => {
const child: FileObject = {
test('should throw error when adding duplicate item', () => {
const child = {
path: '/home/rudeus/test',
type: 'directory',
discovered: false,
type: 'folder',
discovered: true,
}
// Adding the child for the first time
@@ -60,16 +61,16 @@ describe('FileNode', () => {
expect(rootNode.children.length).toBe(1)
// Trying to add the same child again should throw an error
child.discovered = true
rootNode.addChild(child)
expect(rootNode.children[0].discovered).toBe(true)
expect(() => {
rootNode.addChild(child)
}).toThrow(DuplicateItemError)
})
test('should throw error when parent does not exist', () => {
const child: FileObject = {
const child = {
path: '/home/rudeus/alphabet/test',
type: 'directory',
name: 'test',
type: 'folder',
discovered: true,
}
@@ -80,19 +81,22 @@ describe('FileNode', () => {
})
test('adds nested children to the tree', () => {
const files: FileObject[] = [
const files = [
{
path: '/home/rudeus/forgotten_scroll.txt',
name: 'forgotten_scroll.txt',
type: 'file',
discovered: true,
},
{
path: '/home/rudeus/test',
type: 'directory',
name: 'test',
type: 'folder',
discovered: true,
},
{
path: '/home/rudeus/test/a',
name: 'a',
type: 'file',
discovered: false,
},
@@ -107,8 +111,9 @@ describe('FileNode', () => {
})
test('adds nested children to the tree with makeParents option', () => {
const file: FileObject = {
const file = {
path: '/home/rudeus/test/a/b/c/d/e',
name: 'a',
type: 'file',
discovered: false,
}
@@ -124,34 +129,34 @@ describe('FileNode', () => {
})
test('merge-subtree', () => {
const root: FileObject = {
const root = {
path: '/',
type: 'directory',
type: 'folder',
discovered: true,
}
const files1: FileObject[] = [
const files1 = [
root,
{ 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 },
{ 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 },
]
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 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 expected: FileObject[] = [
const expected = [
root,
{ 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 },
{ 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 },
]
mergeTest(files1, files2, expected)
@@ -161,94 +166,90 @@ 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: FileObject = {
const root = {
path: '/',
type: 'directory',
type: 'folder',
discovered: true,
}
const files1: FileObject[] = [
const files1 = [
root,
{ 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 },
{ 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 },
]
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 files2 = [
{ path: '/b', type: 'folder', discovered: true },
{ path: '/b/c', type: 'folder', discovered: true },
{ path: '/b/e', type: 'file', discovered: true },
]
const expected: FileObject[] = [
const expected = [
root,
{ 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 },
{ 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 },
]
mergeTest(files1, files2, expected)
})
test('merge-hidden', () => {
const root: FileObject = {
const root = {
path: '/',
type: 'directory',
type: 'folder',
discovered: true,
}
const files1: FileObject[] = [
const files1 = [
root,
{ 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 },
{ path: '/a', type: 'file', discovered: true },
{ path: '/.b', type: 'file', discovered: false },
{ path: '/c', type: 'folder', discovered: true },
{ path: '/c/.d', type: 'file', discovered: false },
]
const files2: FileObject[] = [
{ path: '/c', type: 'directory' as const, discovered: true },
{ path: '/c/.d', type: 'file' as const, discovered: true },
const files2 = [
{ path: '/c', type: 'folder', discovered: true },
{ path: '/c/.d', type: 'file', discovered: true },
]
const expected: FileObject[] = [
const expected = [
root,
{ 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 },
{ path: '/a', type: 'file', discovered: true },
{ path: '/.b', type: 'file', discovered: false },
{ path: '/c', type: 'folder', discovered: true },
{ path: '/c/.d', type: 'file', discovered: true },
]
mergeTest(files1, files2, expected)
})
test('remove-child', () => {
const root: FileObject = {
const root = {
path: '/',
type: 'directory',
type: 'folder',
discovered: true,
}
const files: FileObject[] = [
const files = [
root,
{ 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 },
{ 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 },
]
const fileToRemove: FileObject = {
path: '/b/c',
type: 'directory',
discovered: true,
}
const fileToRemove = { path: '/b/c', type: 'folder', discovered: true }
const expected: FileObject[] = [
const expected = [
root,
{ 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 },
{ path: '/a', type: 'file', discovered: true },
{ path: '/b', type: 'folder', discovered: true },
{ path: '/b/d', type: 'file', discovered: true },
]
removeChildTest(files, fileToRemove, expected)
@@ -256,9 +257,9 @@ describe('FileNode', () => {
})
test('load nested fileNode', () => {
const data: IFileNode = {
const data = {
path: '/',
type: 'directory',
type: 'folder',
discovered: true,
children: [
{
@@ -268,12 +269,12 @@ test('load nested fileNode', () => {
},
{
path: '/b',
type: 'directory',
type: 'folder',
discovered: true,
children: [
{
path: '/b/c',
type: 'directory',
type: 'folder',
discovered: true,
children: [
{
+10
View File
@@ -0,0 +1,10 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"rootDir": "src",
"composite": true,
"outDir": "./dist"
},
"include": ["src"],
"exclude": ["node_modules", "dist"]
}
-48
View File
@@ -1,48 +0,0 @@
import { Condition } from './condition/Condition'
import {
ICommand,
ICondition,
IFileExistenceChecker,
IRequirements,
IResponse,
} from './schema'
export class Event {
id: string
private condition: Condition
private requirements: string[]
constructor(
id: string,
condition: ICondition,
private response: IResponse,
requirements?: IRequirements
) {
this.id = id
this.condition = new Condition(condition)
this.requirements = requirements || []
}
async satisfies(
command: ICommand,
checker: IFileExistenceChecker
): Promise<boolean> {
return this.condition.satisfies(command, checker)
}
private checkRequirements(completed: string[]): boolean {
return this.requirements.every((req) => completed.includes(req))
}
private finished(completed: string[]): boolean {
return completed.includes(this.id)
}
active(completed: string[]): boolean {
return !this.finished(completed) && this.checkRequirements(completed)
}
getResponse(): IResponse {
return this.response
}
}
-18
View File
@@ -1,18 +0,0 @@
/* eslint-disable max-classes-per-file */
import { Event } from './Event'
import { IStageException, IGlobalException } from './schema/exception'
export class StageException extends Event {
readonly catchAll: boolean
constructor({ id, condition, response, catchAll }: IStageException) {
super(id, condition, response)
this.catchAll = catchAll
}
}
export class GlobalException extends Event {
constructor({ id, condition, response, requirements }: IGlobalException) {
super(id, condition, response, requirements)
}
}
-93
View File
@@ -1,93 +0,0 @@
/* 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]
}
}
-67
View File
@@ -1,67 +0,0 @@
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
}
}
-26
View File
@@ -1,26 +0,0 @@
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
}
}
-8
View File
@@ -1,8 +0,0 @@
// 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))
-13
View File
@@ -1,13 +0,0 @@
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)
}
}
-53
View File
@@ -1,53 +0,0 @@
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))
}
}
-18
View File
@@ -1,18 +0,0 @@
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 || '')
}
}
-11
View File
@@ -1,11 +0,0 @@
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)
}
-5
View File
@@ -1,5 +0,0 @@
import { ICommand } from '../schema'
export interface Matcher {
match(command: ICommand): boolean
}
-10
View File
@@ -1,10 +0,0 @@
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)
}
}
-10
View File
@@ -1,10 +0,0 @@
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))
}
}
-13
View File
@@ -1,13 +0,0 @@
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())
}
}
-10
View File
@@ -1,10 +0,0 @@
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
}
}
-6
View File
@@ -1,6 +0,0 @@
export * from './schema'
export * from './Quest'
export * from './Session'
export * from './Event'
export * from './Stage'
export * from './Exception'
-159
View File
@@ -1,159 +0,0 @@
{
"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#"
}
-27
View File
@@ -1,27 +0,0 @@
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>
-44
View File
@@ -1,44 +0,0 @@
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
}
}
-24
View File
@@ -1,24 +0,0 @@
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>
-20
View File
@@ -1,20 +0,0 @@
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
}
-16
View File
@@ -1,16 +0,0 @@
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>
-5
View File
@@ -1,5 +0,0 @@
import { z } from 'zod'
export const requirementsSchema = z.array(z.string())
export type IRequirements = z.infer<typeof requirementsSchema>
-9
View File
@@ -1,9 +0,0 @@
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 205 files, more files were not shown because too many files have changed in this diff. Show more