@icarusmx/creta 1.4.14 → 1.4.16

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 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,48 @@ 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
+ }
408
+ // Sandbox state persists across interactive steps in the same lesson
409
+
410
+ const workingDir = this.sandbox.getPath()
411
+
412
+ // Interactive loop until user types correct command
413
+ let success = false
414
+ while (!success) {
415
+ console.log('')
416
+ const userInput = await askQuestion(this.rl, `💻 ${action.prompt}: `)
417
+
418
+ if (CommandValidator.matches(userInput, action.expectedCommand)) {
419
+ // Execute the command
420
+ const result = CommandExecutor.execute(action.expectedCommand, workingDir)
421
+
422
+ console.log('')
423
+ console.log('✓ Correcto!')
424
+ console.log('')
425
+
426
+ // Show command output
427
+ if (result.stdout) {
428
+ console.log(CommandExecutor.formatOutput(result))
429
+ console.log('')
430
+ }
431
+
432
+ // Show explanation
433
+ if (action.explanation) {
434
+ console.log(action.explanation)
435
+ }
436
+
437
+ success = true
438
+ } else {
439
+ // Show error message
440
+ console.log('')
441
+ console.log(CommandValidator.getErrorMessage(userInput, action.expectedCommand))
442
+ }
443
+ }
444
+ }
388
445
  }
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: 'Pruébalo en tu terminal. Con -la verás archivos ocultos y más detalles.'
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: 'Crea una carpeta nueva. Prueba: mkdir prueba'
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
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@icarusmx/creta",
3
- "version": "1.4.14",
3
+ "version": "1.4.16",
4
4
  "description": "Salgamos de este laberinto.",
5
5
  "type": "module",
6
6
  "bin": {