@icarusmx/creta 1.4.14 → 1.4.15
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/creta.js +29 -0
- package/lib/builders/LessonBuilder.js +59 -0
- package/lib/cli/index.js +4 -0
- package/lib/data/lessons/sintaxis/terminal-basico.js +30 -2
- package/lib/sandbox/CommandExecutor.js +55 -0
- package/lib/sandbox/CommandValidator.js +32 -0
- package/lib/sandbox/SandboxManager.js +97 -0
- package/package.json +1 -1
package/bin/creta.js
CHANGED
|
@@ -2,6 +2,35 @@
|
|
|
2
2
|
|
|
3
3
|
import { runCLI } from '../lib/cli/index.js'
|
|
4
4
|
|
|
5
|
+
// Setup process exit handlers for sandbox cleanup
|
|
6
|
+
function setupExitHandlers() {
|
|
7
|
+
const cleanup = () => {
|
|
8
|
+
// Cleanup will be handled by individual SandboxManager instances
|
|
9
|
+
// and by the cleanupOldSessions call on startup
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
// Handle Ctrl+C
|
|
13
|
+
process.on('SIGINT', () => {
|
|
14
|
+
cleanup()
|
|
15
|
+
process.exit(0)
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
// Handle kill signal
|
|
19
|
+
process.on('SIGTERM', () => {
|
|
20
|
+
cleanup()
|
|
21
|
+
process.exit(0)
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
// Handle uncaught exceptions
|
|
25
|
+
process.on('uncaughtException', (error) => {
|
|
26
|
+
console.error('\n❌ Error inesperado:', error.message)
|
|
27
|
+
cleanup()
|
|
28
|
+
process.exit(1)
|
|
29
|
+
})
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
setupExitHandlers()
|
|
33
|
+
|
|
5
34
|
try {
|
|
6
35
|
const exitCode = await runCLI(process.argv.slice(2))
|
|
7
36
|
process.exit(exitCode)
|
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
import { createPromptInterface, askQuestion } from '../utils/input.js'
|
|
2
2
|
import { clearConsole } from '../utils/output.js'
|
|
3
3
|
import { UserState } from '../utils/user-state.js'
|
|
4
|
+
import { SandboxManager } from '../sandbox/SandboxManager.js'
|
|
5
|
+
import { CommandValidator } from '../sandbox/CommandValidator.js'
|
|
6
|
+
import { CommandExecutor } from '../sandbox/CommandExecutor.js'
|
|
4
7
|
|
|
5
8
|
const DEFAULT_WAIT_MESSAGE = '\nPresiona Enter para continuar...'
|
|
6
9
|
|
|
@@ -8,6 +11,7 @@ export class LessonBuilder {
|
|
|
8
11
|
constructor(lessonData) {
|
|
9
12
|
this.lesson = lessonData
|
|
10
13
|
this.rl = null
|
|
14
|
+
this.sandbox = null
|
|
11
15
|
}
|
|
12
16
|
|
|
13
17
|
async start() {
|
|
@@ -23,6 +27,12 @@ export class LessonBuilder {
|
|
|
23
27
|
} finally {
|
|
24
28
|
this.rl.close()
|
|
25
29
|
this.rl = null
|
|
30
|
+
|
|
31
|
+
// Cleanup sandbox if it was created
|
|
32
|
+
if (this.sandbox) {
|
|
33
|
+
this.sandbox.cleanup()
|
|
34
|
+
this.sandbox = null
|
|
35
|
+
}
|
|
26
36
|
}
|
|
27
37
|
}
|
|
28
38
|
|
|
@@ -86,6 +96,9 @@ export class LessonBuilder {
|
|
|
86
96
|
case 'command-intro':
|
|
87
97
|
await this.handleCommandIntro(action)
|
|
88
98
|
break
|
|
99
|
+
case 'interactive-command':
|
|
100
|
+
await this.handleInteractiveCommand(action)
|
|
101
|
+
break
|
|
89
102
|
case 'maze-completion':
|
|
90
103
|
await this.handleMazeCompletion(action)
|
|
91
104
|
break
|
|
@@ -385,4 +398,50 @@ export class LessonBuilder {
|
|
|
385
398
|
console.log('')
|
|
386
399
|
await askQuestion(this.rl, '\nPresiona Enter para continuar...')
|
|
387
400
|
}
|
|
401
|
+
|
|
402
|
+
async handleInteractiveCommand(action) {
|
|
403
|
+
// Initialize sandbox on first interactive command
|
|
404
|
+
if (!this.sandbox) {
|
|
405
|
+
this.sandbox = new SandboxManager(this.lesson.id)
|
|
406
|
+
this.sandbox.createSandbox(action.initialFiles || [])
|
|
407
|
+
} else {
|
|
408
|
+
// Restore sandbox state before each validation
|
|
409
|
+
this.sandbox.restoreState()
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
const workingDir = this.sandbox.getPath()
|
|
413
|
+
|
|
414
|
+
// Interactive loop until user types correct command
|
|
415
|
+
let success = false
|
|
416
|
+
while (!success) {
|
|
417
|
+
console.log('')
|
|
418
|
+
const userInput = await askQuestion(this.rl, `💻 ${action.prompt}: `)
|
|
419
|
+
|
|
420
|
+
if (CommandValidator.matches(userInput, action.expectedCommand)) {
|
|
421
|
+
// Execute the command
|
|
422
|
+
const result = CommandExecutor.execute(action.expectedCommand, workingDir)
|
|
423
|
+
|
|
424
|
+
console.log('')
|
|
425
|
+
console.log('✓ Correcto!')
|
|
426
|
+
console.log('')
|
|
427
|
+
|
|
428
|
+
// Show command output
|
|
429
|
+
if (result.stdout) {
|
|
430
|
+
console.log(CommandExecutor.formatOutput(result))
|
|
431
|
+
console.log('')
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
// Show explanation
|
|
435
|
+
if (action.explanation) {
|
|
436
|
+
console.log(action.explanation)
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
success = true
|
|
440
|
+
} else {
|
|
441
|
+
// Show error message
|
|
442
|
+
console.log('')
|
|
443
|
+
console.log(CommandValidator.getErrorMessage(userInput, action.expectedCommand))
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
}
|
|
388
447
|
}
|
package/lib/cli/index.js
CHANGED
|
@@ -7,6 +7,7 @@ import { executePortfolio } from '../executors/portfolio-executor.js'
|
|
|
7
7
|
import { showHelp } from '../commands/help.js'
|
|
8
8
|
import { CretaCodeSession } from '../session.js'
|
|
9
9
|
import { greetUser } from '../utils/greeting.js'
|
|
10
|
+
import { SandboxManager } from '../sandbox/SandboxManager.js'
|
|
10
11
|
|
|
11
12
|
async function executeMainMenu() {
|
|
12
13
|
const menu = new MenuBuilder(getMainMenuConfig())
|
|
@@ -79,6 +80,9 @@ export async function handleCommand(command, args = []) {
|
|
|
79
80
|
export async function runCLI(args = []) {
|
|
80
81
|
const [command, ...rest] = args
|
|
81
82
|
|
|
83
|
+
// Cleanup old sandbox sessions on startup
|
|
84
|
+
SandboxManager.cleanupOldSessions()
|
|
85
|
+
|
|
82
86
|
// Skip greeting for reset command
|
|
83
87
|
if (command !== 'reset') {
|
|
84
88
|
await greetUser()
|
|
@@ -15,7 +15,17 @@ export const TERMINAL_BASICO = {
|
|
|
15
15
|
description: 'Primer comando: ls',
|
|
16
16
|
explanation: 'ls lista los archivos y carpetas en tu ubicación actual.',
|
|
17
17
|
example: 'ls\nls -la',
|
|
18
|
-
instruction: '
|
|
18
|
+
instruction: 'Ahora vamos a practicarlo en un ambiente seguro.'
|
|
19
|
+
},
|
|
20
|
+
{
|
|
21
|
+
type: 'interactive-command',
|
|
22
|
+
prompt: 'Ejecuta el comando ls',
|
|
23
|
+
expectedCommand: 'ls',
|
|
24
|
+
explanation: 'El comando ls listó todos los archivos y carpetas en este directorio.',
|
|
25
|
+
initialFiles: ['proyecto.js', 'datos.txt', 'README.md']
|
|
26
|
+
},
|
|
27
|
+
{
|
|
28
|
+
type: 'pause'
|
|
19
29
|
},
|
|
20
30
|
{
|
|
21
31
|
type: 'command-intro',
|
|
@@ -23,7 +33,25 @@ export const TERMINAL_BASICO = {
|
|
|
23
33
|
description: 'Segundo comando: mkdir',
|
|
24
34
|
explanation: 'mkdir crea un nuevo directorio (carpeta).',
|
|
25
35
|
example: 'mkdir mi-carpeta\nmkdir proyectos/nuevo-proyecto',
|
|
26
|
-
instruction: '
|
|
36
|
+
instruction: 'Ahora vamos a crear una carpeta nueva.'
|
|
37
|
+
},
|
|
38
|
+
{
|
|
39
|
+
type: 'interactive-command',
|
|
40
|
+
prompt: 'Ejecuta el comando mkdir mi-directorio',
|
|
41
|
+
expectedCommand: 'mkdir mi-directorio',
|
|
42
|
+
explanation: 'Creaste una nueva carpeta llamada "mi-directorio".'
|
|
43
|
+
},
|
|
44
|
+
{
|
|
45
|
+
type: 'pause'
|
|
46
|
+
},
|
|
47
|
+
{
|
|
48
|
+
type: 'interactive-command',
|
|
49
|
+
prompt: 'Ejecuta ls de nuevo para ver tu nueva carpeta',
|
|
50
|
+
expectedCommand: 'ls',
|
|
51
|
+
explanation: '¡Ahí está! Ahora "mi-directorio" aparece en la lista junto con los otros archivos.'
|
|
52
|
+
},
|
|
53
|
+
{
|
|
54
|
+
type: 'pause'
|
|
27
55
|
},
|
|
28
56
|
{
|
|
29
57
|
type: 'command-intro',
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { execSync } from 'child_process'
|
|
2
|
+
import { platform } from 'os'
|
|
3
|
+
|
|
4
|
+
export class CommandExecutor {
|
|
5
|
+
/**
|
|
6
|
+
* Execute a command in the sandbox directory
|
|
7
|
+
* @param {string} command - The command to execute
|
|
8
|
+
* @param {string} workingDir - The sandbox directory path
|
|
9
|
+
* @returns {Object} - { stdout, stderr, success }
|
|
10
|
+
*/
|
|
11
|
+
static execute(command, workingDir) {
|
|
12
|
+
try {
|
|
13
|
+
// Detect platform for shell selection
|
|
14
|
+
const isWindows = platform() === 'win32'
|
|
15
|
+
const shell = isWindows ? 'cmd.exe' : '/bin/bash'
|
|
16
|
+
|
|
17
|
+
const stdout = execSync(command, {
|
|
18
|
+
cwd: workingDir,
|
|
19
|
+
encoding: 'utf8',
|
|
20
|
+
shell,
|
|
21
|
+
// Inherit environment but ensure clean output
|
|
22
|
+
env: { ...process.env, FORCE_COLOR: '0' }
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
return {
|
|
26
|
+
stdout: stdout.trim(),
|
|
27
|
+
stderr: '',
|
|
28
|
+
success: true
|
|
29
|
+
}
|
|
30
|
+
} catch (error) {
|
|
31
|
+
return {
|
|
32
|
+
stdout: error.stdout ? error.stdout.toString().trim() : '',
|
|
33
|
+
stderr: error.stderr ? error.stderr.toString().trim() : error.message,
|
|
34
|
+
success: false
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Format output for display to student
|
|
41
|
+
* @param {Object} result - Result from execute()
|
|
42
|
+
* @returns {string} - Formatted output
|
|
43
|
+
*/
|
|
44
|
+
static formatOutput(result) {
|
|
45
|
+
if (!result.success && result.stderr) {
|
|
46
|
+
return `❌ Error:\n${result.stderr}`
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
if (result.stdout) {
|
|
50
|
+
return result.stdout
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
return '(sin salida)'
|
|
54
|
+
}
|
|
55
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
export class CommandValidator {
|
|
2
|
+
/**
|
|
3
|
+
* Validate user input matches expected command (strict exact match)
|
|
4
|
+
* @param {string} userInput - What the user typed
|
|
5
|
+
* @param {string} expectedCommand - What we expect them to type
|
|
6
|
+
* @returns {boolean} - true if matches
|
|
7
|
+
*/
|
|
8
|
+
static matches(userInput, expectedCommand) {
|
|
9
|
+
// Trim whitespace
|
|
10
|
+
const cleanInput = userInput.trim()
|
|
11
|
+
const cleanExpected = expectedCommand.trim()
|
|
12
|
+
|
|
13
|
+
// Strict exact match
|
|
14
|
+
return cleanInput === cleanExpected
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Get a helpful error message when command doesn't match
|
|
19
|
+
* @param {string} userInput - What the user typed
|
|
20
|
+
* @param {string} expectedCommand - What we expect
|
|
21
|
+
* @returns {string} - Error message
|
|
22
|
+
*/
|
|
23
|
+
static getErrorMessage(userInput, expectedCommand) {
|
|
24
|
+
const cleanInput = userInput.trim()
|
|
25
|
+
|
|
26
|
+
if (cleanInput === '') {
|
|
27
|
+
return 'No escribiste ningún comando.'
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
return `Ese no es el comando. Ejecuta: ${expectedCommand}`
|
|
31
|
+
}
|
|
32
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { tmpdir } from 'os'
|
|
2
|
+
import { join } from 'path'
|
|
3
|
+
import { mkdirSync, writeFileSync, rmSync, existsSync, readdirSync } from 'fs'
|
|
4
|
+
|
|
5
|
+
export class SandboxManager {
|
|
6
|
+
constructor(lessonId) {
|
|
7
|
+
this.lessonId = lessonId
|
|
8
|
+
this.sessionId = `${lessonId}-${Date.now()}`
|
|
9
|
+
this.sandboxPath = join(tmpdir(), `creta-practice-${this.sessionId}`)
|
|
10
|
+
this.initialState = null
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Create the sandbox directory with initial files
|
|
15
|
+
* @param {string[]} initialFiles - Array of filenames to create
|
|
16
|
+
*/
|
|
17
|
+
createSandbox(initialFiles = []) {
|
|
18
|
+
if (existsSync(this.sandboxPath)) {
|
|
19
|
+
this.cleanup()
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
mkdirSync(this.sandboxPath, { recursive: true })
|
|
23
|
+
|
|
24
|
+
// Create initial files (empty by default)
|
|
25
|
+
initialFiles.forEach((filename) => {
|
|
26
|
+
const filePath = join(this.sandboxPath, filename)
|
|
27
|
+
writeFileSync(filePath, `# ${filename}\nEste es un archivo de práctica.\n`)
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
// Store initial state for restoration
|
|
31
|
+
this.initialState = {
|
|
32
|
+
files: initialFiles,
|
|
33
|
+
directories: []
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
return this.sandboxPath
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Restore sandbox to initial state
|
|
41
|
+
* Used before each validation step
|
|
42
|
+
*/
|
|
43
|
+
restoreState() {
|
|
44
|
+
if (!this.initialState) return
|
|
45
|
+
|
|
46
|
+
// Remove everything
|
|
47
|
+
this.cleanup()
|
|
48
|
+
|
|
49
|
+
// Recreate with initial files
|
|
50
|
+
this.createSandbox(this.initialState.files)
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Get the current sandbox path
|
|
55
|
+
*/
|
|
56
|
+
getPath() {
|
|
57
|
+
return this.sandboxPath
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Check if sandbox exists
|
|
62
|
+
*/
|
|
63
|
+
exists() {
|
|
64
|
+
return existsSync(this.sandboxPath)
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Cleanup this sandbox
|
|
69
|
+
*/
|
|
70
|
+
cleanup() {
|
|
71
|
+
if (existsSync(this.sandboxPath)) {
|
|
72
|
+
rmSync(this.sandboxPath, { recursive: true, force: true })
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Cleanup all old Creta practice sessions
|
|
78
|
+
* Called on CLI startup
|
|
79
|
+
*/
|
|
80
|
+
static cleanupOldSessions() {
|
|
81
|
+
const tmpDir = tmpdir()
|
|
82
|
+
const allFiles = readdirSync(tmpDir)
|
|
83
|
+
|
|
84
|
+
const cretaSessions = allFiles.filter(name => name.startsWith('creta-practice-'))
|
|
85
|
+
|
|
86
|
+
cretaSessions.forEach((sessionDir) => {
|
|
87
|
+
const fullPath = join(tmpDir, sessionDir)
|
|
88
|
+
try {
|
|
89
|
+
rmSync(fullPath, { recursive: true, force: true })
|
|
90
|
+
} catch (err) {
|
|
91
|
+
// Ignore errors - session might be in use
|
|
92
|
+
}
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
return cretaSessions.length
|
|
96
|
+
}
|
|
97
|
+
}
|