mirror of
https://github.com/linux-odyssey/linux-odyssey.git
synced 2026-11-06 03:20:13 +08:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f1038e40f4 | ||
|
|
c5fb65db51 | ||
|
|
d4c67a7d81 | ||
|
|
0a1973f220 | ||
|
|
776ef1162a | ||
|
|
5b7bf0794c | ||
|
|
ab4d06498a | ||
|
|
d9fc465731 | ||
|
|
dca6861940 | ||
|
|
8161849504 | ||
|
|
3c2dfcf322 | ||
|
|
0341efdd50 | ||
|
|
aba492ff43 | ||
|
|
c2566cf3dd | ||
|
|
307af61726 | ||
|
|
740cbcecb4 | ||
|
|
e43cbe26ce | ||
|
|
95c4fb71ae | ||
|
|
d755822908 | ||
|
|
372378021d | ||
|
|
63e36c1c98 | ||
|
|
2f7845e63e | ||
|
|
156abc044a | ||
|
|
21ff710f83 | ||
|
|
fb863ba030 | ||
|
|
c7e6d31b9c | ||
|
|
9c9456693d | ||
|
|
5e81db2759 | ||
|
|
0b08a9a5d9 | ||
|
|
58b6448c3d | ||
|
|
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
@@ -2,3 +2,4 @@
|
|||||||
.git
|
.git
|
||||||
**/dist
|
**/dist
|
||||||
**/tsconfig.tsbuildinfo
|
**/tsconfig.tsbuildinfo
|
||||||
|
data
|
||||||
+3
-3
@@ -24,6 +24,9 @@ ADMIN_PORT=3001
|
|||||||
# TRUSTED_PROXIES=1.2.3.4
|
# TRUSTED_PROXIES=1.2.3.4
|
||||||
# valid configurations see isIP & isIPRange in https://github.com/validatorjs/validator.js?tab=readme-ov-file#validators
|
# valid configurations see isIP & isIPRange in https://github.com/validatorjs/validator.js?tab=readme-ov-file#validators
|
||||||
|
|
||||||
|
# RATE_LIMIT_MAX_FAILED_ATTEMPTS=5
|
||||||
|
# RATE_LIMIT_LOCKOUT_TIME=10
|
||||||
|
|
||||||
# GOOGLE_CLIENT_ID=
|
# GOOGLE_CLIENT_ID=
|
||||||
# GOOGLE_CLIENT_SECRET=
|
# GOOGLE_CLIENT_SECRET=
|
||||||
# GITHUB_CLIENT_ID=
|
# GITHUB_CLIENT_ID=
|
||||||
@@ -31,9 +34,6 @@ ADMIN_PORT=3001
|
|||||||
# SURVEY_URL=
|
# SURVEY_URL=
|
||||||
# BUG_REPORT_URL=
|
# BUG_REPORT_URL=
|
||||||
|
|
||||||
# SURVEY_URL=
|
|
||||||
# BUG_REPORT_URL=
|
|
||||||
|
|
||||||
# Docker related config
|
# Docker related config
|
||||||
# DOCKER_NETWORK=host
|
# DOCKER_NETWORK=host
|
||||||
# QUEST_IMAGE=linuxodyssey/quest-base
|
# QUEST_IMAGE=linuxodyssey/quest-base
|
||||||
|
|||||||
+4
-4
@@ -1,6 +1,6 @@
|
|||||||
HOST=localhost
|
HOST=localhost
|
||||||
PORT=3000
|
PORT=3000
|
||||||
BASE_URL=http://localhost:5173
|
BASE_URL=http://localhost:8000
|
||||||
MONGO_URL=mongodb://localhost:27017/odyssey-test
|
MONGO_URL=mongodb://localhost:27017/odyssey-test
|
||||||
SECRET_KEY=sample-secret
|
SECRET_KEY=sample-secret
|
||||||
DOCKER_NETWORK=linux-odyssey-players-dev
|
DOCKER_NETWORK=linux-odyssey-players-dev
|
||||||
@@ -12,6 +12,9 @@ DOCKER_NETWORK=linux-odyssey-players-dev
|
|||||||
# TRUSTED_PROXIES=1.2.3.4
|
# TRUSTED_PROXIES=1.2.3.4
|
||||||
# valid configurations see isIP & isIPRange in https://github.com/validatorjs/validator.js?tab=readme-ov-file#validators
|
# valid configurations see isIP & isIPRange in https://github.com/validatorjs/validator.js?tab=readme-ov-file#validators
|
||||||
|
|
||||||
|
# RATE_LIMIT_MAX_FAILED_ATTEMPTS=5
|
||||||
|
# RATE_LIMIT_LOCKOUT_TIME=10
|
||||||
|
|
||||||
# GOOGLE_CLIENT_ID=
|
# GOOGLE_CLIENT_ID=
|
||||||
# GOOGLE_CLIENT_SECRET=
|
# GOOGLE_CLIENT_SECRET=
|
||||||
# GITHUB_CLIENT_ID=
|
# GITHUB_CLIENT_ID=
|
||||||
@@ -19,9 +22,6 @@ DOCKER_NETWORK=linux-odyssey-players-dev
|
|||||||
# SURVEY_URL=
|
# SURVEY_URL=
|
||||||
# BUG_REPORT_URL=
|
# BUG_REPORT_URL=
|
||||||
|
|
||||||
# SURVEY_URL=
|
|
||||||
# BUG_REPORT_URL=
|
|
||||||
|
|
||||||
# Docker related config
|
# Docker related config
|
||||||
# DOCKER_NETWORK=host
|
# DOCKER_NETWORK=host
|
||||||
# QUEST_IMAGE=linuxodyssey/quest-base
|
# QUEST_IMAGE=linuxodyssey/quest-base
|
||||||
|
|||||||
+33
-7
@@ -20,9 +20,12 @@ module.exports = {
|
|||||||
settings: {
|
settings: {
|
||||||
'import/resolver': {
|
'import/resolver': {
|
||||||
node: {
|
node: {
|
||||||
extensions: ['.js', '.jsx', '.ts', '.tsx'],
|
extensions: ['.js', '.ts'],
|
||||||
|
moduleDirectory: ['node_modules', 'packages', 'server/src'],
|
||||||
|
},
|
||||||
|
typescript: {
|
||||||
|
alwaysTryTypes: true,
|
||||||
},
|
},
|
||||||
typescript: {},
|
|
||||||
},
|
},
|
||||||
vue: {
|
vue: {
|
||||||
version: '3.0', // Explicitly specify Vue 3
|
version: '3.0', // Explicitly specify Vue 3
|
||||||
@@ -49,7 +52,7 @@ module.exports = {
|
|||||||
|
|
||||||
'@typescript-eslint/explicit-module-boundary-types': 'off',
|
'@typescript-eslint/explicit-module-boundary-types': 'off',
|
||||||
'@typescript-eslint/no-unused-vars': [
|
'@typescript-eslint/no-unused-vars': [
|
||||||
'error',
|
'warn',
|
||||||
{
|
{
|
||||||
args: 'after-used',
|
args: 'after-used',
|
||||||
argsIgnorePattern: '^_',
|
argsIgnorePattern: '^_',
|
||||||
@@ -63,21 +66,44 @@ module.exports = {
|
|||||||
|
|
||||||
// Allow missing file extensions
|
// Allow missing file extensions
|
||||||
'import/extensions': [
|
'import/extensions': [
|
||||||
'warn',
|
'off',
|
||||||
'ignorePackages',
|
'ignorePackages',
|
||||||
{
|
{
|
||||||
js: 'off',
|
js: 'never',
|
||||||
jsx: 'never',
|
jsx: 'never',
|
||||||
ts: 'never',
|
ts: 'never',
|
||||||
tsx: 'never',
|
tsx: 'never',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
'no-unused-vars': 'warn',
|
||||||
|
'no-useless-constructor': 'off',
|
||||||
|
'@typescript-eslint/no-useless-constructor': 'error',
|
||||||
|
'no-empty-function': 'off',
|
||||||
|
'@typescript-eslint/no-empty-function': 'error',
|
||||||
|
'no-shadow': 'off',
|
||||||
|
'@typescript-eslint/no-shadow': 'error',
|
||||||
|
'no-restricted-imports': ['error', '../*'],
|
||||||
|
'import/no-relative-packages': 'off',
|
||||||
|
'no-await-in-loop': 'off',
|
||||||
},
|
},
|
||||||
overrides: [
|
overrides: [
|
||||||
{
|
{
|
||||||
files: ['**/tests/**/*.js', '**/tests/**/*.ts', '**/*.test.ts'], // Adjust the pattern to match your test files
|
files: ['**/tests/**/*.js', '**/tests/**/*.ts', '**/*.test.ts'],
|
||||||
rules: {
|
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',
|
'import/no-extraneous-dependencies': 'off',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -18,9 +18,9 @@ on:
|
|||||||
|
|
||||||
secrets:
|
secrets:
|
||||||
DOCKERHUB_USERNAME:
|
DOCKERHUB_USERNAME:
|
||||||
required: true
|
required: false
|
||||||
DOCKERHUB_TOKEN:
|
DOCKERHUB_TOKEN:
|
||||||
required: true
|
required: false
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build-and-push:
|
build-and-push:
|
||||||
@@ -42,6 +42,7 @@ jobs:
|
|||||||
type=semver,pattern={{version}}
|
type=semver,pattern={{version}}
|
||||||
|
|
||||||
- name: Login to Docker Hub
|
- name: Login to Docker Hub
|
||||||
|
if: always()
|
||||||
uses: docker/login-action@v1
|
uses: docker/login-action@v1
|
||||||
with:
|
with:
|
||||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||||
@@ -53,5 +54,5 @@ jobs:
|
|||||||
context: .
|
context: .
|
||||||
file: ${{ inputs.dockerfilePath }}
|
file: ${{ inputs.dockerfilePath }}
|
||||||
target: ${{ inputs.target }}
|
target: ${{ inputs.target }}
|
||||||
push: true
|
push: ${{ secrets.DOCKERHUB_USERNAME != '' }}
|
||||||
tags: ${{ steps.meta.outputs.tags }}
|
tags: ${{ steps.meta.outputs.tags }}
|
||||||
@@ -24,7 +24,7 @@ jobs:
|
|||||||
uses: ./.github/workflows/build-docker-image.yml
|
uses: ./.github/workflows/build-docker-image.yml
|
||||||
with:
|
with:
|
||||||
imageName: 'linuxodyssey/analytics'
|
imageName: 'linuxodyssey/analytics'
|
||||||
dockerfilePath: './packages/analytics/Dockerfile'
|
dockerfilePath: './services/analytics/Dockerfile'
|
||||||
target: prod
|
target: prod
|
||||||
secrets: inherit
|
secrets: inherit
|
||||||
|
|
||||||
|
|||||||
@@ -2,15 +2,6 @@ name: CI Checks
|
|||||||
|
|
||||||
on:
|
on:
|
||||||
workflow_call:
|
workflow_call:
|
||||||
secrets:
|
|
||||||
GOOGLE_CLIENT_ID:
|
|
||||||
required: true
|
|
||||||
GOOGLE_CLIENT_SECRET:
|
|
||||||
required: true
|
|
||||||
GH_CLIENT_ID:
|
|
||||||
required: true
|
|
||||||
GH_CLIENT_SECRET:
|
|
||||||
required: true
|
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
ci:
|
ci:
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
name: E2E Tests
|
name: E2E Tests
|
||||||
|
|
||||||
on: [push, pull_request]
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
e2e:
|
e2e:
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ concurrency:
|
|||||||
jobs:
|
jobs:
|
||||||
ci-checks:
|
ci-checks:
|
||||||
uses: ./.github/workflows/ci-checks.yml
|
uses: ./.github/workflows/ci-checks.yml
|
||||||
secrets: inherit
|
|
||||||
|
|
||||||
build:
|
build:
|
||||||
needs: ci-checks
|
needs: ci-checks
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ concurrency:
|
|||||||
jobs:
|
jobs:
|
||||||
ci-checks:
|
ci-checks:
|
||||||
uses: ./.github/workflows/ci-checks.yml
|
uses: ./.github/workflows/ci-checks.yml
|
||||||
secrets: inherit
|
|
||||||
|
|
||||||
build:
|
build:
|
||||||
uses: ./.github/workflows/build.yml
|
uses: ./.github/workflows/build.yml
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ concurrency:
|
|||||||
jobs:
|
jobs:
|
||||||
ci-checks:
|
ci-checks:
|
||||||
uses: ./.github/workflows/ci-checks.yml
|
uses: ./.github/workflows/ci-checks.yml
|
||||||
secrets: inherit
|
|
||||||
|
|
||||||
build:
|
build:
|
||||||
needs: ci-checks
|
needs: ci-checks
|
||||||
|
|||||||
@@ -1,4 +1,8 @@
|
|||||||
config
|
config
|
||||||
|
.quest
|
||||||
|
tmp
|
||||||
|
services/terminal-service/terminal-service
|
||||||
|
bin
|
||||||
|
|
||||||
# Logs
|
# Logs
|
||||||
logs
|
logs
|
||||||
@@ -141,3 +145,4 @@ dist
|
|||||||
|
|
||||||
#screenshots
|
#screenshots
|
||||||
tests/cypress/screenshots
|
tests/cypress/screenshots
|
||||||
|
services/cmd-hook/cmd-hook
|
||||||
Vendored
+7
-1
@@ -1,5 +1,11 @@
|
|||||||
{
|
{
|
||||||
"editor.tabSize": 2,
|
"editor.tabSize": 2,
|
||||||
"editor.defaultFormatter": "esbenp.prettier-vscode",
|
"editor.defaultFormatter": "esbenp.prettier-vscode",
|
||||||
"editor.formatOnSave": true
|
"editor.formatOnSave": true,
|
||||||
|
"yaml.schemas": {
|
||||||
|
"packages/game/quest.json": "quests/**/game.yml"
|
||||||
|
},
|
||||||
|
"[go]": {
|
||||||
|
"editor.defaultFormatter": "golang.go"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -3,6 +3,13 @@
|
|||||||
[](https://github.com/linux-odyssey/linux-odyssey/actions/workflows/production.yml)
|
[](https://github.com/linux-odyssey/linux-odyssey/actions/workflows/production.yml)
|
||||||
[](https://github.com/linux-odyssey/linux-odyssey/actions/workflows/staging.yml)
|
[](https://github.com/linux-odyssey/linux-odyssey/actions/workflows/staging.yml)
|
||||||
|
|
||||||
|
## TLDR
|
||||||
|
|
||||||
|
yarn install
|
||||||
|
cp .env.sample .env
|
||||||
|
docker compose up -d
|
||||||
|
yarn dev
|
||||||
|
|
||||||
## Requirements
|
## Requirements
|
||||||
|
|
||||||
- NodeJS v20+
|
- NodeJS v20+
|
||||||
@@ -41,10 +48,6 @@ The configuration is stored in the `.env` file. You can copy from the sample fil
|
|||||||
|
|
||||||
cp .env.sample .env
|
cp .env.sample .env
|
||||||
|
|
||||||
The backend will use a SSH key pair to login to the player's container. The private key is on the host, and the public key is copied to the player's container.
|
|
||||||
|
|
||||||
The key pair is stored in `./config/ssh_key` and `./config/ssh_key.pub`. If it doesn't exist, the backend will generate a new pair and store them.
|
|
||||||
|
|
||||||
## Docker Setup
|
## Docker Setup
|
||||||
|
|
||||||
### Docker compose files explaination
|
### Docker compose files explaination
|
||||||
@@ -70,7 +73,6 @@ This will start the db. it will automatically restarts by Docker.
|
|||||||
Building quest images:
|
Building quest images:
|
||||||
|
|
||||||
docker compose build base
|
docker compose build base
|
||||||
yarn build:quests
|
|
||||||
|
|
||||||
### Build Quest Images in Details
|
### Build Quest Images in Details
|
||||||
|
|
||||||
@@ -108,31 +110,32 @@ The base image is built with docker-compose, so you can use `docker compose buil
|
|||||||
|
|
||||||
### Optional: Mounting Host Directory
|
### Optional: Mounting Host Directory
|
||||||
|
|
||||||
To improve the development experience, we can mount the host directory to the container in the pattern:
|
To update the home content of a quest, simply re-create a new session will do. The home content is mounted and copied on container creation.
|
||||||
|
|
||||||
|
To further improve the development experience, we can mount the host directory to the container in the pattern:
|
||||||
|
|
||||||
/path/to/linux-odyssey/quests/${quest_id}/home:/home/commander
|
/path/to/linux-odyssey/quests/${quest_id}/home:/home/commander
|
||||||
/path/to/linux-odyssey/packages/container:/usr/local/lib/container
|
|
||||||
|
|
||||||
You can edit the files on your host, and the changes will be reflected in the container. This is disabled by default. To enable it, you can set the following environment variables:
|
You can edit the files on your host, and the changes will be reflected in the container. This is disabled by default. To enable it, you can set the following environment variables:
|
||||||
|
|
||||||
MOUNT_QUEST=true
|
MOUNT_QUEST=true
|
||||||
|
|
||||||
|
### Optional: Mounting terminal service & cmd-hook
|
||||||
|
|
||||||
|
To make the development of terminal services & cmd-hook smoother, we can enable `MOUNT_CLI` to mount the local binaries into container.
|
||||||
|
|
||||||
|
MOUNT_CLI
|
||||||
|
|
||||||
|
And you can run `air` inside of `services/terminal-service` or `services/cmd-hook` to get auto recompiling.
|
||||||
|
|
||||||
## Development
|
## Development
|
||||||
|
|
||||||
Copy `.env.sample` to `.env` and customize it for your needs.
|
Copy `.env.sample` to `.env` and customize it for your needs.
|
||||||
|
|
||||||
Build the whole project for the first time:
|
|
||||||
|
|
||||||
yarn build
|
|
||||||
|
|
||||||
This will build the typescript for each package and the server.
|
|
||||||
|
|
||||||
Run dev server (frontend, backend):
|
Run dev server (frontend, backend):
|
||||||
|
|
||||||
yarn dev
|
yarn dev
|
||||||
|
|
||||||
**IMPORTANT NOTE**: only the app and server have hot-reloading, other packages require manually running `yarn dev` in each package.
|
|
||||||
|
|
||||||
To develop analytics dashboard:
|
To develop analytics dashboard:
|
||||||
|
|
||||||
yarn analytics
|
yarn analytics
|
||||||
@@ -163,6 +166,17 @@ Run Cypress in container, create brand-new containers along with it:
|
|||||||
|
|
||||||
## Deployment
|
## Deployment
|
||||||
|
|
||||||
|
Create production env
|
||||||
|
|
||||||
|
cp .env.prod.sample .env.prod
|
||||||
|
|
||||||
|
Edit `.env.prod`
|
||||||
|
|
||||||
|
BASE_URL=https://yourdomain.com # IMPORTANT to make login work
|
||||||
|
SECRET_KEY=xxxx # Change it to something safe!
|
||||||
|
|
||||||
|
Start the service
|
||||||
|
|
||||||
# Build the images (only allowed for maintainers)
|
# Build the images (only allowed for maintainers)
|
||||||
docker compose -f docker-compose.prod.yml build
|
docker compose -f docker-compose.prod.yml build
|
||||||
docker compose -f docker-compose.prod.yml push
|
docker compose -f docker-compose.prod.yml push
|
||||||
@@ -180,3 +194,7 @@ You can copy from `.env.sample`
|
|||||||
**YOU MUST CHANGE THE SECRET_KEY** in `.env` file.
|
**YOU MUST CHANGE THE SECRET_KEY** in `.env` file.
|
||||||
|
|
||||||
SECRET_KEY=your-secret-key
|
SECRET_KEY=your-secret-key
|
||||||
|
|
||||||
|
You can generate a random key by running
|
||||||
|
|
||||||
|
yarn setup
|
||||||
+7
-18
@@ -1,31 +1,20 @@
|
|||||||
# Base stage
|
# Base stage
|
||||||
FROM node:20-alpine as base
|
FROM node:20-alpine AS build
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY package.json yarn.lock tsconfig.json ./
|
COPY package.json yarn.lock ./
|
||||||
COPY packages/file-graph/package.json ./packages/file-graph/package.json
|
|
||||||
COPY packages/utils/package.json ./packages/utils/package.json
|
|
||||||
COPY packages/constants/package.json ./packages/constants/package.json
|
|
||||||
COPY packages/models/package.json ./packages/models/package.json
|
|
||||||
COPY app/package.json ./app/package.json
|
COPY app/package.json ./app/package.json
|
||||||
|
COPY server/package.json ./server/package.json
|
||||||
RUN yarn install --frozen-lockfile
|
RUN yarn install --frozen-lockfile
|
||||||
|
|
||||||
# Dev stage
|
COPY tsconfig.json ./tsconfig.json
|
||||||
FROM base as dev
|
COPY server ./server
|
||||||
WORKDIR /app/app
|
COPY packages ./packages
|
||||||
CMD [ "yarn", "dev:docker", "--host", "0.0.0.0" ]
|
|
||||||
|
|
||||||
# Build stage
|
|
||||||
FROM base as build
|
|
||||||
COPY packages/file-graph ./packages/file-graph
|
|
||||||
COPY packages/utils ./packages/utils
|
|
||||||
COPY packages/constants ./packages/constants
|
|
||||||
COPY packages/models ./packages/models
|
|
||||||
COPY app ./app
|
COPY app ./app
|
||||||
RUN yarn workspace app build
|
RUN yarn workspace app build
|
||||||
|
|
||||||
# Production stage
|
# Production stage
|
||||||
FROM nginx:stable-alpine as prod
|
FROM nginx:stable-alpine AS prod
|
||||||
COPY --from=build /app/app/dist /usr/share/nginx/html
|
COPY --from=build /app/app/dist /usr/share/nginx/html
|
||||||
COPY deployment/nginx.conf /etc/nginx/conf.d/default.conf
|
COPY deployment/nginx.conf /etc/nginx/conf.d/default.conf
|
||||||
EXPOSE 80
|
EXPOSE 80
|
||||||
|
|||||||
+11
-12
@@ -5,8 +5,8 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"dev:docker": "API_TARGET=http://backend:3000 vite",
|
"dev:docker": "API_TARGET=http://backend:3000 vite",
|
||||||
"build": "vue-tsc -b && vite build",
|
"build": "vue-tsc && vite build",
|
||||||
"type-check": "vue-tsc -b",
|
"type-check": "vue-tsc",
|
||||||
"preview": "vite preview"
|
"preview": "vite preview"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -16,15 +16,13 @@
|
|||||||
"@fortawesome/free-regular-svg-icons": "^6.4.0",
|
"@fortawesome/free-regular-svg-icons": "^6.4.0",
|
||||||
"@fortawesome/free-solid-svg-icons": "^6.4.0",
|
"@fortawesome/free-solid-svg-icons": "^6.4.0",
|
||||||
"@fortawesome/vue-fontawesome": "^3.0.3",
|
"@fortawesome/vue-fontawesome": "^3.0.3",
|
||||||
"@linux-odyssey/constants": "*",
|
"@trpc/client": "^11.1.0",
|
||||||
"@linux-odyssey/file-graph": "*",
|
|
||||||
"@linux-odyssey/models": "*",
|
|
||||||
"@linux-odyssey/utils": "*",
|
|
||||||
"@types/dompurify": "^3.0.5",
|
"@types/dompurify": "^3.0.5",
|
||||||
"axios": "^1.6.0",
|
"axios": "^1.8.2",
|
||||||
"dompurify": "^3.0.6",
|
"dompurify": "^3.2.4",
|
||||||
"marked": "^10.0.0",
|
"marked": "^10.0.0",
|
||||||
"pinia": "^2.1.7",
|
"pinia": "^2.1.7",
|
||||||
|
"reconnecting-websocket": "^4.4.0",
|
||||||
"socket.io-client": "^4.7.1",
|
"socket.io-client": "^4.7.1",
|
||||||
"validator": "^13.11.0",
|
"validator": "^13.11.0",
|
||||||
"vue": "^3.3.4",
|
"vue": "^3.3.4",
|
||||||
@@ -37,13 +35,14 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/validator": "^13.11.9",
|
"@types/validator": "^13.11.9",
|
||||||
"@vitejs/plugin-vue": "^4.5.2",
|
"@vitejs/plugin-vue": "^5.2.2",
|
||||||
"@vue/compiler-sfc": "^3.3.13",
|
"@vue/compiler-sfc": "^3.3.13",
|
||||||
|
"@vue/tsconfig": "^0.7.0",
|
||||||
"autoprefixer": "^10.4.14",
|
"autoprefixer": "^10.4.14",
|
||||||
"postcss": "^8.4.31",
|
"postcss": "^8.4.31",
|
||||||
"tailwindcss": "^3.3.3",
|
"tailwindcss": "^3.3.3",
|
||||||
"typescript": "^5.3.3",
|
"typescript": "~5.7.2",
|
||||||
"vite": "^4.5.3",
|
"vite": "^6.3.1",
|
||||||
"vue-tsc": "^2.0.10"
|
"vue-tsc": "^2.2.8"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -22,7 +22,7 @@ function nodeClass() {
|
|||||||
<template>
|
<template>
|
||||||
<a href="#" :class="nodeClass()">
|
<a href="#" :class="nodeClass()">
|
||||||
<font-awesome-icon
|
<font-awesome-icon
|
||||||
v-if="node.type === 'folder'"
|
v-if="node.type === 'directory'"
|
||||||
:icon="['far', 'folder']"
|
:icon="['far', 'folder']"
|
||||||
class="text-text-primary size-8"
|
class="text-text-primary size-8"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -31,11 +31,31 @@ const html = computed(() => {
|
|||||||
.markdown-content :deep(ol) {
|
.markdown-content :deep(ol) {
|
||||||
list-style: decimal;
|
list-style: decimal;
|
||||||
padding-left: 1em;
|
padding-left: 1em;
|
||||||
|
line-height: 1.2;
|
||||||
}
|
}
|
||||||
.markdown-content :deep(code) {
|
.markdown-content :deep(ul) {
|
||||||
background-color: #282a33;
|
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;
|
border-radius: 0.25rem;
|
||||||
padding: 0.25rem;
|
padding: 0.2rem 0.4rem;
|
||||||
font-size: 0.875rem;
|
font-size: 0.9rem;
|
||||||
|
color: #d8dee9;
|
||||||
|
font-family: 'monospace';
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
@@ -2,8 +2,8 @@
|
|||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import { useToast } from 'vue-toastification'
|
import { useToast } from 'vue-toastification'
|
||||||
import { ref, defineProps, onMounted } from 'vue'
|
import { ref, defineProps, onMounted } from 'vue'
|
||||||
import { getQuest } from '../utils/api'
|
|
||||||
import MarkdownText from './MarkdownText.vue'
|
import MarkdownText from './MarkdownText.vue'
|
||||||
|
import { trpc } from '../utils/trpc'
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
questTitle: {
|
questTitle: {
|
||||||
@@ -49,7 +49,7 @@ const handleColour = () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
const quest = await getQuest(questId)
|
const quest = await trpc.quests.getQuestDetail.query(questId)
|
||||||
questInstruction.value = quest.instruction
|
questInstruction.value = quest.instruction
|
||||||
handleColour()
|
handleColour()
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted, watch, computed } from 'vue'
|
import { ref, onMounted, watch, computed } from 'vue'
|
||||||
import { useToast } from 'vue-toastification'
|
import { useToast } from 'vue-toastification'
|
||||||
import { DAG } from '@linux-odyssey/utils'
|
import { DAG } from '../../../packages/utils'
|
||||||
import { getQuests } from '../utils/api'
|
import { trpc } from '../utils/trpc'
|
||||||
import useUserProfile from '../store/userProfile'
|
import useUserProfile from '../store/userProfile'
|
||||||
import QuestIntro from './QuestIntro.vue'
|
import QuestIntro from './QuestIntro.vue'
|
||||||
|
|
||||||
@@ -64,19 +64,19 @@ async function computeGraphData() {
|
|||||||
if (!store.progress) return
|
if (!store.progress) return
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const quests = await getQuests()
|
const quests = await trpc.quests.getQuests.query()
|
||||||
const dag = new DAG(quests)
|
const dag = new DAG(quests)
|
||||||
const nodesValues = dag.getNodes()
|
const nodesValues = dag.getNodes()
|
||||||
|
|
||||||
const nodes = nodesValues.map((node) => ({
|
const nodes = nodesValues.map((node) => ({
|
||||||
id: node._id,
|
id: node.id,
|
||||||
title: node.title,
|
title: node.title,
|
||||||
x: marginX * node.layer * 2,
|
x: marginX * node.layer * 2,
|
||||||
y:
|
y:
|
||||||
marginY * node.index -
|
marginY * node.index -
|
||||||
(marginX * dag.getLayer(node._id)) / 2 +
|
(marginX * dag.getLayer(node.id)) / 2 +
|
||||||
svgHeight.value / 2,
|
svgHeight.value / 2,
|
||||||
completed: store.progress[node._id]?.completed || false,
|
completed: store.progress[node.id]?.completed || false,
|
||||||
unlocked: node.requirements.every(
|
unlocked: node.requirements.every(
|
||||||
(req: string) => store.progress[req]?.completed
|
(req: string) => store.progress[req]?.completed
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import { useToast } from 'vue-toastification'
|
import { useToast } from 'vue-toastification'
|
||||||
import useSession from '../../store/session'
|
import { useSession } from '../../store/session'
|
||||||
import { openBugReport, openQuestSurvey } from '../../utils/formbricks'
|
import { openBugReport, openQuestSurvey } from '../../utils/formbricks'
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, ref, watch } from 'vue'
|
import { computed, ref, watch } from 'vue'
|
||||||
import useSession from '../../store/session'
|
import { useSession } from '../../store/session'
|
||||||
import MarkdownText from '../MarkdownText.vue'
|
import MarkdownText from '../MarkdownText.vue'
|
||||||
|
|
||||||
const sessionStore = useSession()
|
const sessionStore = useSession()
|
||||||
const current = ref(-1)
|
const current = ref(-1)
|
||||||
|
const hintDetails = ref<HTMLDetailsElement | null>(null)
|
||||||
const length = computed(() => {
|
const length = computed(() => {
|
||||||
return sessionStore.session?.responses.length ?? 0
|
return sessionStore.session?.responses?.length ?? 0
|
||||||
})
|
})
|
||||||
|
|
||||||
const disabled = computed(() => {
|
const disabled = computed(() => {
|
||||||
@@ -14,6 +15,7 @@ const disabled = computed(() => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
watch(length, () => {
|
watch(length, () => {
|
||||||
|
console.log('length update', length.value)
|
||||||
current.value = length.value - 1
|
current.value = length.value - 1
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -28,6 +30,19 @@ const right = () => {
|
|||||||
current.value += 1
|
current.value += 1
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const response = computed(() => {
|
||||||
|
if (!sessionStore.session) return null
|
||||||
|
if (current.value < 0) return null
|
||||||
|
if (current.value >= sessionStore.session.responses.length) return null
|
||||||
|
return sessionStore.session.responses[current.value]
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(response, () => {
|
||||||
|
if (hintDetails.value) {
|
||||||
|
hintDetails.value.open = false
|
||||||
|
}
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -62,27 +77,30 @@ const right = () => {
|
|||||||
@click="right"
|
@click="right"
|
||||||
:disabled="disabled"
|
: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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div id="hint" class="bg-bg p-8 overflow-y-auto">
|
<div id="hint" class="bg-bg p-8 overflow-y-auto">
|
||||||
<ul>
|
<ul>
|
||||||
<li
|
<li
|
||||||
v-for="response in sessionStore.session?.responses[current]"
|
v-if="response"
|
||||||
:key="response.type"
|
:key="response.type"
|
||||||
class="text-text font-xl whitespace-pre-wrap"
|
class="text-text font-xl whitespace-pre-wrap"
|
||||||
>
|
>
|
||||||
<div v-for="content in response.content" :key="content">
|
<MarkdownText :content="response.content" />
|
||||||
<MarkdownText :content="content" />
|
|
||||||
</div>
|
|
||||||
</li>
|
</li>
|
||||||
<li
|
<li
|
||||||
v-for="hint in sessionStore.session?.hints[current]"
|
v-if="response?.hint"
|
||||||
:key="hint"
|
|
||||||
class="text-text-primary font-xl whitespace-pre-wrap"
|
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>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
<br />
|
<br />
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed } from 'vue'
|
import { computed } from 'vue'
|
||||||
import MarkdownText from '../MarkdownText.vue'
|
import MarkdownText from '../MarkdownText.vue'
|
||||||
import useSession from '../../store/session'
|
import { useSession } from '../../store/session'
|
||||||
import { Task } from '../../types'
|
|
||||||
|
|
||||||
const sessionStore = useSession()
|
const sessionStore = useSession()
|
||||||
const tasks = computed(() => {
|
const tasks = computed(() => {
|
||||||
return sessionStore.session?.tasks.map((task: Task) => {
|
console.log('session', sessionStore.session)
|
||||||
|
return sessionStore.session?.tasks.map((task) => {
|
||||||
const prefix = task.completed ? '✓' : '➤'
|
const prefix = task.completed ? '✓' : '➤'
|
||||||
const color = task.completed ? 'text-text-primary' : 'text-text'
|
const color = task.completed ? 'text-text-primary' : 'text-text'
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { useToast } from 'vue-toastification'
|
import { useToast } from 'vue-toastification'
|
||||||
import { computed } from 'vue'
|
import { computed } from 'vue'
|
||||||
import useSession from '../../store/session'
|
import { useSession } from '../../store/session'
|
||||||
|
|
||||||
const toast = useToast()
|
const toast = useToast()
|
||||||
const sessionStore = useSession()
|
const sessionStore = useSession()
|
||||||
|
|||||||
@@ -1,23 +1,44 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { onMounted, ref } from 'vue'
|
import { onMounted, onUnmounted, ref } from 'vue'
|
||||||
import { useTerminal } from '../../store/session'
|
import { useSession, useTerminal } from '../../store/session'
|
||||||
|
|
||||||
|
const sessionStore = useSession()
|
||||||
|
|
||||||
const terminal = ref<HTMLElement | null>(null)
|
const terminal = ref<HTMLElement | null>(null)
|
||||||
|
const term = useTerminal()
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
const term = useTerminal()
|
|
||||||
if (!terminal.value) return
|
if (!terminal.value) return
|
||||||
term.mount(terminal.value)
|
term.mount(terminal.value)
|
||||||
window.addEventListener('resize', () => term.resizeScreen())
|
|
||||||
|
const handleResize = () => term.resizeScreen()
|
||||||
|
window.addEventListener('resize', handleResize)
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
window.removeEventListener('resize', handleResize)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<font-awesome-icon :icon="['fas', 'terminal']" class="text-text p-1" />
|
<div class="flex flex-col h-full">
|
||||||
<button id="Terminal" class="text-text h-[5%]">終端機</button>
|
<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>
|
||||||
|
|
||||||
|
<a
|
||||||
|
class="text-sm font-normal text-gray-200 ml-10"
|
||||||
|
:href="`/terminal/${sessionStore.session?.containerName}?token=${sessionStore.session?.token}`"
|
||||||
|
target="_blank"
|
||||||
|
>
|
||||||
|
Open Terminal in New Tab
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
<div
|
<div
|
||||||
id="terminal"
|
id="terminal"
|
||||||
ref="terminal"
|
ref="terminal"
|
||||||
class="bg-bg-secondary h-[calc(100%-5%)] p-0.5"
|
class="bg-bg-secondary flex-grow overflow-hidden px-2"
|
||||||
|
style="height: calc(100vh - 40px)"
|
||||||
></div>
|
></div>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import FileNode from '../FileNode.vue'
|
import FileNode from '../FileNode.vue'
|
||||||
import useSession from '../../store/session'
|
import { useSession } from '../../store/session'
|
||||||
|
|
||||||
const sessionStore = useSession()
|
const sessionStore = useSession()
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -3,9 +3,9 @@ import { onMounted } from 'vue'
|
|||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import { useToast } from 'vue-toastification'
|
import { useToast } from 'vue-toastification'
|
||||||
import { logout } from '../../utils/auth'
|
import { logout } from '../../utils/auth'
|
||||||
import { surveyUrl } from '../../config'
|
import { trpc } from '../../utils/trpc'
|
||||||
import { openBugReport } from '../../utils/formbricks'
|
import { openBugReport } from '../../utils/formbricks'
|
||||||
import useSession from '../../store/session'
|
import { useSession } from '../../store/session'
|
||||||
import useUserProfile from '../../store/userProfile'
|
import useUserProfile from '../../store/userProfile'
|
||||||
|
|
||||||
const userStore = useUserProfile()
|
const userStore = useUserProfile()
|
||||||
@@ -26,6 +26,11 @@ const handleLogout = async () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const openSurvey = async () => {
|
||||||
|
const surveyUrl = await trpc.links.surveyLink.query()
|
||||||
|
window.open(surveyUrl, '_blank')
|
||||||
|
}
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
try {
|
try {
|
||||||
await userStore.loadUserProfile()
|
await userStore.loadUserProfile()
|
||||||
@@ -61,18 +66,18 @@ onMounted(async () => {
|
|||||||
>
|
>
|
||||||
{{ userStore.username }}
|
{{ userStore.username }}
|
||||||
</p>
|
</p>
|
||||||
<a
|
<button
|
||||||
title="Survey"
|
title="Survey"
|
||||||
:href="surveyUrl"
|
|
||||||
target="_blank"
|
target="_blank"
|
||||||
class="md:h-5 md:w-5 px-1.5 w-auto"
|
class="md:h-5 md:w-5 px-1.5 w-auto"
|
||||||
|
@click="openSurvey"
|
||||||
>
|
>
|
||||||
<font-awesome-icon
|
<font-awesome-icon
|
||||||
:icon="['fas', 'file-invoice']"
|
:icon="['fas', 'file-invoice']"
|
||||||
class="text-text-primary inline"
|
class="text-text-primary inline"
|
||||||
/>
|
/>
|
||||||
<span class="text-text sm:hidden px-2">Survey</span>
|
<span class="text-text sm:hidden px-2">Survey</span>
|
||||||
</a>
|
</button>
|
||||||
<RouterLink
|
<RouterLink
|
||||||
title="LeaderBoard"
|
title="LeaderBoard"
|
||||||
to="/leaderboard"
|
to="/leaderboard"
|
||||||
|
|||||||
+71
-48
@@ -1,22 +1,40 @@
|
|||||||
import { defineStore } from 'pinia'
|
import { defineStore } from 'pinia'
|
||||||
import { FileGraph, FileObject } from '@linux-odyssey/file-graph'
|
import {
|
||||||
import { IQuest } from '@linux-odyssey/models'
|
FileGraph,
|
||||||
import { createSession, getActiveSession, getQuest } from '../utils/api'
|
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 Socket from '../utils/socket'
|
||||||
import SocketTerminal from '../utils/terminal'
|
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 socket = new Socket()
|
||||||
const term = new SocketTerminal(40, 80)
|
const term = new SocketTerminal()
|
||||||
let hasSetup = false
|
let hasSetup = false
|
||||||
|
|
||||||
|
interface QuestDetailResponse {
|
||||||
|
id: string
|
||||||
|
title: string
|
||||||
|
instruction: string
|
||||||
|
requirements: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SessionUpdate {
|
||||||
|
status: string
|
||||||
|
responses: IResponse[]
|
||||||
|
tasks: ITask[]
|
||||||
|
graphUpdate: FileGraphUpdateEvent
|
||||||
|
}
|
||||||
|
|
||||||
interface Store {
|
interface Store {
|
||||||
session: Session | null
|
session: Session | null
|
||||||
questId: string
|
questId: string
|
||||||
quest: IQuest | null
|
quest: QuestDetailResponse | null
|
||||||
}
|
}
|
||||||
|
|
||||||
const useSession = defineStore('session', {
|
export const useSession = defineStore('session', {
|
||||||
state: (): Store => ({
|
state: (): Store => ({
|
||||||
session: null,
|
session: null,
|
||||||
questId: '',
|
questId: '',
|
||||||
@@ -24,52 +42,55 @@ const useSession = defineStore('session', {
|
|||||||
}),
|
}),
|
||||||
actions: {
|
actions: {
|
||||||
async setQuest(questId: string) {
|
async setQuest(questId: string) {
|
||||||
this.quest = await getQuest(questId)
|
this.quest = await trpc.quests.getQuestDetail.query(questId)
|
||||||
this.questId = questId
|
this.questId = questId
|
||||||
},
|
},
|
||||||
async setSession(session: Session) {
|
async setSession(session: SessionDetail) {
|
||||||
this.session = session
|
this.session = {
|
||||||
this.session.graph = new FileGraph(session.graph)
|
...session,
|
||||||
|
graph: new FileGraph(session.graph),
|
||||||
|
pwd: '/home/commander',
|
||||||
|
containerName: session.containerName || '',
|
||||||
|
}
|
||||||
term.reset()
|
term.reset()
|
||||||
await socket.connect(session)
|
await socket.connect(this.session)
|
||||||
|
await term.connect(
|
||||||
|
`/terminal/${session.containerName}/ws?token=${session.token}`
|
||||||
|
)
|
||||||
term.focus()
|
term.focus()
|
||||||
},
|
},
|
||||||
async createSession() {
|
async createSession() {
|
||||||
const session = await createSession(this.questId)
|
const session = await trpc.session.createSession.mutate({
|
||||||
|
questId: this.questId,
|
||||||
|
})
|
||||||
await this.setSession(session)
|
await this.setSession(session)
|
||||||
socket.emit('terminal', 'echo start\n')
|
term.send('echo start\n')
|
||||||
},
|
},
|
||||||
async getActiveSession() {
|
async getActiveSession() {
|
||||||
const session = await getActiveSession(this.questId)
|
const session = await trpc.session.getActiveSession.query({
|
||||||
|
questId: this.questId,
|
||||||
|
})
|
||||||
if (session) {
|
if (session) {
|
||||||
this.setSession(session)
|
this.setSession(session)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
updateGraph(event: { discover?: FileObject[]; pwd?: string }) {
|
newUpdate(update: SessionUpdate) {
|
||||||
if (!this.session) return
|
if (!this.session) return
|
||||||
if (event.discover) {
|
this.session.responses = update.responses
|
||||||
this.session.graph.discover(event.discover)
|
this.session.tasks = update.tasks
|
||||||
}
|
this.session.status = update.status
|
||||||
if (event.pwd) {
|
// if (
|
||||||
this.session.pwd = event.pwd
|
// response.status === 'finished' &&
|
||||||
}
|
// this.session.status !== 'finished'
|
||||||
|
// ) {
|
||||||
|
// this.finish()
|
||||||
|
// }
|
||||||
|
// this.session.status = response.status
|
||||||
},
|
},
|
||||||
newResponse(response: StageResponse) {
|
finish() {
|
||||||
if (!this.session) return
|
if (!this.session) return
|
||||||
this.session.responses.push(response.responses)
|
this.session.status = 'finished'
|
||||||
this.session.hints.push(response.hints)
|
|
||||||
if (response.tasks) {
|
|
||||||
this.session.tasks = response.tasks
|
|
||||||
}
|
|
||||||
if (
|
|
||||||
response.status === 'finished' &&
|
|
||||||
this.session.status !== 'finished'
|
|
||||||
) {
|
|
||||||
this.finish()
|
|
||||||
}
|
|
||||||
this.session.status = response.status
|
|
||||||
},
|
},
|
||||||
finish() {},
|
|
||||||
reset() {
|
reset() {
|
||||||
socket.reset()
|
socket.reset()
|
||||||
term.reset()
|
term.reset()
|
||||||
@@ -77,17 +98,21 @@ const useSession = defineStore('session', {
|
|||||||
},
|
},
|
||||||
setup() {
|
setup() {
|
||||||
if (hasSetup) return
|
if (hasSetup) return
|
||||||
socket.on('terminal', (data: string) => {
|
// socket.on('terminal', (data: string) => {
|
||||||
term.write(data)
|
// term.write(data)
|
||||||
|
// })
|
||||||
|
// term.onData((data: string) => {
|
||||||
|
// socket.emit('terminal', data)
|
||||||
|
// })
|
||||||
|
socket.on('graph', (event: FileGraphUpdateEvent) => {
|
||||||
|
if (!this.session) return
|
||||||
|
this.session.graph.handleEvent(event)
|
||||||
|
if (event.pwd) {
|
||||||
|
this.session.pwd = event.pwd
|
||||||
|
}
|
||||||
})
|
})
|
||||||
term.onData((data: string) => {
|
socket.on('update', (update: SessionUpdate) => {
|
||||||
socket.emit('terminal', data)
|
this.newUpdate(update)
|
||||||
})
|
|
||||||
socket.on('graph', (event: { discover: FileObject[]; pwd: string }) => {
|
|
||||||
this.updateGraph(event)
|
|
||||||
})
|
|
||||||
socket.on('response', (response: StageResponse) => {
|
|
||||||
this.newResponse(response)
|
|
||||||
})
|
})
|
||||||
hasSetup = true
|
hasSetup = true
|
||||||
},
|
},
|
||||||
@@ -101,5 +126,3 @@ const useSession = defineStore('session', {
|
|||||||
export function useTerminal() {
|
export function useTerminal() {
|
||||||
return term
|
return term
|
||||||
}
|
}
|
||||||
|
|
||||||
export default useSession
|
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { defineStore } from 'pinia'
|
import { defineStore } from 'pinia'
|
||||||
import { getUserProfile } from '../utils/api'
|
import { trpc } from '../utils/trpc'
|
||||||
|
|
||||||
interface IProgress {
|
interface IProgress {
|
||||||
quest: string
|
quest: string
|
||||||
@@ -23,7 +23,7 @@ const useUserProfile = defineStore('userProfile', {
|
|||||||
}),
|
}),
|
||||||
actions: {
|
actions: {
|
||||||
async loadUserProfile() {
|
async loadUserProfile() {
|
||||||
const res = await getUserProfile()
|
const res = await trpc.users.getMyProfile.query()
|
||||||
this.$patch(res)
|
this.$patch(res)
|
||||||
},
|
},
|
||||||
async resetUserProfile() {
|
async resetUserProfile() {
|
||||||
|
|||||||
+6
-6
@@ -1,6 +1,5 @@
|
|||||||
import { FileGraph } from '@linux-odyssey/file-graph'
|
import type { FileGraph } from '../../packages/file-graph'
|
||||||
// eslint-disable-next-line import/no-relative-packages
|
import type { IResponse, ITask } from '../../packages/game'
|
||||||
import { IResponse } from '@linux-odyssey/models'
|
|
||||||
|
|
||||||
export interface Task {
|
export interface Task {
|
||||||
id: number
|
id: number
|
||||||
@@ -20,7 +19,8 @@ export interface Session {
|
|||||||
status: string
|
status: string
|
||||||
graph: FileGraph
|
graph: FileGraph
|
||||||
pwd: string
|
pwd: string
|
||||||
hints: string[][]
|
tasks: ITask[]
|
||||||
tasks: Task[]
|
responses: IResponse[]
|
||||||
responses: IResponse[][]
|
token: string
|
||||||
|
containerName: string
|
||||||
}
|
}
|
||||||
Vendored
+4
@@ -0,0 +1,4 @@
|
|||||||
|
declare module '*.css' {
|
||||||
|
const content: string
|
||||||
|
export default content
|
||||||
|
}
|
||||||
@@ -1,8 +1,4 @@
|
|||||||
import axios from 'axios'
|
import axios from 'axios'
|
||||||
import { IQuest } from '@linux-odyssey/models'
|
|
||||||
import { GetSessionsRequest } from '@linux-odyssey/constants'
|
|
||||||
import { Session } from '../types'
|
|
||||||
import { UserProfile } from '../store/userProfile'
|
|
||||||
|
|
||||||
const api = axios.create({
|
const api = axios.create({
|
||||||
baseURL: '/api/v1',
|
baseURL: '/api/v1',
|
||||||
@@ -12,39 +8,3 @@ const api = axios.create({
|
|||||||
withCredentials: true,
|
withCredentials: true,
|
||||||
})
|
})
|
||||||
export default api
|
export default api
|
||||||
|
|
||||||
export async function getUserProfile(): Promise<UserProfile> {
|
|
||||||
const res = await api.get<UserProfile>('/users/me')
|
|
||||||
return res.data
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getQuest(id: string): Promise<IQuest> {
|
|
||||||
const res = await api.get<IQuest>(`/quests/${id}`)
|
|
||||||
return res.data
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function createSession(questId: string): Promise<Session> {
|
|
||||||
const res = await api.post<Session>('/sessions', { questId })
|
|
||||||
return res.data
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getSessions(
|
|
||||||
request: GetSessionsRequest
|
|
||||||
): Promise<Session[]> {
|
|
||||||
const res = await api.get<Session[]>('/sessions', { params: request })
|
|
||||||
return res.data
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getActiveSession(
|
|
||||||
questId: string
|
|
||||||
): Promise<Session | null> {
|
|
||||||
const res = await api.get<Session | null>('/sessions/active', {
|
|
||||||
params: { questId },
|
|
||||||
})
|
|
||||||
return res.data
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getQuests(): Promise<IQuest[]> {
|
|
||||||
const res = await api.get<IQuest[]>('/quests')
|
|
||||||
return res.data
|
|
||||||
}
|
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import formbricks from '@formbricks/js/website'
|
import formbricks from '@formbricks/js/website'
|
||||||
import useSession from '../store/session'
|
import { useSession } from '../store/session'
|
||||||
import useUserProfile from '../store/userProfile'
|
import useUserProfile from '../store/userProfile'
|
||||||
|
|
||||||
if (typeof window !== 'undefined') {
|
if (typeof window !== 'undefined') {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { io, Socket } from 'socket.io-client'
|
import { io, Socket } from 'socket.io-client'
|
||||||
import { Session } from '../types'
|
import type { Session } from '../types'
|
||||||
|
|
||||||
type SocketCallback = (data: any) => void
|
type SocketCallback = (data: any) => void
|
||||||
|
|
||||||
@@ -23,11 +23,9 @@ class SocketWrapper {
|
|||||||
|
|
||||||
this.socket.on('connect', () => {
|
this.socket.on('connect', () => {
|
||||||
console.log('Socket connected to session', session._id)
|
console.log('Socket connected to session', session._id)
|
||||||
})
|
|
||||||
|
|
||||||
this.socket.once('terminal', () => {
|
|
||||||
resolve()
|
resolve()
|
||||||
})
|
})
|
||||||
|
|
||||||
this.socket.on('connect_error', reject)
|
this.socket.on('connect_error', reject)
|
||||||
this.bindListeners()
|
this.bindListeners()
|
||||||
})
|
})
|
||||||
|
|||||||
+56
-20
@@ -1,47 +1,72 @@
|
|||||||
import { Terminal } from 'xterm'
|
import { Terminal } from 'xterm'
|
||||||
import { FitAddon } from 'xterm-addon-fit'
|
import { FitAddon } from 'xterm-addon-fit'
|
||||||
|
import ReconnectingWebSocket from 'reconnecting-websocket'
|
||||||
import 'xterm/css/xterm.css'
|
import 'xterm/css/xterm.css'
|
||||||
|
|
||||||
class SocketTerminal {
|
class SocketTerminal {
|
||||||
private term: Terminal
|
private term: Terminal
|
||||||
private fitAddon: FitAddon
|
private fitAddon: FitAddon
|
||||||
|
private socket: ReconnectingWebSocket | null = null
|
||||||
|
|
||||||
constructor(rows: number, cols: number) {
|
constructor() {
|
||||||
this.term = new Terminal({
|
this.term = new Terminal({
|
||||||
rows, // 行数
|
convertEol: true,
|
||||||
cols, // 不指定行数,自动回车后光标从下一行开始
|
scrollback: 1000,
|
||||||
convertEol: true, // 启用时,光标将设置为下一行的开头
|
fontSize: 16,
|
||||||
// scrollback: 50, //终端中的回滚量
|
|
||||||
disableStdin: false, // 是否应禁用输入
|
|
||||||
// cursorStyle: "underline", //光标样式
|
|
||||||
cursorBlink: true, // 光标闪烁
|
|
||||||
theme: {
|
theme: {
|
||||||
foreground: '#ECECEC', // 字体
|
foreground: '#ECECEC',
|
||||||
background: '#000000', // 背景色
|
background: '#000000',
|
||||||
cursor: 'help', // 设置光标
|
cursor: 'help',
|
||||||
},
|
},
|
||||||
fontSize: 18,
|
|
||||||
fontWeight: 'normal',
|
|
||||||
})
|
})
|
||||||
this.fitAddon = new FitAddon()
|
this.fitAddon = new FitAddon()
|
||||||
this.term.loadAddon(this.fitAddon)
|
this.term.loadAddon(this.fitAddon)
|
||||||
this.fitAddon.fit()
|
this.term.onData((data) => {
|
||||||
|
if (this.socket) {
|
||||||
|
this.socket.send(data)
|
||||||
|
}
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
write(message: string): void {
|
connect(url: string): Promise<void> {
|
||||||
this.term.write(message)
|
return new Promise((resolve, reject) => {
|
||||||
|
console.log('Connecting to terminal', url)
|
||||||
|
this.term.write('\r\n\x1B[1;32mConnecting to terminal\x1B[0m\r\n')
|
||||||
|
this.socket = new ReconnectingWebSocket(url, [], {
|
||||||
|
connectionTimeout: 5000,
|
||||||
|
maxRetries: 10,
|
||||||
|
})
|
||||||
|
this.socket.onopen = () => {
|
||||||
|
console.log('Connected to terminal')
|
||||||
|
this.term.reset()
|
||||||
|
this.term.write('\r\n\x1B[1;32mConnected to terminal\x1B[0m\r\n')
|
||||||
|
resolve()
|
||||||
}
|
}
|
||||||
|
|
||||||
onData(callback: any): void {
|
this.socket.onclose = () => {
|
||||||
this.term.onData(callback)
|
console.log('Disconnected from terminal')
|
||||||
}
|
}
|
||||||
|
|
||||||
focus(): void {
|
this.socket.onerror = (error) => {
|
||||||
this.term.focus()
|
console.error('WebSocket error:', error)
|
||||||
|
}
|
||||||
|
this.socket.onmessage = (event) => {
|
||||||
|
console.log('Received message from terminal')
|
||||||
|
const data =
|
||||||
|
typeof event.data === 'string'
|
||||||
|
? event.data
|
||||||
|
: new TextDecoder().decode(event.data)
|
||||||
|
this.term.write(data)
|
||||||
|
}
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
resizeScreen(): void {
|
resizeScreen(): void {
|
||||||
|
try {
|
||||||
this.fitAddon.fit()
|
this.fitAddon.fit()
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to resize terminal:', e)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
mount(reference: HTMLElement): void {
|
mount(reference: HTMLElement): void {
|
||||||
@@ -49,8 +74,19 @@ class SocketTerminal {
|
|||||||
this.resizeScreen()
|
this.resizeScreen()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
send(message: string): void {
|
||||||
|
if (this.socket) {
|
||||||
|
this.socket.send(message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
focus(): void {
|
||||||
|
this.term.focus()
|
||||||
|
}
|
||||||
|
|
||||||
reset(): void {
|
reset(): void {
|
||||||
this.term.reset()
|
this.term.reset()
|
||||||
|
this.socket?.close()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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">
|
<script setup lang="ts">
|
||||||
import { isValidUsername } from '@linux-odyssey/utils'
|
import { isValidUsername } from '../../../packages/utils'
|
||||||
import AuthForm from '../components/AuthForm.vue'
|
import AuthForm from '../components/AuthForm.vue'
|
||||||
import Background from '../components/DynamicBackground.vue'
|
import Background from '../components/DynamicBackground.vue'
|
||||||
import { TooManyRequestsError, ValidationError } from '../utils/errors'
|
import { TooManyRequestsError, ValidationError } from '../utils/errors'
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onMounted, watch } from 'vue'
|
import { computed, onMounted, watch } from 'vue'
|
||||||
import useSession from '../store/session'
|
import { useSession } from '../store/session'
|
||||||
import useUserProfile from '../store/userProfile'
|
import useUserProfile from '../store/userProfile'
|
||||||
import HeaderPart from '../components/header/HeaderPart.vue'
|
import HeaderPart from '../components/header/HeaderPart.vue'
|
||||||
import GameHeaderComponents from '../components/header/GameHeaderComponents.vue'
|
import GameHeaderComponents from '../components/header/GameHeaderComponents.vue'
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import validator from 'validator'
|
import validator from 'validator'
|
||||||
import { isValidUsername } from '@linux-odyssey/utils'
|
import { isValidUsername } from '../../../packages/utils'
|
||||||
import { passwordPolicy } from '@linux-odyssey/constants'
|
import { passwordPolicy } from '../../../packages/constants'
|
||||||
import AuthForm from '../components/AuthForm.vue'
|
import AuthForm from '../components/AuthForm.vue'
|
||||||
import DynamicBackground from '../components/DynamicBackground.vue'
|
import DynamicBackground from '../components/DynamicBackground.vue'
|
||||||
import HeaderPart from '../components/header/HeaderPart.vue'
|
import HeaderPart from '../components/header/HeaderPart.vue'
|
||||||
|
|||||||
@@ -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": {
|
"files": [],
|
||||||
"target": "ES2020",
|
|
||||||
"useDefineForClassFields": true,
|
|
||||||
"module": "ESNext",
|
|
||||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
|
||||||
"skipLibCheck": true,
|
|
||||||
|
|
||||||
/* Bundler mode */
|
|
||||||
"moduleResolution": "bundler",
|
|
||||||
"allowImportingTsExtensions": true,
|
|
||||||
"resolveJsonModule": true,
|
|
||||||
"isolatedModules": true,
|
|
||||||
"noEmit": true,
|
|
||||||
"jsx": "preserve",
|
|
||||||
|
|
||||||
/* Linting */
|
|
||||||
"strict": true,
|
|
||||||
"noUnusedLocals": true,
|
|
||||||
"noUnusedParameters": true,
|
|
||||||
"noFallthroughCasesInSwitch": true
|
|
||||||
},
|
|
||||||
"include": ["src/**/*.ts", "src/**/*.vue"],
|
|
||||||
"references": [
|
"references": [
|
||||||
{ "path": "../packages/utils" },
|
{ "path": "./tsconfig.app.json" },
|
||||||
{ "path": "../packages/constants" },
|
{ "path": "./tsconfig.node.json" }
|
||||||
{ "path": "../packages/file-graph" },
|
|
||||||
{ "path": "../packages/models" }
|
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -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,27 @@
|
|||||||
import { defineConfig } from 'vite'
|
import { defineConfig } from 'vite'
|
||||||
import vue from '@vitejs/plugin-vue'
|
import vue from '@vitejs/plugin-vue'
|
||||||
|
import path from 'path'
|
||||||
|
|
||||||
// https://vitejs.dev/config/
|
// https://vitejs.dev/config/
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [vue()],
|
plugins: [vue()],
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
'@game': path.resolve(__dirname, '../packages/game'),
|
||||||
|
'@models': path.resolve(__dirname, '../packages/models'),
|
||||||
|
'@utils': path.resolve(__dirname, '../packages/utils'),
|
||||||
|
'@constants': path.resolve(__dirname, '../packages/constants'),
|
||||||
|
'@file-graph': path.resolve(__dirname, '../packages/file-graph'),
|
||||||
|
},
|
||||||
|
},
|
||||||
server: {
|
server: {
|
||||||
|
host: '0.0.0.0',
|
||||||
proxy: {
|
proxy: {
|
||||||
|
'/trpc': {
|
||||||
|
target: process.env.API_TARGET || 'http://localhost:3000',
|
||||||
|
changeOrigin: false,
|
||||||
|
xfwd: true,
|
||||||
|
},
|
||||||
'/api': {
|
'/api': {
|
||||||
target: process.env.API_TARGET || 'http://localhost:3000',
|
target: process.env.API_TARGET || 'http://localhost:3000',
|
||||||
changeOrigin: false,
|
changeOrigin: false,
|
||||||
|
|||||||
+1
-18
@@ -1,27 +1,10 @@
|
|||||||
server {
|
server {
|
||||||
listen 80;
|
listen 80;
|
||||||
|
error_log /var/log/nginx/error.log debug;
|
||||||
|
|
||||||
location / {
|
location / {
|
||||||
root /usr/share/nginx/html;
|
root /usr/share/nginx/html;
|
||||||
index index.html;
|
index index.html;
|
||||||
try_files $uri $uri/ /index.html;
|
try_files $uri $uri/ /index.html;
|
||||||
}
|
}
|
||||||
|
|
||||||
location /api/ {
|
|
||||||
proxy_pass http://backend:3000;
|
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
|
||||||
}
|
|
||||||
|
|
||||||
location /api-docs/ {
|
|
||||||
proxy_pass http://backend:3000;
|
|
||||||
}
|
|
||||||
|
|
||||||
location /socket.io/ {
|
|
||||||
proxy_pass http://backend:3000;
|
|
||||||
proxy_http_version 1.1;
|
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
|
||||||
proxy_set_header Upgrade $http_upgrade;
|
|
||||||
proxy_set_header Connection "upgrade";
|
|
||||||
proxy_set_header Host $host;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
+47
-11
@@ -1,6 +1,23 @@
|
|||||||
services:
|
services:
|
||||||
|
traefik:
|
||||||
|
image: traefik:v3.6.9
|
||||||
|
ports:
|
||||||
|
- 8000:8000
|
||||||
|
- 8080:8080
|
||||||
|
networks:
|
||||||
|
- players
|
||||||
|
- internal
|
||||||
|
command:
|
||||||
|
- '--api.insecure=true'
|
||||||
|
- '--providers.docker=true'
|
||||||
|
- '--providers.docker.exposedbydefault=false'
|
||||||
|
- '--entrypoints.web.address=:8000'
|
||||||
|
- '--entrypoints.web.forwardedHeaders.insecure=true' # trust all X-Forwarded headers
|
||||||
|
volumes:
|
||||||
|
- /var/run/docker.sock:/var/run/docker.sock:ro
|
||||||
|
|
||||||
app:
|
app:
|
||||||
image: linuxodyssey/frontend
|
image: linuxodyssey/frontend:${IMAGE_TAG:-latest}
|
||||||
depends_on:
|
depends_on:
|
||||||
- backend
|
- backend
|
||||||
restart: always
|
restart: always
|
||||||
@@ -9,12 +26,16 @@ services:
|
|||||||
dockerfile: app/Dockerfile
|
dockerfile: app/Dockerfile
|
||||||
target: prod
|
target: prod
|
||||||
networks:
|
networks:
|
||||||
internal:
|
- internal
|
||||||
ports:
|
labels:
|
||||||
- 8080:80
|
- 'traefik.enable=true'
|
||||||
|
- 'traefik.http.routers.app.entrypoints=web'
|
||||||
|
- 'traefik.http.routers.app.rule=PathPrefix(`/`)'
|
||||||
|
- 'traefik.http.routers.app.service=app'
|
||||||
|
- 'traefik.http.services.app.loadbalancer.server.port=80'
|
||||||
|
|
||||||
backend:
|
backend:
|
||||||
image: linuxodyssey/backend
|
image: linuxodyssey/backend:${IMAGE_TAG:-latest}
|
||||||
depends_on:
|
depends_on:
|
||||||
- db
|
- db
|
||||||
restart: always
|
restart: always
|
||||||
@@ -31,17 +52,32 @@ services:
|
|||||||
- ./config:/app/config
|
- ./config:/app/config
|
||||||
- ./quests:/app/quests:ro
|
- ./quests:/app/quests:ro
|
||||||
env_file:
|
env_file:
|
||||||
- .env
|
- .env.prod
|
||||||
environment:
|
environment:
|
||||||
- DOCKER_NETWORK=linux-odyssey-players
|
- DOCKER_NETWORK=linux-odyssey-players
|
||||||
- BACKEND_URL=http://backend:3000
|
- BACKEND_URL=http://backend:3000
|
||||||
- HOST_PROJECT_ROOT=${PWD}
|
- HOST_PROJECT_ROOT=${PWD}
|
||||||
|
labels:
|
||||||
|
- 'traefik.enable=true'
|
||||||
|
- 'traefik.http.routers.backend-api.entrypoints=web'
|
||||||
|
- 'traefik.http.routers.backend-api.rule=PathPrefix(`/api`)'
|
||||||
|
- 'traefik.http.routers.backend-api.service=backend'
|
||||||
|
- 'traefik.http.routers.backend-socket.entrypoints=web'
|
||||||
|
- 'traefik.http.routers.backend-socket.rule=PathPrefix(`/socket.io`)'
|
||||||
|
- 'traefik.http.routers.backend-socket.service=backend'
|
||||||
|
- 'traefik.http.routers.backend-trpc.entrypoints=web'
|
||||||
|
- 'traefik.http.routers.backend-trpc.rule=PathPrefix(`/trpc`)'
|
||||||
|
- 'traefik.http.routers.backend-trpc.service=backend'
|
||||||
|
- 'traefik.http.routers.backend-api-docs.entrypoints=web'
|
||||||
|
- 'traefik.http.routers.backend-api-docs.rule=PathPrefix(`/api-docs`)'
|
||||||
|
- 'traefik.http.routers.backend-api-docs.service=backend'
|
||||||
|
- 'traefik.http.services.backend.loadbalancer.server.port=3000'
|
||||||
|
|
||||||
analytics:
|
analytics:
|
||||||
image: linuxodyssey/analytics
|
image: linuxodyssey/analytics:${IMAGE_TAG:-latest}
|
||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
dockerfile: packages/analytics/Dockerfile
|
dockerfile: services/analytics/Dockerfile
|
||||||
target: prod
|
target: prod
|
||||||
restart: always
|
restart: always
|
||||||
depends_on:
|
depends_on:
|
||||||
@@ -51,7 +87,7 @@ services:
|
|||||||
ports:
|
ports:
|
||||||
- '3001:3001'
|
- '3001:3001'
|
||||||
env_file:
|
env_file:
|
||||||
- .env
|
- .env.prod
|
||||||
|
|
||||||
db:
|
db:
|
||||||
image: mongo:6
|
image: mongo:6
|
||||||
@@ -64,10 +100,10 @@ services:
|
|||||||
- mongo:/data/db
|
- mongo:/data/db
|
||||||
- ./mongo-init.js:/docker-entrypoint-initdb.d/mongo-init.js:ro
|
- ./mongo-init.js:/docker-entrypoint-initdb.d/mongo-init.js:ro
|
||||||
env_file:
|
env_file:
|
||||||
- .env
|
- .env.prod
|
||||||
|
|
||||||
base:
|
base:
|
||||||
image: linuxodyssey/quest-base
|
image: linuxodyssey/quest-base:${IMAGE_TAG:-latest}
|
||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
dockerfile: ./quests/Dockerfile
|
dockerfile: ./quests/Dockerfile
|
||||||
|
|||||||
@@ -1,5 +1,25 @@
|
|||||||
version: '3'
|
version: '3'
|
||||||
services:
|
services:
|
||||||
|
traefik:
|
||||||
|
image: traefik:v3.6.9
|
||||||
|
networks:
|
||||||
|
- players-dev
|
||||||
|
command:
|
||||||
|
- '--api.insecure=true'
|
||||||
|
- '--providers.docker=true'
|
||||||
|
- '--providers.docker.exposedbydefault=false'
|
||||||
|
- '--providers.file.directory=/etc/traefik/conf'
|
||||||
|
- '--providers.file.watch=true'
|
||||||
|
- '--entrypoints.web.address=:8000'
|
||||||
|
ports:
|
||||||
|
- '8000:8000'
|
||||||
|
- '8080:8080'
|
||||||
|
extra_hosts:
|
||||||
|
- 'host.docker.internal:host-gateway'
|
||||||
|
volumes:
|
||||||
|
- /var/run/docker.sock:/var/run/docker.sock:ro
|
||||||
|
- ./traefik/conf:/etc/traefik/conf
|
||||||
|
|
||||||
db:
|
db:
|
||||||
image: mongo:6
|
image: mongo:6
|
||||||
restart: always
|
restart: always
|
||||||
@@ -19,6 +39,13 @@ services:
|
|||||||
dockerfile: ./quests/Dockerfile
|
dockerfile: ./quests/Dockerfile
|
||||||
extra_hosts:
|
extra_hosts:
|
||||||
- 'host.docker.internal:host-gateway'
|
- 'host.docker.internal:host-gateway'
|
||||||
|
labels:
|
||||||
|
- 'traefik.enable=true'
|
||||||
|
- 'traefik.http.routers.base.rule=PathPrefix(`/terminal/base`)'
|
||||||
|
- 'traefik.http.middlewares.base-stripprefix.stripprefix.prefixes=/terminal/base'
|
||||||
|
- 'traefik.http.routers.base.middlewares=base-stripprefix'
|
||||||
|
- 'traefik.http.routers.base.entrypoints=web'
|
||||||
|
- 'traefik.http.services.base.loadbalancer.server.port=9090'
|
||||||
|
|
||||||
networks:
|
networks:
|
||||||
internal:
|
internal:
|
||||||
|
|||||||
+11
-10
@@ -9,6 +9,7 @@
|
|||||||
"app",
|
"app",
|
||||||
"server",
|
"server",
|
||||||
"packages/*",
|
"packages/*",
|
||||||
|
"services/*",
|
||||||
"tests"
|
"tests"
|
||||||
],
|
],
|
||||||
"scripts": {
|
"scripts": {
|
||||||
@@ -16,7 +17,6 @@
|
|||||||
"build": "yarn workspace server build && yarn workspace app build && yarn workspace analytics build",
|
"build": "yarn workspace server build && yarn workspace app build && yarn workspace analytics build",
|
||||||
"dev": "concurrently -n \"App,Server\" -c \"bgBlue.bold,bgGreen.bold\" \"yarn workspace app dev\" \"yarn workspace server dev\"",
|
"dev": "concurrently -n \"App,Server\" -c \"bgBlue.bold,bgGreen.bold\" \"yarn workspace app dev\" \"yarn workspace server dev\"",
|
||||||
"server": "yarn workspace server dev",
|
"server": "yarn workspace server dev",
|
||||||
"cli": "yarn workspace client start",
|
|
||||||
"lint": "eslint .",
|
"lint": "eslint .",
|
||||||
"format": "prettier --ignore-path .prettierignore --write app server",
|
"format": "prettier --ignore-path .prettierignore --write app server",
|
||||||
"test": "yarn node --experimental-vm-modules --no-warnings $(yarn bin jest)",
|
"test": "yarn node --experimental-vm-modules --no-warnings $(yarn bin jest)",
|
||||||
@@ -34,28 +34,29 @@
|
|||||||
"@jest/globals": "^29.7.0",
|
"@jest/globals": "^29.7.0",
|
||||||
"@types/jest": "^29.5.12",
|
"@types/jest": "^29.5.12",
|
||||||
"@types/node": "^20.10.4",
|
"@types/node": "^20.10.4",
|
||||||
"@typescript-eslint/eslint-plugin": "^6.14.0",
|
"@typescript-eslint/eslint-plugin": "^6.21.0",
|
||||||
"@typescript-eslint/parser": "^6.14.0",
|
"@typescript-eslint/parser": "^6.21.0",
|
||||||
"@vue/eslint-config-prettier": "^7.1.0",
|
"@vue/eslint-config-prettier": "^7.1.0",
|
||||||
"babel-jest": "^29.7.0",
|
"babel-jest": "^29.7.0",
|
||||||
"concurrently": "^8.2.2",
|
"concurrently": "^8.2.2",
|
||||||
"eslint": "^8.44.0",
|
"eslint": "^8.56.0",
|
||||||
"eslint-config-airbnb-base": "^15.0.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-import-resolver-typescript": "^3.6.1",
|
||||||
"eslint-plugin-cypress": "^2.15.1",
|
"eslint-plugin-cypress": "^2.15.1",
|
||||||
"eslint-plugin-import": "^2.29.0",
|
"eslint-plugin-import": "^2.29.0",
|
||||||
"eslint-plugin-node": "^11.1.0",
|
"eslint-plugin-prettier": "^5.1.3",
|
||||||
"eslint-plugin-prettier": "^5.0.0",
|
|
||||||
"eslint-plugin-vue": "^9.15.1",
|
"eslint-plugin-vue": "^9.15.1",
|
||||||
|
"globals": "^16.0.0",
|
||||||
"jest": "^29.7.0",
|
"jest": "^29.7.0",
|
||||||
"prettier": "^3.0.0",
|
"prettier": "^3.0.0",
|
||||||
"ts-node": "^10.9.2",
|
"ts-node": "^10.9.2",
|
||||||
"typescript": "^5.3.3"
|
"typescript": "^5.3.3",
|
||||||
|
"vue-eslint-parser": "^9.4.2",
|
||||||
|
"zod-to-json-schema": "^3.24.5"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"latest": "^0.2.0",
|
"latest": "^0.2.0"
|
||||||
"typescript-eslint": "^7.4.0"
|
|
||||||
},
|
},
|
||||||
"packageManager": "yarn@1.22.22+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e"
|
"packageManager": "yarn@1.22.22+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e"
|
||||||
}
|
}
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
FROM node:20-alpine as build
|
|
||||||
|
|
||||||
WORKDIR /app
|
|
||||||
COPY ./package.json yarn.lock tsconfig.json ./
|
|
||||||
COPY packages/analytics/package.json ./packages/analytics/package.json
|
|
||||||
COPY packages/models/package.json ./packages/models/package.json
|
|
||||||
RUN yarn install --frozen-lockfile
|
|
||||||
ENV HOST=0.0.0.0
|
|
||||||
ENV PORT=3001
|
|
||||||
EXPOSE 3001
|
|
||||||
|
|
||||||
# Production stage
|
|
||||||
FROM build as prod
|
|
||||||
COPY packages/analytics ./packages/analytics
|
|
||||||
COPY packages/models ./packages/models
|
|
||||||
ENV NODE_ENV=production
|
|
||||||
WORKDIR /app/packages/analytics
|
|
||||||
RUN yarn build
|
|
||||||
CMD [ "yarn", "start" ]
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
{
|
|
||||||
"watch": ["src", "views"],
|
|
||||||
"ext": "ts,handlebars,js",
|
|
||||||
"ignore": ["src/**/*.spec.ts"],
|
|
||||||
"exec": "node --loader ts-node/esm src/index.ts"
|
|
||||||
}
|
|
||||||
Whitespace-only changes.
@@ -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)
|
||||||
@@ -1,98 +0,0 @@
|
|||||||
/* eslint-disable no-restricted-syntax */
|
|
||||||
const fs = require('fs').promises
|
|
||||||
const { exit } = require('process')
|
|
||||||
const minimist = require('minimist')
|
|
||||||
const axios = require('axios')
|
|
||||||
const { colorize, printResponses, printHints } = require('./print.js')
|
|
||||||
const discoverFiles = require('./discover.js')
|
|
||||||
|
|
||||||
const {
|
|
||||||
API_ENDPOINT,
|
|
||||||
TOKEN,
|
|
||||||
PWD,
|
|
||||||
CMD_NAME,
|
|
||||||
CMD_OUTPUT_FILE,
|
|
||||||
CMD_ERROR_FILE,
|
|
||||||
CMD_EXIT_CODE,
|
|
||||||
} = process.env
|
|
||||||
|
|
||||||
const api = axios.create({
|
|
||||||
baseURL: `${API_ENDPOINT}/api/v1`,
|
|
||||||
headers: {
|
|
||||||
Authorization: `Bearer ${TOKEN}`,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
async function readOrNone(file) {
|
|
||||||
try {
|
|
||||||
return await fs.readFile(file, 'utf8')
|
|
||||||
} catch (_) {
|
|
||||||
return ''
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const commandListeners = {
|
|
||||||
ls: [discoverFiles],
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleCommand(command) {
|
|
||||||
const argv = minimist(command.split(' '))
|
|
||||||
const name = argv._[0]
|
|
||||||
const listeners = commandListeners[name]
|
|
||||||
if (!listeners) return {}
|
|
||||||
return listeners.reduce(async (result, listener) => {
|
|
||||||
const res = await listener(argv)
|
|
||||||
return { ...res, ...result }
|
|
||||||
}, {})
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleResponse({ responses, hints, end }) {
|
|
||||||
if (responses) {
|
|
||||||
await printResponses(responses, 90)
|
|
||||||
}
|
|
||||||
if (hints) {
|
|
||||||
await printHints(hints, 90)
|
|
||||||
}
|
|
||||||
if (end) {
|
|
||||||
console.log(colorize('Quest completed!', 'blue'))
|
|
||||||
}
|
|
||||||
await api.post('/commands/completed')
|
|
||||||
}
|
|
||||||
|
|
||||||
async function main() {
|
|
||||||
if (!CMD_NAME) exit(0)
|
|
||||||
|
|
||||||
const params = await handleCommand(CMD_NAME)
|
|
||||||
|
|
||||||
const output = await readOrNone(CMD_OUTPUT_FILE)
|
|
||||||
const error = await readOrNone(CMD_ERROR_FILE)
|
|
||||||
const payload = {
|
|
||||||
command: CMD_NAME,
|
|
||||||
output,
|
|
||||||
error,
|
|
||||||
exit_code: CMD_EXIT_CODE,
|
|
||||||
pwd: PWD,
|
|
||||||
params,
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
const res = await api.post('/commands', payload)
|
|
||||||
if (!res.data || !res.data.responses) return
|
|
||||||
await handleResponse(res.data)
|
|
||||||
} catch (err) {
|
|
||||||
if (err.response) {
|
|
||||||
console.error(err.response.data)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
console.error(err.message)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
main()
|
|
||||||
.then(exit)
|
|
||||||
.catch((err) => {
|
|
||||||
console.log(
|
|
||||||
'發生預期外的錯誤,請嘗試重新啟動 Quest,如果問題持續發生,請幫我們填寫右上角的錯誤回報表單。'
|
|
||||||
)
|
|
||||||
console.error(err)
|
|
||||||
exit(1)
|
|
||||||
})
|
|
||||||
@@ -1,49 +0,0 @@
|
|||||||
const fs = require('fs').promises
|
|
||||||
const path = require('path')
|
|
||||||
|
|
||||||
async function collectFilesInfo(inputPath, level = 0, hiddenFiles = false) {
|
|
||||||
const stats = await fs.stat(inputPath)
|
|
||||||
const name = path.basename(inputPath)
|
|
||||||
const discovered = level <= 1 && (hiddenFiles || !name.startsWith('.'))
|
|
||||||
const files = [
|
|
||||||
{
|
|
||||||
path: inputPath,
|
|
||||||
name,
|
|
||||||
type: stats.isDirectory() ? 'folder' : 'file',
|
|
||||||
discovered,
|
|
||||||
},
|
|
||||||
]
|
|
||||||
if (stats.isDirectory() && level < 2) {
|
|
||||||
try {
|
|
||||||
let entries = await fs.readdir(inputPath)
|
|
||||||
if (level > 0) {
|
|
||||||
entries = entries.slice(0, 5)
|
|
||||||
}
|
|
||||||
const result = await Promise.all(
|
|
||||||
entries.map((entry) =>
|
|
||||||
collectFilesInfo(path.join(inputPath, entry), level + 1, hiddenFiles)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
files.push(...result.flat())
|
|
||||||
} catch (_) {
|
|
||||||
// ignore
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return files
|
|
||||||
}
|
|
||||||
|
|
||||||
async function discoverFiles(argv) {
|
|
||||||
const targetPath = argv._.length < 2 ? ['.'] : argv._.slice(1)
|
|
||||||
try {
|
|
||||||
const result = await Promise.all(
|
|
||||||
targetPath
|
|
||||||
.map((p) => path.resolve(process.cwd(), p))
|
|
||||||
.map((p) => collectFilesInfo(p, 0, argv.a || argv.all))
|
|
||||||
)
|
|
||||||
return { discover: result.flat() }
|
|
||||||
} catch (_) {
|
|
||||||
return {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = discoverFiles
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "container",
|
|
||||||
"version": "1.0.0",
|
|
||||||
"main": "index.js",
|
|
||||||
"license": "MIT",
|
|
||||||
"scripts": {
|
|
||||||
"build": "",
|
|
||||||
"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,
|
|
||||||
}
|
|
||||||
File renamed without changes.
@@ -2,25 +2,28 @@ import { basename } from './utils.js'
|
|||||||
|
|
||||||
export interface FileObject {
|
export interface FileObject {
|
||||||
path: string
|
path: string
|
||||||
type: string
|
type: 'file' | 'directory'
|
||||||
discovered: boolean
|
discovered: boolean
|
||||||
|
empty?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export default class File implements FileObject {
|
export default class File implements FileObject {
|
||||||
path: string
|
path: string
|
||||||
name: string
|
name: string
|
||||||
type: string
|
type: 'file' | 'directory'
|
||||||
discovered: boolean
|
discovered: boolean
|
||||||
|
empty?: boolean
|
||||||
|
|
||||||
constructor(obj: FileObject) {
|
constructor(obj: FileObject) {
|
||||||
this.path = obj.path.trimEnd()
|
this.path = obj.path.trimEnd()
|
||||||
this.name = basename(obj.path)
|
this.name = basename(obj.path)
|
||||||
this.type = obj.type
|
this.type = obj.type
|
||||||
this.discovered = obj.discovered
|
this.discovered = obj.discovered
|
||||||
|
this.empty = obj.empty && obj.type === 'directory'
|
||||||
}
|
}
|
||||||
|
|
||||||
isDirectory() {
|
isDirectory() {
|
||||||
return this.type === 'folder'
|
return this.type === 'directory'
|
||||||
}
|
}
|
||||||
|
|
||||||
contains(file: FileObject) {
|
contains(file: FileObject) {
|
||||||
@@ -1,10 +1,11 @@
|
|||||||
import { FileObject } from './file.js'
|
import { FileObject } from './file.js'
|
||||||
import FileNode from './fileNode.js'
|
import FileNode from './fileNode.js'
|
||||||
|
|
||||||
export interface Event {
|
export interface FileGraphUpdateEvent {
|
||||||
add?: FileObject[]
|
add?: FileObject[]
|
||||||
remove?: FileObject[]
|
remove?: FileObject[]
|
||||||
discover?: FileObject[]
|
discover?: FileObject[]
|
||||||
|
pwd?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export default class FileGraph extends FileNode {
|
export default class FileGraph extends FileNode {
|
||||||
@@ -20,7 +21,7 @@ export default class FileGraph extends FileNode {
|
|||||||
|
|
||||||
remove(files: FileObject[]) {
|
remove(files: FileObject[]) {
|
||||||
// Logic to remove a file node from the graph
|
// Logic to remove a file node from the graph
|
||||||
// If the file node is a 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
|
// If the file node doesn't exist, throw an error
|
||||||
|
|
||||||
files.forEach((file) => {
|
files.forEach((file) => {
|
||||||
@@ -33,6 +34,10 @@ export default class FileGraph extends FileNode {
|
|||||||
// Overwrite the file graph with the provided files
|
// Overwrite the file graph with the provided files
|
||||||
// Remove file nodes that don't exist anymore and add new ones
|
// Remove file nodes that don't exist anymore and add new ones
|
||||||
|
|
||||||
|
if (eventFiles.length === 0) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
const newNode = new FileNode(eventFiles[0])
|
const newNode = new FileNode(eventFiles[0])
|
||||||
|
|
||||||
eventFiles.slice(1).forEach((file) => {
|
eventFiles.slice(1).forEach((file) => {
|
||||||
@@ -42,19 +47,16 @@ export default class FileGraph extends FileNode {
|
|||||||
this.merge(newNode)
|
this.merge(newNode)
|
||||||
}
|
}
|
||||||
|
|
||||||
handleEvent(event: Event) {
|
handleEvent(event: FileGraphUpdateEvent) {
|
||||||
// eslint-disable-next-line default-case
|
// eslint-disable-next-line default-case
|
||||||
switch (true) {
|
if (event.add) {
|
||||||
case !!event.add:
|
|
||||||
this.add(event.add)
|
this.add(event.add)
|
||||||
break
|
}
|
||||||
case !!event.remove:
|
if (event.remove) {
|
||||||
this.remove(event.remove)
|
this.remove(event.remove)
|
||||||
break
|
}
|
||||||
case !!event.discover:
|
if (event.discover) {
|
||||||
this.discover(event.discover)
|
this.discover(event.discover)
|
||||||
break
|
|
||||||
// Implement other cases if needed
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,15 +1,18 @@
|
|||||||
import File, { FileObject } from './file.js'
|
import File, { FileObject } from './file.js'
|
||||||
import {
|
import { ParentNotExistsError, FileNotExistsError } from './errors.js'
|
||||||
DuplicateItemError,
|
|
||||||
ParentNotExistsError,
|
|
||||||
FileNotExistsError,
|
|
||||||
} from './errors.js'
|
|
||||||
import { dirname } from './utils.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[]
|
children: FileNode[]
|
||||||
|
|
||||||
constructor(file: FileObject | FileNode) {
|
constructor(file: FileObject | IFileNode) {
|
||||||
super(file)
|
super(file)
|
||||||
this.children = []
|
this.children = []
|
||||||
if ((<FileNode>file).children)
|
if ((<FileNode>file).children)
|
||||||
@@ -22,15 +25,24 @@ export default class FileNode extends File {
|
|||||||
) {
|
) {
|
||||||
const file = new FileNode(fileInput)
|
const file = new FileNode(fileInput)
|
||||||
if (file.path === this.path) {
|
if (file.path === this.path) {
|
||||||
throw new DuplicateItemError(
|
if (!this.discovered) {
|
||||||
`File ${file.path} already exists in the tree: ${this.toString()}`
|
this.discovered = file.discovered
|
||||||
)
|
}
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.children.some((child) => child.path === file.path)) {
|
let existing = false
|
||||||
throw new DuplicateItemError(
|
for (const child of this.children) {
|
||||||
`File ${file.path} already exists in the tree: ${this.toString()}`
|
if (child.path === file.path) {
|
||||||
)
|
existing = true
|
||||||
|
if (!child.discovered) {
|
||||||
|
child.discovered = file.discovered
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (existing) {
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const parentPath = dirname(file.path)
|
const parentPath = dirname(file.path)
|
||||||
@@ -41,11 +53,11 @@ export default class FileNode extends File {
|
|||||||
// Recursively call addChild on the correct child node
|
// Recursively call addChild on the correct child node
|
||||||
const childNode = this.children.find((child) => child.contains(file))
|
const childNode = this.children.find((child) => child.contains(file))
|
||||||
if (childNode) {
|
if (childNode) {
|
||||||
childNode.addChild(file)
|
childNode.addChild(file, { makeParents })
|
||||||
} else if (makeParents) {
|
} else if (makeParents) {
|
||||||
const parent = new FileNode({
|
const parent = new FileNode({
|
||||||
path: parentPath,
|
path: parentPath,
|
||||||
type: 'folder',
|
type: 'directory',
|
||||||
discovered: false,
|
discovered: false,
|
||||||
children: [file],
|
children: [file],
|
||||||
})
|
})
|
||||||
@@ -88,6 +100,12 @@ export default class FileNode extends File {
|
|||||||
// Update the state of this node
|
// Update the state of this node
|
||||||
this.discovered = this.discovered || fileNode.discovered
|
this.discovered = this.discovered || fileNode.discovered
|
||||||
|
|
||||||
|
// the provided directory is empty, remove all children
|
||||||
|
if (fileNode.empty) {
|
||||||
|
this.children = []
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
fileNode.children.forEach((child) => {
|
fileNode.children.forEach((child) => {
|
||||||
const existingChild = this.children.find((c) => c.path === child.path)
|
const existingChild = this.children.find((c) => c.path === child.path)
|
||||||
if (existingChild) {
|
if (existingChild) {
|
||||||
@@ -133,4 +151,11 @@ export default class FileNode extends File {
|
|||||||
.join('')
|
.join('')
|
||||||
return `${name}\n${childrenString}`
|
return `${name}\n${childrenString}`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
include(filePath: string, type?: 'file' | 'directory'): boolean {
|
||||||
|
if (this.path === filePath) {
|
||||||
|
return type ? this.type === type : true
|
||||||
|
}
|
||||||
|
return this.children.some((child) => child.include(filePath, type))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -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 { describe, test, expect } from '@jest/globals'
|
||||||
import File from '../src/file.js'
|
import File from '../file.js'
|
||||||
|
|
||||||
describe('File', () => {
|
describe('File', () => {
|
||||||
let file: File
|
let file: File
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
file = new File({ path: '/home/user', type: 'folder', discovered: true })
|
file = new File({ path: '/home/user', type: 'directory', discovered: true })
|
||||||
})
|
})
|
||||||
|
|
||||||
const filesToCompare = [
|
const filesToCompare = [
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { describe, test, expect } from '@jest/globals'
|
import { describe, test, expect } from '@jest/globals'
|
||||||
import FileGraph from '../src/fileGraph.js'
|
import FileGraph from '../fileGraph.js'
|
||||||
import FileNode from '../src/fileNode.js'
|
import FileNode from '../fileNode.js'
|
||||||
|
import { FileObject } from '../file.js'
|
||||||
|
|
||||||
describe('FileGraph', () => {
|
describe('FileGraph', () => {
|
||||||
let root: FileNode
|
let root: FileNode
|
||||||
@@ -8,7 +9,7 @@ describe('FileGraph', () => {
|
|||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
root = new FileNode({
|
root = new FileNode({
|
||||||
path: '/',
|
path: '/',
|
||||||
type: 'folder',
|
type: 'directory',
|
||||||
discovered: true,
|
discovered: true,
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
@@ -16,7 +17,7 @@ describe('FileGraph', () => {
|
|||||||
test('add files to graph', () => {
|
test('add files to graph', () => {
|
||||||
const fileGraph = new FileGraph(root)
|
const fileGraph = new FileGraph(root)
|
||||||
|
|
||||||
const filesToAdd = [
|
const filesToAdd: FileObject[] = [
|
||||||
{
|
{
|
||||||
path: '/home/user/file1.txt',
|
path: '/home/user/file1.txt',
|
||||||
type: 'file',
|
type: 'file',
|
||||||
@@ -24,7 +25,12 @@ describe('FileGraph', () => {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: '/home/user/folder1',
|
path: '/home/user/folder1',
|
||||||
type: 'folder',
|
type: 'directory',
|
||||||
|
discovered: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/home/user/folder2/file2.txt',
|
||||||
|
type: 'file',
|
||||||
discovered: true,
|
discovered: true,
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
@@ -32,7 +38,34 @@ describe('FileGraph', () => {
|
|||||||
fileGraph.add(filesToAdd)
|
fileGraph.add(filesToAdd)
|
||||||
|
|
||||||
expect(fileGraph.children.length).toBe(1)
|
expect(fileGraph.children.length).toBe(1)
|
||||||
expect(fileGraph.children[0].children[0].children.length).toBe(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', () => {
|
test('remove files from graph', () => {
|
||||||
@@ -41,12 +74,12 @@ describe('FileGraph', () => {
|
|||||||
const filesToAdd = [
|
const filesToAdd = [
|
||||||
{
|
{
|
||||||
path: '/home/user/folder1',
|
path: '/home/user/folder1',
|
||||||
type: 'folder',
|
type: 'directory',
|
||||||
discovered: true,
|
discovered: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: '/home/user/folder2',
|
path: '/home/user/folder2',
|
||||||
type: 'folder',
|
type: 'directory',
|
||||||
discovered: true,
|
discovered: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -61,22 +94,22 @@ describe('FileGraph', () => {
|
|||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
fileGraph.add(filesToAdd)
|
fileGraph.add(filesToAdd as FileObject[])
|
||||||
|
|
||||||
const filesToRemove = [
|
const filesToRemove = [
|
||||||
{
|
{
|
||||||
path: '/home/user/folder1/file1.txt',
|
path: '/home/user/folder1/file1.txt',
|
||||||
type: 'folder',
|
type: 'directory',
|
||||||
discovered: true,
|
discovered: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: '/home/user/folder2',
|
path: '/home/user/folder2',
|
||||||
type: 'folder',
|
type: 'directory',
|
||||||
discovered: true,
|
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.length).toBe(1)
|
||||||
expect(fileGraph.children[0].children[0].children[0].name).toBe('folder1')
|
expect(fileGraph.children[0].children[0].children[0].name).toBe('folder1')
|
||||||
@@ -90,7 +123,7 @@ describe('FileGraph', () => {
|
|||||||
const discoverFiles = [
|
const discoverFiles = [
|
||||||
{
|
{
|
||||||
path: '/home/user/folder1',
|
path: '/home/user/folder1',
|
||||||
type: 'folder',
|
type: 'directory',
|
||||||
discovered: true,
|
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(
|
expect(fileGraph.children[0].children[0].children[0].children.length).toBe(
|
||||||
3
|
3
|
||||||
)
|
)
|
||||||
@@ -122,12 +155,12 @@ describe('FileGraph', () => {
|
|||||||
const filesToAdd = [
|
const filesToAdd = [
|
||||||
{
|
{
|
||||||
path: '/home/user/folder1',
|
path: '/home/user/folder1',
|
||||||
type: 'folder',
|
type: 'directory',
|
||||||
discovered: true,
|
discovered: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: '/home/user/folder2',
|
path: '/home/user/folder2',
|
||||||
type: 'folder',
|
type: 'directory',
|
||||||
discovered: true,
|
discovered: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -142,12 +175,12 @@ describe('FileGraph', () => {
|
|||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
fileGraph.add(filesToAdd)
|
fileGraph.add(filesToAdd as FileObject[])
|
||||||
|
|
||||||
const newFiles = [
|
const newFiles = [
|
||||||
{
|
{
|
||||||
path: '/home/user/folder1',
|
path: '/home/user/folder1',
|
||||||
type: 'folder',
|
type: 'directory',
|
||||||
discovered: true,
|
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.length).toBe(2)
|
||||||
expect(fileGraph.children[0].children[0].children[0].children.length).toBe(
|
expect(fileGraph.children[0].children[0].children[0].children.length).toBe(
|
||||||
3
|
3
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('discover a empty directory which children just being deleted', () => {
|
||||||
|
const fileGraph = new FileGraph(root)
|
||||||
|
const discoverFiles = [
|
||||||
|
{
|
||||||
|
path: '/home/user/folder1',
|
||||||
|
type: 'directory',
|
||||||
|
discovered: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/home/user/folder1/file1.txt',
|
||||||
|
type: 'file',
|
||||||
|
discovered: true,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
fileGraph.add(discoverFiles as FileObject[])
|
||||||
|
expect(fileGraph.include('/home/user/folder1/file1.txt', 'file')).toBe(true)
|
||||||
|
|
||||||
|
fileGraph.discover([
|
||||||
|
{
|
||||||
|
path: '/home/user/folder1',
|
||||||
|
type: 'directory',
|
||||||
|
discovered: true,
|
||||||
|
empty: true,
|
||||||
|
},
|
||||||
|
])
|
||||||
|
|
||||||
|
expect(fileGraph.include('/home/user/folder1/file1.txt', 'file')).toBe(
|
||||||
|
false
|
||||||
|
)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import { describe, test, expect } from '@jest/globals'
|
import { describe, test, expect } from '@jest/globals'
|
||||||
import { DuplicateItemError, ParentNotExistsError } from '../src/errors.js'
|
import { ParentNotExistsError } from '../errors.js'
|
||||||
import FileNode from '../src/fileNode.js'
|
import FileNode, { IFileNode } from '../fileNode.js'
|
||||||
import { FileObject } from '../src/file.js'
|
import { FileObject } from '../file.js'
|
||||||
|
|
||||||
function createNodeFromFiles(files: FileObject[]) {
|
function createNodeFromFiles(files: FileObject[]) {
|
||||||
const node = new FileNode(files[0])
|
const node = new FileNode(files[0])
|
||||||
@@ -42,17 +42,16 @@ describe('FileNode', () => {
|
|||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
rootNode = new FileNode({
|
rootNode = new FileNode({
|
||||||
path: '/home/rudeus',
|
path: '/home/rudeus',
|
||||||
name: 'rudeus',
|
type: 'directory',
|
||||||
type: 'folder',
|
|
||||||
discovered: true,
|
discovered: true,
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
test('should throw error when adding duplicate item', () => {
|
test('should override discovered when adding duplicate item', () => {
|
||||||
const child = {
|
const child: FileObject = {
|
||||||
path: '/home/rudeus/test',
|
path: '/home/rudeus/test',
|
||||||
type: 'folder',
|
type: 'directory',
|
||||||
discovered: true,
|
discovered: false,
|
||||||
}
|
}
|
||||||
|
|
||||||
// Adding the child for the first time
|
// Adding the child for the first time
|
||||||
@@ -61,16 +60,16 @@ describe('FileNode', () => {
|
|||||||
expect(rootNode.children.length).toBe(1)
|
expect(rootNode.children.length).toBe(1)
|
||||||
|
|
||||||
// Trying to add the same child again should throw an error
|
// Trying to add the same child again should throw an error
|
||||||
expect(() => {
|
child.discovered = true
|
||||||
rootNode.addChild(child)
|
rootNode.addChild(child)
|
||||||
}).toThrow(DuplicateItemError)
|
|
||||||
|
expect(rootNode.children[0].discovered).toBe(true)
|
||||||
})
|
})
|
||||||
|
|
||||||
test('should throw error when parent does not exist', () => {
|
test('should throw error when parent does not exist', () => {
|
||||||
const child = {
|
const child: FileObject = {
|
||||||
path: '/home/rudeus/alphabet/test',
|
path: '/home/rudeus/alphabet/test',
|
||||||
name: 'test',
|
type: 'directory',
|
||||||
type: 'folder',
|
|
||||||
discovered: true,
|
discovered: true,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -81,22 +80,19 @@ describe('FileNode', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
test('adds nested children to the tree', () => {
|
test('adds nested children to the tree', () => {
|
||||||
const files = [
|
const files: FileObject[] = [
|
||||||
{
|
{
|
||||||
path: '/home/rudeus/forgotten_scroll.txt',
|
path: '/home/rudeus/forgotten_scroll.txt',
|
||||||
name: 'forgotten_scroll.txt',
|
|
||||||
type: 'file',
|
type: 'file',
|
||||||
discovered: true,
|
discovered: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: '/home/rudeus/test',
|
path: '/home/rudeus/test',
|
||||||
name: 'test',
|
type: 'directory',
|
||||||
type: 'folder',
|
|
||||||
discovered: true,
|
discovered: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: '/home/rudeus/test/a',
|
path: '/home/rudeus/test/a',
|
||||||
name: 'a',
|
|
||||||
type: 'file',
|
type: 'file',
|
||||||
discovered: false,
|
discovered: false,
|
||||||
},
|
},
|
||||||
@@ -111,9 +107,8 @@ describe('FileNode', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
test('adds nested children to the tree with makeParents option', () => {
|
test('adds nested children to the tree with makeParents option', () => {
|
||||||
const file = {
|
const file: FileObject = {
|
||||||
path: '/home/rudeus/test/a/b/c/d/e',
|
path: '/home/rudeus/test/a/b/c/d/e',
|
||||||
name: 'a',
|
|
||||||
type: 'file',
|
type: 'file',
|
||||||
discovered: false,
|
discovered: false,
|
||||||
}
|
}
|
||||||
@@ -129,34 +124,34 @@ describe('FileNode', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
test('merge-subtree', () => {
|
test('merge-subtree', () => {
|
||||||
const root = {
|
const root: FileObject = {
|
||||||
path: '/',
|
path: '/',
|
||||||
type: 'folder',
|
type: 'directory',
|
||||||
discovered: true,
|
discovered: true,
|
||||||
}
|
}
|
||||||
const files1 = [
|
const files1: FileObject[] = [
|
||||||
root,
|
root,
|
||||||
{ path: '/a', type: 'file', discovered: true },
|
{ path: '/a', type: 'file' as const, discovered: true },
|
||||||
{ path: '/b', type: 'folder', discovered: true },
|
{ path: '/b', type: 'directory' as const, discovered: true },
|
||||||
{ path: '/b/c', type: 'folder', discovered: true },
|
{ path: '/b/c', type: 'directory' as const, discovered: true },
|
||||||
{ path: '/b/d', type: 'file', discovered: true },
|
{ path: '/b/d', type: 'file' as const, discovered: true },
|
||||||
{ path: '/b/c/f', type: 'file', discovered: true },
|
{ path: '/b/c/f', type: 'file' as const, discovered: true },
|
||||||
]
|
]
|
||||||
|
|
||||||
const files2 = [
|
const files2: FileObject[] = [
|
||||||
{ path: '/b', type: 'folder', discovered: true },
|
{ path: '/b', type: 'directory' as const, discovered: true },
|
||||||
{ path: '/b/c', type: 'folder', discovered: true },
|
{ path: '/b/c', type: 'directory' as const, discovered: true },
|
||||||
{ path: '/b/e', type: 'file', discovered: true },
|
{ path: '/b/e', type: 'file' as const, discovered: true },
|
||||||
{ path: '/b/c/g', type: 'file', discovered: true },
|
{ path: '/b/c/g', type: 'file' as const, discovered: true },
|
||||||
]
|
]
|
||||||
|
|
||||||
const expected = [
|
const expected: FileObject[] = [
|
||||||
root,
|
root,
|
||||||
{ path: '/a', type: 'file', discovered: true },
|
{ path: '/a', type: 'file' as const, discovered: true },
|
||||||
{ path: '/b', type: 'folder', discovered: true },
|
{ path: '/b', type: 'directory' as const, discovered: true },
|
||||||
{ path: '/b/c', type: 'folder', discovered: true },
|
{ path: '/b/c', type: 'directory' as const, discovered: true },
|
||||||
{ path: '/b/e', type: 'file', discovered: true },
|
{ path: '/b/e', type: 'file' as const, discovered: true },
|
||||||
{ path: '/b/c/g', type: 'file', discovered: true },
|
{ path: '/b/c/g', type: 'file' as const, discovered: true },
|
||||||
]
|
]
|
||||||
|
|
||||||
mergeTest(files1, files2, expected)
|
mergeTest(files1, files2, expected)
|
||||||
@@ -166,90 +161,94 @@ describe('FileNode', () => {
|
|||||||
// Test if the node to merge is only the middle part of the tree
|
// Test if the node to merge is only the middle part of the tree
|
||||||
// (i.e. the node to merge doesn't contains all level of children)
|
// (i.e. the node to merge doesn't contains all level of children)
|
||||||
// The original tree should keep the children that are not in the node to merge
|
// The original tree should keep the children that are not in the node to merge
|
||||||
const root = {
|
const root: FileObject = {
|
||||||
path: '/',
|
path: '/',
|
||||||
type: 'folder',
|
type: 'directory',
|
||||||
discovered: true,
|
discovered: true,
|
||||||
}
|
}
|
||||||
const files1 = [
|
const files1: FileObject[] = [
|
||||||
root,
|
root,
|
||||||
{ path: '/a', type: 'file', discovered: true },
|
{ path: '/a', type: 'file' as const, discovered: true },
|
||||||
{ path: '/b', type: 'folder', discovered: true },
|
{ path: '/b', type: 'directory' as const, discovered: true },
|
||||||
{ path: '/b/c', type: 'folder', discovered: true },
|
{ path: '/b/c', type: 'directory' as const, discovered: true },
|
||||||
{ path: '/b/d', type: 'file', discovered: true },
|
{ path: '/b/d', type: 'file' as const, discovered: true },
|
||||||
{ path: '/b/c/f', type: 'file', discovered: true },
|
{ path: '/b/c/f', type: 'file' as const, discovered: true },
|
||||||
]
|
]
|
||||||
|
|
||||||
const files2 = [
|
const files2: FileObject[] = [
|
||||||
{ path: '/b', type: 'folder', discovered: true },
|
{ path: '/b', type: 'directory' as const, discovered: true },
|
||||||
{ path: '/b/c', type: 'folder', discovered: true },
|
{ path: '/b/c', type: 'directory' as const, discovered: true },
|
||||||
{ path: '/b/e', type: 'file', discovered: true },
|
{ path: '/b/e', type: 'file' as const, discovered: true },
|
||||||
]
|
]
|
||||||
|
|
||||||
const expected = [
|
const expected: FileObject[] = [
|
||||||
root,
|
root,
|
||||||
{ path: '/a', type: 'file', discovered: true },
|
{ path: '/a', type: 'file' as const, discovered: true },
|
||||||
{ path: '/b', type: 'folder', discovered: true },
|
{ path: '/b', type: 'directory' as const, discovered: true },
|
||||||
{ path: '/b/c', type: 'folder', discovered: true },
|
{ path: '/b/c', type: 'directory' as const, discovered: true },
|
||||||
{ path: '/b/e', type: 'file', discovered: true },
|
{ path: '/b/e', type: 'file' as const, discovered: true },
|
||||||
{ path: '/b/c/f', type: 'file', discovered: true },
|
{ path: '/b/c/f', type: 'file' as const, discovered: true },
|
||||||
]
|
]
|
||||||
|
|
||||||
mergeTest(files1, files2, expected)
|
mergeTest(files1, files2, expected)
|
||||||
})
|
})
|
||||||
|
|
||||||
test('merge-hidden', () => {
|
test('merge-hidden', () => {
|
||||||
const root = {
|
const root: FileObject = {
|
||||||
path: '/',
|
path: '/',
|
||||||
type: 'folder',
|
type: 'directory',
|
||||||
discovered: true,
|
discovered: true,
|
||||||
}
|
}
|
||||||
const files1 = [
|
const files1: FileObject[] = [
|
||||||
root,
|
root,
|
||||||
{ path: '/a', type: 'file', discovered: true },
|
{ path: '/a', type: 'file' as const, discovered: true },
|
||||||
{ path: '/.b', type: 'file', discovered: false },
|
{ path: '/.b', type: 'file' as const, discovered: false },
|
||||||
{ path: '/c', type: 'folder', discovered: true },
|
{ path: '/c', type: 'directory' as const, discovered: true },
|
||||||
{ path: '/c/.d', type: 'file', discovered: false },
|
{ path: '/c/.d', type: 'file' as const, discovered: false },
|
||||||
]
|
]
|
||||||
|
|
||||||
const files2 = [
|
const files2: FileObject[] = [
|
||||||
{ path: '/c', type: 'folder', discovered: true },
|
{ path: '/c', type: 'directory' as const, discovered: true },
|
||||||
{ path: '/c/.d', type: 'file', discovered: true },
|
{ path: '/c/.d', type: 'file' as const, discovered: true },
|
||||||
]
|
]
|
||||||
|
|
||||||
const expected = [
|
const expected: FileObject[] = [
|
||||||
root,
|
root,
|
||||||
{ path: '/a', type: 'file', discovered: true },
|
{ path: '/a', type: 'file' as const, discovered: true },
|
||||||
{ path: '/.b', type: 'file', discovered: false },
|
{ path: '/.b', type: 'file' as const, discovered: false },
|
||||||
{ path: '/c', type: 'folder', discovered: true },
|
{ path: '/c', type: 'directory' as const, discovered: true },
|
||||||
{ path: '/c/.d', type: 'file', discovered: true },
|
{ path: '/c/.d', type: 'file' as const, discovered: true },
|
||||||
]
|
]
|
||||||
|
|
||||||
mergeTest(files1, files2, expected)
|
mergeTest(files1, files2, expected)
|
||||||
})
|
})
|
||||||
|
|
||||||
test('remove-child', () => {
|
test('remove-child', () => {
|
||||||
const root = {
|
const root: FileObject = {
|
||||||
path: '/',
|
path: '/',
|
||||||
type: 'folder',
|
type: 'directory',
|
||||||
discovered: true,
|
discovered: true,
|
||||||
}
|
}
|
||||||
const files = [
|
const files: FileObject[] = [
|
||||||
root,
|
root,
|
||||||
{ path: '/a', type: 'file', discovered: true },
|
{ path: '/a', type: 'file' as const, discovered: true },
|
||||||
{ path: '/b', type: 'folder', discovered: true },
|
{ path: '/b', type: 'directory' as const, discovered: true },
|
||||||
{ path: '/b/c', type: 'folder', discovered: true },
|
{ path: '/b/c', type: 'directory' as const, discovered: true },
|
||||||
{ path: '/b/d', type: 'file', discovered: true },
|
{ path: '/b/d', type: 'file' as const, discovered: true },
|
||||||
{ path: '/b/c/f', type: 'file', 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,
|
root,
|
||||||
{ path: '/a', type: 'file', discovered: true },
|
{ path: '/a', type: 'file' as const, discovered: true },
|
||||||
{ path: '/b', type: 'folder', discovered: true },
|
{ path: '/b', type: 'directory' as const, discovered: true },
|
||||||
{ path: '/b/d', type: 'file', discovered: true },
|
{ path: '/b/d', type: 'file' as const, discovered: true },
|
||||||
]
|
]
|
||||||
|
|
||||||
removeChildTest(files, fileToRemove, expected)
|
removeChildTest(files, fileToRemove, expected)
|
||||||
@@ -257,9 +256,9 @@ describe('FileNode', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
test('load nested fileNode', () => {
|
test('load nested fileNode', () => {
|
||||||
const data = {
|
const data: IFileNode = {
|
||||||
path: '/',
|
path: '/',
|
||||||
type: 'folder',
|
type: 'directory',
|
||||||
discovered: true,
|
discovered: true,
|
||||||
children: [
|
children: [
|
||||||
{
|
{
|
||||||
@@ -269,12 +268,12 @@ test('load nested fileNode', () => {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: '/b',
|
path: '/b',
|
||||||
type: 'folder',
|
type: 'directory',
|
||||||
discovered: true,
|
discovered: true,
|
||||||
children: [
|
children: [
|
||||||
{
|
{
|
||||||
path: '/b/c',
|
path: '/b/c',
|
||||||
type: 'folder',
|
type: 'directory',
|
||||||
discovered: true,
|
discovered: true,
|
||||||
children: [
|
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().nullable(),
|
||||||
|
})
|
||||||
|
|
||||||
|
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>
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import { z } from 'zod'
|
||||||
|
import { conditionSchema } from './condition.js'
|
||||||
|
import { responseSchema } from './response.js'
|
||||||
|
import { stageExceptionSchema } from './exception.js'
|
||||||
|
import { requirementsSchema } from './requirements.js'
|
||||||
|
|
||||||
|
export const stageSchema = z.object({
|
||||||
|
id: z.string().regex(/^[a-zA-Z0-9_-]+$/),
|
||||||
|
name: z.string().optional(),
|
||||||
|
requirements: requirementsSchema.default([]),
|
||||||
|
condition: conditionSchema,
|
||||||
|
response: responseSchema,
|
||||||
|
exceptions: stageExceptionSchema.array().optional(),
|
||||||
|
})
|
||||||
|
|
||||||
|
export type IStage = z.infer<typeof stageSchema>
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
import { test, expect } from '@jest/globals'
|
||||||
|
import { IFileExistenceChecker, IFileInput } from '../schema'
|
||||||
|
import { checkFiles } from '../condition/FileMatcher.js'
|
||||||
|
import { Condition } from '../condition/Condition.js'
|
||||||
|
|
||||||
|
export class MockFileChecker implements IFileExistenceChecker {
|
||||||
|
private files: IFileInput[] = [
|
||||||
|
{
|
||||||
|
path: '/home/user/hello.txt',
|
||||||
|
type: 'file',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/home/user/world.txt',
|
||||||
|
type: 'file',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/home/user/Downloads',
|
||||||
|
type: 'directory',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
async exists(file: IFileInput): Promise<boolean> {
|
||||||
|
return this.files.some((f) => f.path === file.path && f.type === file.type)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
test('file matcher', async () => {
|
||||||
|
const checker = new MockFileChecker()
|
||||||
|
expect(
|
||||||
|
await checkFiles(checker, [
|
||||||
|
{
|
||||||
|
path: '/home/user/hello.txt',
|
||||||
|
type: 'file',
|
||||||
|
exists: true,
|
||||||
|
},
|
||||||
|
])
|
||||||
|
).toBe(true)
|
||||||
|
expect(
|
||||||
|
await checkFiles(checker, [
|
||||||
|
{
|
||||||
|
path: '/home/user/not-exists.txt',
|
||||||
|
type: 'file',
|
||||||
|
exists: true,
|
||||||
|
},
|
||||||
|
])
|
||||||
|
).toBe(false)
|
||||||
|
expect(
|
||||||
|
await checkFiles(checker, [
|
||||||
|
{
|
||||||
|
path: '/home/user/not-exists.txt',
|
||||||
|
type: 'file',
|
||||||
|
exists: false,
|
||||||
|
},
|
||||||
|
])
|
||||||
|
).toBe(true)
|
||||||
|
expect(
|
||||||
|
await checkFiles(checker, [
|
||||||
|
{
|
||||||
|
path: '/home/user/Downloads',
|
||||||
|
type: 'directory',
|
||||||
|
exists: true,
|
||||||
|
},
|
||||||
|
])
|
||||||
|
).toBe(true)
|
||||||
|
expect(
|
||||||
|
await checkFiles(checker, [
|
||||||
|
{
|
||||||
|
path: '/home/user/hello.txt',
|
||||||
|
type: 'file',
|
||||||
|
exists: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/home/user/Downloads',
|
||||||
|
type: 'directory',
|
||||||
|
exists: true,
|
||||||
|
},
|
||||||
|
])
|
||||||
|
).toBe(true)
|
||||||
|
expect(
|
||||||
|
await checkFiles(checker, [
|
||||||
|
{
|
||||||
|
path: '/home/user/hello.txt',
|
||||||
|
type: 'file',
|
||||||
|
exists: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/home/user/not-exist.txt',
|
||||||
|
type: 'file',
|
||||||
|
exists: true,
|
||||||
|
},
|
||||||
|
])
|
||||||
|
).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('condition with file matcher', async () => {
|
||||||
|
const checker = new MockFileChecker()
|
||||||
|
const condition = new Condition({
|
||||||
|
files: [
|
||||||
|
{
|
||||||
|
path: '/home/user/hello.txt',
|
||||||
|
type: 'file',
|
||||||
|
exists: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
expect(await condition.check(checker)).toBe(true)
|
||||||
|
})
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
import { test, expect } from '@jest/globals'
|
||||||
|
import { Condition } from '../condition/Condition'
|
||||||
|
import { OutputMatcher } from '../condition/OutputMatcher'
|
||||||
|
import { PwdMatcher } from '../condition/PwdMatcher'
|
||||||
|
import { CommandMatcher } from '../condition/CommandMatcher'
|
||||||
|
import { ErrorMatcher } from '../condition/ErrorMatcher'
|
||||||
|
import { conditionSchema } from '../schema'
|
||||||
|
import { MockFileChecker } from './asyncCondition.test'
|
||||||
|
|
||||||
|
test('command match', () => {
|
||||||
|
const matcher = new CommandMatcher('^echo start$')
|
||||||
|
expect(matcher.match({ command: 'echo start' })).toBe(true)
|
||||||
|
expect(matcher.match({ command: 'echo start1' })).toBe(false)
|
||||||
|
// Currently not supported
|
||||||
|
expect(matcher.match({ command: 'echo "start"' })).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('error matcher', () => {
|
||||||
|
const matcher = new ErrorMatcher()
|
||||||
|
expect(matcher.match({ error: '' })).toBe(true)
|
||||||
|
expect(matcher.match({ error: 'permission denied' })).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('error matcher with specific error', () => {
|
||||||
|
const matcher = new ErrorMatcher('permission denied')
|
||||||
|
expect(matcher.match({ error: '' })).toBe(false)
|
||||||
|
expect(matcher.match({ error: 'file not found' })).toBe(false)
|
||||||
|
expect(matcher.match({ error: 'cannot open files: permission denied' })).toBe(
|
||||||
|
true
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('pwd matcher', () => {
|
||||||
|
const matcher = new PwdMatcher('/home/user')
|
||||||
|
expect(matcher.match({ pwd: '/home/user' })).toBe(true)
|
||||||
|
expect(matcher.match({ pwd: '/home/user1' })).toBe(false)
|
||||||
|
expect(matcher.match({ pwd: '/home/user/anotherpath' })).toBe(false)
|
||||||
|
expect(matcher.match({ pwd: '/var/home/user' })).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('output matcher', () => {
|
||||||
|
const matcher = new OutputMatcher('^start$')
|
||||||
|
expect(matcher.match({ output: 'start' })).toBe(true)
|
||||||
|
expect(matcher.match({ output: 'start1' })).toBe(false)
|
||||||
|
expect(matcher.match({ output: 'start\n' })).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('condition match command', () => {
|
||||||
|
const condition = new Condition({
|
||||||
|
command: '^echo start$',
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(
|
||||||
|
condition.match({
|
||||||
|
command: 'echo start',
|
||||||
|
})
|
||||||
|
).toBe(true)
|
||||||
|
expect(
|
||||||
|
condition.match({
|
||||||
|
command: 'echo hello',
|
||||||
|
})
|
||||||
|
).toBe(false)
|
||||||
|
expect(
|
||||||
|
condition.match({ command: 'echo start', error: 'permission denied' })
|
||||||
|
).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('condition with OR', () => {
|
||||||
|
const condition = new Condition({
|
||||||
|
or: [{ command: '^echo start$' }, { command: '^echo hello$' }],
|
||||||
|
})
|
||||||
|
expect(condition.match({ command: 'echo start' })).toBe(true)
|
||||||
|
expect(condition.match({ command: 'echo hello' })).toBe(true)
|
||||||
|
expect(condition.match({ command: 'echo start1' })).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('condition with NOT', () => {
|
||||||
|
const condition = new Condition({
|
||||||
|
not: { command: '^echo start$' },
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(condition.match({ command: 'echo start' })).toBe(false)
|
||||||
|
expect(condition.match({ command: 'echo hello' })).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should parse condition', () => {
|
||||||
|
const condition = {
|
||||||
|
command: 'echo start',
|
||||||
|
output: 'start',
|
||||||
|
not: {
|
||||||
|
pwd: '/home/user',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
expect(conditionSchema.safeParse(condition).error).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should not match empty condition', async () => {
|
||||||
|
const condition = new Condition({})
|
||||||
|
expect(
|
||||||
|
await condition.satisfies({ command: 'echo start' }, new MockFileChecker())
|
||||||
|
).toBe(false)
|
||||||
|
})
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
import { expect, it, describe } from '@jest/globals'
|
||||||
|
import { IQuest } from '../schema'
|
||||||
|
import { GameSession } from '../Session'
|
||||||
|
import { MockFileChecker } from './asyncCondition.test'
|
||||||
|
|
||||||
|
const quest: IQuest = {
|
||||||
|
id: 'quest1',
|
||||||
|
title: 'Quest 1',
|
||||||
|
image: 'image1',
|
||||||
|
instruction: 'Instruction 1',
|
||||||
|
requirements: [],
|
||||||
|
stages: [
|
||||||
|
{
|
||||||
|
id: 'stage1',
|
||||||
|
requirements: [],
|
||||||
|
condition: {
|
||||||
|
command: 'echo start',
|
||||||
|
},
|
||||||
|
exceptions: [
|
||||||
|
{
|
||||||
|
id: 'exception1',
|
||||||
|
condition: {
|
||||||
|
command: 'ls',
|
||||||
|
},
|
||||||
|
response: { type: 'narrative', content: 'Exception 1' },
|
||||||
|
catchAll: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'exception2',
|
||||||
|
condition: {},
|
||||||
|
catchAll: true,
|
||||||
|
response: { type: 'narrative', content: 'Exception 2' },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
response: { type: 'narrative', content: 'Narrative 1' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'stage2',
|
||||||
|
requirements: ['stage1'],
|
||||||
|
condition: {
|
||||||
|
command: 'ls',
|
||||||
|
},
|
||||||
|
response: { type: 'narrative', content: 'Narrative 2' },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
exceptions: [
|
||||||
|
{
|
||||||
|
id: 'exception3',
|
||||||
|
condition: {
|
||||||
|
command: 'rm',
|
||||||
|
},
|
||||||
|
response: { type: 'narrative', content: 'Exception 3' },
|
||||||
|
requirements: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'exception4',
|
||||||
|
condition: {
|
||||||
|
command: 'mv',
|
||||||
|
},
|
||||||
|
response: { type: 'narrative', content: 'Exception 4' },
|
||||||
|
requirements: ['stage1'],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('exceptions', () => {
|
||||||
|
it('should return the stage exception', async () => {
|
||||||
|
const session = new GameSession(
|
||||||
|
{ completedEvents: [] },
|
||||||
|
quest,
|
||||||
|
new MockFileChecker()
|
||||||
|
)
|
||||||
|
expect(await session.runCommand({ command: 'ls' })).toBe('exception1')
|
||||||
|
await session.runCommand({ command: 'echo start' })
|
||||||
|
expect(await session.runCommand({ command: 'ls' })).toBe('stage2')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should return the catch all exception', async () => {
|
||||||
|
const session = new GameSession(
|
||||||
|
{ completedEvents: [] },
|
||||||
|
quest,
|
||||||
|
new MockFileChecker()
|
||||||
|
)
|
||||||
|
expect(await session.runCommand({ command: 'what is this' })).toBe(
|
||||||
|
'exception2'
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should return the global exception', async () => {
|
||||||
|
const session = new GameSession(
|
||||||
|
{ completedEvents: [] },
|
||||||
|
quest,
|
||||||
|
new MockFileChecker()
|
||||||
|
)
|
||||||
|
expect(await session.runCommand({ command: 'rm' })).toBe('exception3')
|
||||||
|
expect(await session.runCommand({ command: 'mv' })).toBe('exception2') // catch all
|
||||||
|
session.complete('stage1')
|
||||||
|
expect(await session.runCommand({ command: 'mv' })).toBe('exception4')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,214 @@
|
|||||||
|
import { expect, describe, it } from '@jest/globals'
|
||||||
|
import { IQuest } from '../schema'
|
||||||
|
import { GameSession } from '../Session'
|
||||||
|
import { MockFileChecker } from './asyncCondition.test'
|
||||||
|
|
||||||
|
const quest: IQuest = {
|
||||||
|
id: 'quest1',
|
||||||
|
title: 'Quest 1',
|
||||||
|
image: 'image1',
|
||||||
|
instruction: 'Instruction 1',
|
||||||
|
requirements: [],
|
||||||
|
stages: [
|
||||||
|
{
|
||||||
|
id: 'stage1',
|
||||||
|
name: 'Stage 1',
|
||||||
|
requirements: [],
|
||||||
|
condition: {
|
||||||
|
command: 'echo start',
|
||||||
|
},
|
||||||
|
response: { type: 'narrative', content: 'Narrative 1' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'stage2',
|
||||||
|
name: 'Stage 2',
|
||||||
|
requirements: ['stage1'],
|
||||||
|
condition: {
|
||||||
|
command: 'ls',
|
||||||
|
},
|
||||||
|
response: { type: 'narrative', content: 'Narrative 2' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'stage3',
|
||||||
|
name: 'Stage 3',
|
||||||
|
requirements: ['stage1'],
|
||||||
|
condition: {
|
||||||
|
command: 'mkdir hello',
|
||||||
|
},
|
||||||
|
response: { type: 'narrative', content: 'Narrative 3' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'stage4',
|
||||||
|
name: 'Stage 4',
|
||||||
|
requirements: ['stage2', 'stage3'],
|
||||||
|
condition: {
|
||||||
|
command: 'echo finish',
|
||||||
|
},
|
||||||
|
response: { type: 'narrative', content: 'Narrative 4' },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
exceptions: [
|
||||||
|
{
|
||||||
|
id: 'exception1',
|
||||||
|
condition: {
|
||||||
|
command: 'rm',
|
||||||
|
},
|
||||||
|
response: {
|
||||||
|
type: 'narrative',
|
||||||
|
content: 'Exception 1',
|
||||||
|
},
|
||||||
|
requirements: [],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('Session', () => {
|
||||||
|
it('should return active stages', () => {
|
||||||
|
const session = new GameSession(
|
||||||
|
{
|
||||||
|
completedEvents: [],
|
||||||
|
},
|
||||||
|
quest,
|
||||||
|
new MockFileChecker()
|
||||||
|
)
|
||||||
|
expect(session.getActiveStages()).toEqual(['stage1'])
|
||||||
|
|
||||||
|
session.complete('stage1')
|
||||||
|
expect(session.getActiveStages()).toEqual(['stage2', 'stage3'])
|
||||||
|
|
||||||
|
session.complete('stage2')
|
||||||
|
expect(session.getActiveStages()).toEqual(['stage3'])
|
||||||
|
|
||||||
|
session.complete('stage3')
|
||||||
|
expect(session.getActiveStages()).toEqual(['stage4'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should run command', async () => {
|
||||||
|
const session = new GameSession(
|
||||||
|
{
|
||||||
|
completedEvents: [],
|
||||||
|
},
|
||||||
|
quest,
|
||||||
|
new MockFileChecker()
|
||||||
|
)
|
||||||
|
expect(await session.runCommand({ command: 'hi' })).toBeNull()
|
||||||
|
expect(await session.runCommand({ command: 'echo start' })).toEqual(
|
||||||
|
'stage1'
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(await session.runCommand({ command: 'echo start' })).toBeNull()
|
||||||
|
expect(await session.runCommand({ command: 'ls' })).toEqual('stage2')
|
||||||
|
|
||||||
|
expect(await session.runCommand({ command: 'echo finish' })).toBeNull()
|
||||||
|
|
||||||
|
expect(await session.runCommand({ command: 'mkdir hello' })).toEqual(
|
||||||
|
'stage3'
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(await session.runCommand({ command: 'echo finish' })).toEqual(
|
||||||
|
'stage4'
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should return all responses', () => {
|
||||||
|
const session = new GameSession(
|
||||||
|
{
|
||||||
|
completedEvents: ['stage1'],
|
||||||
|
},
|
||||||
|
quest,
|
||||||
|
new MockFileChecker()
|
||||||
|
)
|
||||||
|
expect(session.getResponses()).toEqual([
|
||||||
|
{
|
||||||
|
type: 'narrative',
|
||||||
|
content: 'Narrative 1',
|
||||||
|
},
|
||||||
|
])
|
||||||
|
|
||||||
|
session.complete('exception1')
|
||||||
|
session.complete('stage2')
|
||||||
|
expect(session.getResponses()).toEqual([
|
||||||
|
{ type: 'narrative', content: 'Narrative 1' },
|
||||||
|
{ type: 'narrative', content: 'Exception 1' },
|
||||||
|
{ type: 'narrative', content: 'Narrative 2' },
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should return tasks', () => {
|
||||||
|
const session = new GameSession(
|
||||||
|
{
|
||||||
|
completedEvents: [],
|
||||||
|
},
|
||||||
|
quest,
|
||||||
|
new MockFileChecker()
|
||||||
|
)
|
||||||
|
expect(session.getTasks()).toEqual([
|
||||||
|
{ id: 'stage1', name: 'Stage 1', completed: false },
|
||||||
|
])
|
||||||
|
|
||||||
|
session.complete('stage1')
|
||||||
|
expect(session.getTasks()).toEqual([
|
||||||
|
{ id: 'stage1', name: 'Stage 1', completed: true },
|
||||||
|
{ id: 'stage2', name: 'Stage 2', completed: false },
|
||||||
|
{ id: 'stage3', name: 'Stage 3', completed: false },
|
||||||
|
])
|
||||||
|
|
||||||
|
session.complete('stage2')
|
||||||
|
expect(session.getTasks()).toEqual([
|
||||||
|
{ id: 'stage1', name: 'Stage 1', completed: true },
|
||||||
|
{ id: 'stage2', name: 'Stage 2', completed: true },
|
||||||
|
{ id: 'stage3', name: 'Stage 3', completed: false },
|
||||||
|
])
|
||||||
|
|
||||||
|
session.complete('stage3')
|
||||||
|
expect(session.getTasks()).toEqual([
|
||||||
|
{ id: 'stage1', name: 'Stage 1', completed: true },
|
||||||
|
{ id: 'stage2', name: 'Stage 2', completed: true },
|
||||||
|
{ id: 'stage3', name: 'Stage 3', completed: true },
|
||||||
|
{ id: 'stage4', name: 'Stage 4', completed: false },
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should update graph', () => {
|
||||||
|
const session = new GameSession({}, quest, new MockFileChecker())
|
||||||
|
|
||||||
|
expect(session.getGraph()).toMatchObject({
|
||||||
|
path: '/',
|
||||||
|
type: 'directory',
|
||||||
|
discovered: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
session.runCommand({
|
||||||
|
command: 'ls',
|
||||||
|
params: {
|
||||||
|
discover: [
|
||||||
|
{
|
||||||
|
path: '/home/user',
|
||||||
|
type: 'directory',
|
||||||
|
discovered: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(session.getGraph()).toMatchObject({
|
||||||
|
path: '/',
|
||||||
|
type: 'directory',
|
||||||
|
discovered: false,
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
path: '/home',
|
||||||
|
type: 'directory',
|
||||||
|
discovered: false,
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
path: '/home/user',
|
||||||
|
type: 'directory',
|
||||||
|
discovered: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
import { test, expect } from '@jest/globals'
|
||||||
|
import { Stage } from '../Stage'
|
||||||
|
import { MockFileChecker } from './asyncCondition.test'
|
||||||
|
|
||||||
|
const checker = new MockFileChecker()
|
||||||
|
|
||||||
|
test('check stage condition', async () => {
|
||||||
|
const stage = new Stage({
|
||||||
|
id: 'stage1',
|
||||||
|
name: 'List the files using `ls`',
|
||||||
|
condition: {
|
||||||
|
command: 'ls',
|
||||||
|
pwd: '/home/user',
|
||||||
|
},
|
||||||
|
requirements: [],
|
||||||
|
response: {
|
||||||
|
type: 'narrative',
|
||||||
|
content: 'Narrative 1',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
expect(
|
||||||
|
await stage.satisfies(
|
||||||
|
{
|
||||||
|
command: 'ls',
|
||||||
|
pwd: '/home/user',
|
||||||
|
},
|
||||||
|
checker
|
||||||
|
)
|
||||||
|
).toBe(true)
|
||||||
|
expect(
|
||||||
|
await stage.satisfies(
|
||||||
|
{
|
||||||
|
command: 'ls',
|
||||||
|
pwd: '/home/user/Downloads',
|
||||||
|
},
|
||||||
|
checker
|
||||||
|
)
|
||||||
|
).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('check stage with async condition truthy', async () => {
|
||||||
|
const stage = new Stage({
|
||||||
|
id: 'stage1',
|
||||||
|
name: 'Create file',
|
||||||
|
condition: {
|
||||||
|
files: [
|
||||||
|
{
|
||||||
|
path: '/home/user/hello.txt',
|
||||||
|
type: 'file',
|
||||||
|
exists: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
requirements: [],
|
||||||
|
response: {
|
||||||
|
type: 'narrative',
|
||||||
|
content: 'Narrative 1',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
expect(await stage.satisfies({}, checker)).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('check stage with async condition falsy', async () => {
|
||||||
|
const stage = new Stage({
|
||||||
|
id: 'stage1',
|
||||||
|
name: 'Create file',
|
||||||
|
condition: {
|
||||||
|
files: [
|
||||||
|
{
|
||||||
|
path: '/home/user/not-exists.txt',
|
||||||
|
type: 'file',
|
||||||
|
exists: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
requirements: [],
|
||||||
|
response: {
|
||||||
|
type: 'narrative',
|
||||||
|
content: 'Narrative 1',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
expect(await stage.satisfies({}, checker)).toBe(false)
|
||||||
|
})
|
||||||
File renamed without changes.
Loaded 100 of 247 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user