Feat filegraph (#194)

* feat: update filegraph on touch command

* feat: update filegraph on mkdir

* feat: add different parsing opts for each command

* refactor: replace axios with fetch. put handleCommand to a new file

* feat: add config for mounting cli in dev stage

* refactor: clean up unused validators

* feat: add command handler for copy files. reuse resolvePath function

* feat: add rm handler, support specific files deletion. Glob not supported yet

* feat: handle discover cases that the folder is actually empty

* feat: handle discover empty folder better and support rmdir

* feat: handle mv command

* refactor: remove unused console logs
This commit is contained in:
Justin Lin authored and GitHub committed 2025-04-29 20:40:02 +08:00
1 parent a732c1d119
commit f0a09ac4d9
19 files changed
+326 -119

No files matched your search

+1
View File
@@ -84,6 +84,7 @@ module.exports = {
'@typescript-eslint/no-shadow': 'error', '@typescript-eslint/no-shadow': 'error',
'no-restricted-imports': ['error', '../*'], 'no-restricted-imports': ['error', '../*'],
'import/no-relative-packages': 'off', 'import/no-relative-packages': 'off',
'no-await-in-loop': 'off',
}, },
overrides: [ overrides: [
{ {
+18 -34
View File
@@ -1,9 +1,6 @@
/* eslint-disable no-restricted-syntax */
const fs = require('fs').promises const fs = require('fs').promises
const { exit } = require('process') const { exit } = require('process')
const minimist = require('minimist') const handleCommand = require('./handleCommand.js')
const axios = require('axios')
const discoverFiles = require('./discover.js')
const { const {
API_ENDPOINT, API_ENDPOINT,
@@ -15,13 +12,6 @@ const {
CMD_EXIT_CODE, CMD_EXIT_CODE,
} = process.env } = process.env
const api = axios.create({
baseURL: `${API_ENDPOINT}/api/v1`,
headers: {
Authorization: `Bearer ${TOKEN}`,
},
})
async function readOrNone(file) { async function readOrNone(file) {
try { try {
return await fs.readFile(file, 'utf8') return await fs.readFile(file, 'utf8')
@@ -30,28 +20,6 @@ async function readOrNone(file) {
} }
} }
const commandListeners = {
ls: [discoverFiles],
cd: [changeDirectory],
}
function changeDirectory() {
return {
pwd: process.cwd(),
}
}
async function handleCommand(command) {
const argv = minimist(command.split(' '))
const name = argv._[0]
const listeners = commandListeners[name]
if (!listeners) return {}
return listeners.reduce(async (result, listener) => {
const res = await listener(argv)
return { ...res, ...result }
}, {})
}
async function main() { async function main() {
if (!CMD_NAME) exit(0) if (!CMD_NAME) exit(0)
@@ -67,7 +35,23 @@ async function main() {
pwd: PWD, pwd: PWD,
params, params,
} }
await api.post('/commands', payload) try {
const response = await fetch(`${API_ENDPOINT}/api/v1/commands`, {
method: 'POST',
headers: {
Authorization: `Bearer ${TOKEN}`,
'Content-Type': 'application/json',
},
body: JSON.stringify(payload),
})
if (!response.ok) {
console.error(`Failed to send command: ${response.statusText}`)
const responseBody = await response.text()
console.error(`Response Body: ${responseBody}`)
}
} catch (e) {
console.error('Error occurred while sending command:', e)
}
} }
main() main()
+17 -10
View File
@@ -1,21 +1,25 @@
const fs = require('fs').promises const fs = require('fs').promises
const path = require('path') const path = require('path')
const { resolvePath } = require('./utils')
async function collectFilesInfo(inputPath, level = 0, hiddenFiles = false) { async function collectFilesInfo(inputPath, level = 0, hiddenFiles = false) {
const stats = await fs.stat(inputPath) const stats = await fs.stat(inputPath)
const name = path.basename(inputPath) const name = path.basename(inputPath)
const discovered = level <= 1 && (hiddenFiles || !name.startsWith('.')) const discovered = level <= 1 && (hiddenFiles || !name.startsWith('.'))
const files = [ const self = {
{ path: inputPath,
path: inputPath, name,
name, type: stats.isDirectory() ? 'directory' : 'file',
type: stats.isDirectory() ? 'directory' : 'file', discovered,
discovered, }
}, const files = [self]
]
if (stats.isDirectory() && level < 2) { if (stats.isDirectory() && level < 2) {
try { try {
let entries = await fs.readdir(inputPath) let entries = await fs.readdir(inputPath)
if (entries.length === 0) {
self.empty = true
return files
}
if (level > 0) { if (level > 0) {
entries = entries.slice(0, 5) entries = entries.slice(0, 5)
} }
@@ -37,7 +41,7 @@ async function discoverFiles(argv) {
try { try {
const result = await Promise.all( const result = await Promise.all(
targetPath targetPath
.map((p) => path.resolve(process.cwd(), p)) .map((p) => resolvePath(p))
.map((p) => collectFilesInfo(p, 0, argv.a || argv.all)) .map((p) => collectFilesInfo(p, 0, argv.a || argv.all))
) )
return { discover: result.flat() } return { discover: result.flat() }
@@ -46,4 +50,7 @@ async function discoverFiles(argv) {
} }
} }
module.exports = discoverFiles module.exports = {
discoverFiles,
collectFilesInfo,
}
+152
View File
@@ -0,0 +1,152 @@
const fs = require('fs').promises
const minimist = require('minimist')
const { discoverFiles, collectFilesInfo } = require('./discover')
const { resolvePath } = require('./utils')
const commandListeners = {
ls: {
opts: {
boolean: ['a', 'l', 'h'],
},
handler: discoverFiles,
},
cd: {
handler: changeDirectory,
},
touch: {
handler: createFiles,
},
mkdir: {
opts: {
boolean: ['p'],
},
handler: createFiles,
},
cp: {
opts: {
boolean: ['r'],
},
handler: copyFiles,
},
rm: {
opts: {
boolean: ['r', 'f', 'v'],
},
handler: removeFiles,
},
rmdir: {
opts: {
boolean: ['p', 'v'],
},
handler: removeFiles,
},
mv: {
handler: moveFiles,
},
}
async function handleCommand(command) {
const name = command.split(' ')[0]
const listener = commandListeners[name]
if (!listener) return {}
const argv = minimist(command.split(' '), listener.opts)
return listener.handler(argv)
}
function changeDirectory() {
return {
pwd: process.cwd(),
}
}
async function createFiles(argv) {
const { _ } = argv
const files = _.slice(1)
const results = []
for (const file of files) {
const filePath = resolvePath(file)
try {
const exists = await fs.stat(filePath)
if (exists) {
results.push({
path: filePath,
type: exists.isFile() ? 'file' : 'directory',
discovered: true,
})
}
} catch (err) {
if (err.code !== 'ENOENT') {
throw err
}
}
}
return {
add: results,
}
}
async function copyFiles(argv) {
const { _ } = argv
const files = _.slice(1)
if (files.length === 0) {
return undefined
}
const dest = files.pop()
const destPath = resolvePath(dest)
return {
discover: await collectFilesInfo(destPath, 0, true),
}
}
async function removeFiles(argv) {
const files = argv._.slice(1)
const results = []
for (const file of files) {
const filePath = resolvePath(file)
try {
await fs.access(filePath, fs.constants.F_OK)
} catch {
results.push({
path: filePath,
type: 'file',
discovered: false,
})
}
}
return {
remove: results,
}
}
async function moveFiles(argv) {
const { _ } = argv
const sources = _.slice(1)
if (sources.length === 0) {
return undefined
}
const dest = sources.pop()
const results = {
add: [],
remove: [],
}
// check source
for (const source of sources) {
const sourcePath = resolvePath(source)
try {
await fs.access(sourcePath, fs.constants.F_OK)
} catch {
results.remove.push({
path: sourcePath,
type: 'file',
discovered: false,
})
}
}
// check dest
const destPath = resolvePath(dest)
results.add = await collectFilesInfo(destPath, 0, true)
return results
}
module.exports = handleCommand
-1
View File
@@ -8,7 +8,6 @@
"watch": "" "watch": ""
}, },
"dependencies": { "dependencies": {
"axios": "^1.8.2",
"minimist": "^1.2.8" "minimist": "^1.2.8"
} }
} }
+10
View File
@@ -0,0 +1,10 @@
const os = require('os')
const path = require('path')
function resolvePath(p) {
return path.resolve(process.cwd(), p.replace('~', os.homedir()))
}
module.exports = {
resolvePath,
}
+3
View File
@@ -4,6 +4,7 @@ export interface FileObject {
path: string path: string
type: 'file' | 'directory' type: 'file' | 'directory'
discovered: boolean discovered: boolean
empty?: boolean
} }
export default class File implements FileObject { export default class File implements FileObject {
@@ -11,12 +12,14 @@ export default class File implements FileObject {
name: string name: string
type: 'file' | 'directory' 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() {
+8 -11
View File
@@ -45,17 +45,14 @@ export default class FileGraph extends FileNode {
handleEvent(event: FileGraphUpdateEvent) { 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 if (event.remove) {
case !!event.remove: this.remove(event.remove)
this.remove(event.remove) }
break if (event.discover) {
case !!event.discover: this.discover(event.discover)
this.discover(event.discover)
break
// Implement other cases if needed
} }
} }
} }
+31 -13
View File
@@ -1,9 +1,5 @@
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 interface IFileNode { export interface IFileNode {
@@ -29,15 +25,24 @@ export default class FileNode extends File implements IFileNode {
) { ) {
const file = new FileNode(fileInput) const file = new FileNode(fileInput)
if (file.path === this.path) { if (file.path === this.path) {
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)
@@ -48,7 +53,7 @@ export default class FileNode extends File implements IFileNode {
// Recursively call addChild on the correct child node // Recursively call addChild on the correct child node
const childNode = this.children.find((child) => child.contains(file)) const childNode = this.children.find((child) => child.contains(file))
if (childNode) { if (childNode) {
childNode.addChild(file) childNode.addChild(file, { makeParents })
} else if (makeParents) { } else if (makeParents) {
const parent = new FileNode({ const parent = new FileNode({
path: parentPath, path: parentPath,
@@ -95,6 +100,12 @@ export default class FileNode extends File implements IFileNode {
// Update the state of this node // Update the state of this node
this.discovered = this.discovered || fileNode.discovered this.discovered = this.discovered || fileNode.discovered
// the provided directory is empty, remove all children
if (fileNode.empty) {
this.children = []
return
}
fileNode.children.forEach((child) => { fileNode.children.forEach((child) => {
const existingChild = this.children.find((c) => c.path === child.path) const existingChild = this.children.find((c) => c.path === child.path)
if (existingChild) { if (existingChild) {
@@ -140,4 +151,11 @@ export default class FileNode extends File implements IFileNode {
.join('') .join('')
return `${name}\n${childrenString}` return `${name}\n${childrenString}`
} }
include(filePath: string, type?: 'file' | 'directory'): boolean {
if (this.path === filePath) {
return type ? this.type === type : true
}
return this.children.some((child) => child.include(filePath, type))
}
} }
+65 -1
View File
@@ -28,12 +28,44 @@ describe('FileGraph', () => {
type: 'directory', type: 'directory',
discovered: true, discovered: true,
}, },
{
path: '/home/user/folder2/file2.txt',
type: 'file',
discovered: true,
},
] ]
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', () => {
@@ -175,4 +207,36 @@ describe('FileGraph', () => {
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
)
})
}) })
+7 -6
View File
@@ -1,5 +1,5 @@
import { describe, test, expect } from '@jest/globals' import { describe, test, expect } from '@jest/globals'
import { DuplicateItemError, ParentNotExistsError } from '../errors.js' import { ParentNotExistsError } from '../errors.js'
import FileNode, { IFileNode } from '../fileNode.js' import FileNode, { IFileNode } from '../fileNode.js'
import { FileObject } from '../file.js' import { FileObject } from '../file.js'
@@ -47,11 +47,11 @@ describe('FileNode', () => {
}) })
}) })
test('should throw error when adding duplicate item', () => { test('should override discovered when adding duplicate item', () => {
const child: FileObject = { const child: FileObject = {
path: '/home/rudeus/test', path: '/home/rudeus/test',
type: 'directory', type: 'directory',
discovered: true, discovered: false,
} }
// Adding the child for the first time // Adding the child for the first time
@@ -60,9 +60,10 @@ 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', () => {
+3
View File
@@ -4,10 +4,13 @@ const fileSchema = z.object({
path: z.string(), path: z.string(),
type: z.enum(['file', 'directory']), type: z.enum(['file', 'directory']),
discovered: z.boolean(), discovered: z.boolean(),
empty: z.boolean().optional(),
}) })
export const commandParamsSchema = z.object({ export const commandParamsSchema = z.object({
discover: fileSchema.array().optional(), discover: fileSchema.array().optional(),
add: fileSchema.array().optional(),
remove: fileSchema.array().optional(),
pwd: z.string().optional(), pwd: z.string().optional(),
}) })
+1 -2
View File
@@ -1,9 +1,8 @@
import { Router } from 'express' import { Router } from 'express'
import * as commandController from '../controllers/commandController.js' import * as commandController from '../controllers/commandController.js'
import { newCommandValidator } from '../validators/commandValidator.js'
const router = Router() const router = Router()
router.post('/', newCommandValidator, commandController.newCommand) router.post('/', commandController.newCommand)
export default router export default router
@@ -1,17 +0,0 @@
import { body } from 'express-validator'
import { noError } from '../../middleware/validator.js'
const checkCommand = () => body('command').isString().notEmpty().trim()
const checkPwd = () => body('pwd').isString().notEmpty().trim()
const checkOutput = () => body('output').trim().optional()
const checkError = () => body('error').trim().optional()
const checkParams = () => body('params').isObject().optional()
export const newCommandValidator = [
checkCommand(),
checkPwd(),
checkOutput(),
checkError(),
checkParams(),
noError,
]
@@ -1,7 +0,0 @@
import { check } from 'express-validator'
import { noError } from '../../middleware/validator.js'
export const checkQuestId = () =>
check('questId').notEmpty().isString().isLowercase().isLength({ max: 64 })
export const questIdValidator = [checkQuestId(), noError]
@@ -1,14 +0,0 @@
import { check } from 'express-validator'
import { checkQuestId } from './questValidator.js'
import { noError } from '../../middleware/validator.js'
const checkSessionStatus = () =>
check('status').isString().isIn(['active', 'inactive', 'finished'])
export const checkSessionId = () =>
check('sessionId').notEmpty().isString().isMongoId()
export const sessionListValidator = [checkQuestId(), checkSessionStatus()]
export const sessionDetailValidator = [checkSessionId()]
export const createSessionValidator = [checkQuestId(), noError]
export const activeSessionValidator = [checkQuestId(), noError]
+2 -1
View File
@@ -82,7 +82,8 @@ function createConfig() {
network: get('DOCKER_NETWORK', 'linux-odyssey-players-dev'), network: get('DOCKER_NETWORK', 'linux-odyssey-players-dev'),
defaultImage: get('QUEST_IMAGE', 'linuxodyssey/quest-base'), defaultImage: get('QUEST_IMAGE', 'linuxodyssey/quest-base'),
imagePrefix: get('DOCKER_PREFIX', 'linuxodyssey/quest-'), imagePrefix: get('DOCKER_PREFIX', 'linuxodyssey/quest-'),
mountQuest: process.env.MOUNT_QUEST === 'true', mountQuest: !isProduction && process.env.MOUNT_QUEST === 'true',
mountCLI: !isProduction && process.env.MOUNT_CLI === 'true',
hostProjectRoot: get('HOST_PROJECT_ROOT', projectRoot), hostProjectRoot: get('HOST_PROJECT_ROOT', projectRoot),
keypairPath, keypairPath,
keypair, keypair,
+7 -2
View File
@@ -39,13 +39,18 @@ export async function createContainer(
`${config.docker.hostProjectRoot}/quests/${questId}/home:/etc/skel:ro` `${config.docker.hostProjectRoot}/quests/${questId}/home:/etc/skel:ro`
) )
} }
if (!config.isProduction && config.docker.mountQuest && imageId !== 'base') { if (config.docker.mountQuest && imageId !== 'base') {
logger.info('Mounting quest directory', questId) logger.info('Mounting quest directory', questId)
binds = [ binds = [
`${config.docker.hostProjectRoot}/quests/${questId}/home:/home/commander`, `${config.docker.hostProjectRoot}/quests/${questId}/home:/home/commander`,
`${config.docker.hostProjectRoot}/packages/container:/usr/local/lib/container`,
] ]
} }
if (config.docker.mountCLI) {
logger.info('Mounting CLI', questId)
binds.push(
`${config.docker.hostProjectRoot}/packages/container:/usr/local/lib/container`
)
}
const option = newContainerOptions(name, imageId, { binds }) const option = newContainerOptions(name, imageId, { binds })
await createNetworkIfNotExists(config.docker.network) await createNetworkIfNotExists(config.docker.network)
return engine.createContainer(option) return engine.createContainer(option)
+1
View File
@@ -38,6 +38,7 @@ async function main() {
} }
if (config.testing.enabled) { if (config.testing.enabled) {
logger.info('Setting up test environment')
await setupTest() await setupTest()
} }