diff --git a/bin/cli.mjs b/bin/cli.mjs new file mode 100755 index 0000000..f7e4910 --- /dev/null +++ b/bin/cli.mjs @@ -0,0 +1,27 @@ +#!/usr/bin/env node + +import { repl } from '../src/repl.mjs'; +import { dev } from '../src/dev.mjs'; + +const command = process.argv[2]; + +switch (command) { + case 'repl': + await repl(); + break; + case 'dev': + await dev(); + break; + default: + console.log(` +command-stream - Modern shell utility library + +Usage: + npx command-stream repl Start interactive REPL + npx command-stream dev Start development mode + +Options: + --help, -h Show this help message +`); + process.exit(1); +} \ No newline at end of file diff --git a/examples/dev-mode-demo.mjs b/examples/dev-mode-demo.mjs new file mode 100644 index 0000000..059ff2e --- /dev/null +++ b/examples/dev-mode-demo.mjs @@ -0,0 +1,26 @@ +#!/usr/bin/env node + +// Example demonstrating the new dev mode functionality + +import { $ } from '../src/$.mjs'; + +console.log('šŸš€ Dev Mode Demo'); +console.log('================\n'); + +console.log('1. Basic dev mode (file watching):'); +console.log(' $.dev() // Starts file watching\n'); + +console.log('2. Dev mode with REPL:'); +console.log(' $.dev({ repl: true }) // Starts interactive REPL\n'); + +console.log('3. Custom watch patterns:'); +console.log(' $.dev({ watch: ["src/**/*.js", "test/**/*.js"] })\n'); + +console.log('4. CLI usage:'); +console.log(' npx command-stream repl // Start REPL directly'); +console.log(' npx command-stream dev // Start dev mode'); + +console.log('\nšŸ“ Try these commands:'); +console.log(' node examples/dev-mode-demo.mjs'); +console.log(' npx command-stream repl'); +console.log(' npx command-stream dev'); \ No newline at end of file diff --git a/package.json b/package.json index 6723c5b..f739d00 100644 --- a/package.json +++ b/package.json @@ -4,6 +4,9 @@ "description": "Modern $ shell utility library with streaming, async iteration, and EventEmitter support, optimized for Bun runtime", "type": "module", "main": "src/$.mjs", + "bin": { + "command-stream": "./bin/cli.mjs" + }, "exports": { ".": "./src/$.mjs" }, @@ -44,6 +47,7 @@ }, "files": [ "src/", + "bin/", "README.md", "LICENSE" ] diff --git a/src/$.mjs b/src/$.mjs index 46c7258..faf4ea5 100755 --- a/src/$.mjs +++ b/src/$.mjs @@ -4379,6 +4379,10 @@ function $tagged(strings, ...values) { return runner; } +// Import and add dev functionality +import { dev } from './dev.mjs'; +$tagged.dev = dev; + function create(defaultOptions = {}) { trace('API', () => `create ENTER | ${JSON.stringify({ defaultOptions }, null, 2)}`); diff --git a/src/dev.mjs b/src/dev.mjs new file mode 100644 index 0000000..60e653d --- /dev/null +++ b/src/dev.mjs @@ -0,0 +1,117 @@ +import fs from 'fs'; +import path from 'path'; +import { repl } from './repl.mjs'; +import { $ } from './$.mjs'; + +const DEFAULT_WATCH_PATTERNS = [ + '**/*.mjs', + '**/*.js', + '**/*.json' +]; + +export async function dev(options = {}) { + const { + watch = DEFAULT_WATCH_PATTERNS, + repl: startRepl = false, + cwd = process.cwd(), + verbose = false + } = options; + + console.log('šŸš€ command-stream Development Mode'); + console.log(`šŸ“ Working directory: ${cwd}`); + console.log(`šŸ‘€ Watching patterns: ${watch.join(', ')}`); + + if (startRepl) { + console.log('šŸ”§ Starting interactive REPL...\n'); + return await repl(); + } else { + console.log('šŸ’” Use $.dev({ repl: true }) to start interactive mode'); + console.log('ā³ Development mode active - watching for changes...\n'); + + // Set up file watching + const watchers = setupFileWatchers(watch, cwd, verbose); + + // Keep the process running + process.on('SIGINT', () => { + console.log('\nšŸ›‘ Stopping development mode...'); + watchers.forEach(watcher => watcher.close()); + process.exit(0); + }); + + return new Promise(() => {}); // Never resolves, dev mode runs until stopped + } +} + +function setupFileWatchers(patterns, cwd, verbose) { + const watchers = []; + + patterns.forEach(pattern => { + try { + // Convert glob pattern to directory watching + const baseDir = getBaseDirectory(pattern); + const fullPath = path.resolve(cwd, baseDir); + + if (fs.existsSync(fullPath)) { + const watcher = fs.watch(fullPath, { recursive: true }, (eventType, filename) => { + if (filename && shouldWatchFile(filename, patterns)) { + const filePath = path.join(fullPath, filename); + console.log(`šŸ“ ${eventType}: ${path.relative(cwd, filePath)}`); + + if (verbose) { + console.log(` Event: ${eventType}`); + console.log(` File: ${filePath}`); + console.log(` Time: ${new Date().toISOString()}`); + } + } + }); + + watchers.push(watcher); + + if (verbose) { + console.log(`šŸ‘ļø Watching: ${fullPath}`); + } + } + } catch (error) { + console.error(`āŒ Failed to watch pattern ${pattern}:`, error.message); + } + }); + + return watchers; +} + +function getBaseDirectory(pattern) { + // Extract the base directory from a glob pattern + const parts = pattern.split('/'); + const baseIndex = parts.findIndex(part => part.includes('*')); + + if (baseIndex === -1) { + return path.dirname(pattern); + } + + return parts.slice(0, baseIndex).join('/') || '.'; +} + +function shouldWatchFile(filename, patterns) { + // Simple pattern matching - in a real implementation you'd use a proper glob library + return patterns.some(pattern => { + const regex = patternToRegex(pattern); + return regex.test(filename); + }); +} + +function patternToRegex(pattern) { + // Convert basic glob pattern to regex + const escaped = pattern + .replace(/\./g, '\\.') + .replace(/\*\*/g, '___DOUBLESTAR___') + .replace(/\*/g, '[^/]*') + .replace(/___DOUBLESTAR___/g, '.*'); + + return new RegExp(`^${escaped}$`); +} + +// Add $.dev() method to the main $ object +export function addDevMethodTo$($obj) { + $obj.dev = dev; + return $obj; +} \ No newline at end of file diff --git a/src/repl.mjs b/src/repl.mjs new file mode 100644 index 0000000..a07907f --- /dev/null +++ b/src/repl.mjs @@ -0,0 +1,268 @@ +import { createInterface } from 'readline'; +import { $ } from './$.mjs'; +import { register } from './$.mjs'; + +const REPL_PROMPT = '> '; +const CONTINUATION_PROMPT = '... '; + +export async function repl(options = {}) { + console.log('command-stream REPL v0.7.1'); + console.log('Interactive shell environment with $ command support'); + console.log('Type "help" for commands, "exit" or Ctrl+C to quit\n'); + + const rl = createInterface({ + input: process.stdin, + output: process.stdout, + prompt: REPL_PROMPT, + completer: autoComplete + }); + + let multilineBuffer = ''; + let inMultilineMode = false; + + // REPL-specific commands + const replCommands = { + help: () => { + console.log(` +Available commands: + help Show this help message + exit Exit the REPL + clear Clear the screen + .commands List all registered virtual commands + .register Register a new virtual command + .unregister Unregister a virtual command + +Shell features: + $\`command\` Execute shell command with streaming + await $\`command\` Wait for command to complete + $.register(name, fn) Register virtual command + +Examples: + > $\`ls -la\` + > await $\`git status\` + > const result = await $\`echo "hello"\` + > result.stdout +`); + }, + + exit: () => { + console.log('Goodbye!'); + rl.close(); + process.exit(0); + }, + + clear: () => { + console.clear(); + console.log('command-stream REPL v0.7.1\n'); + }, + + '.commands': async () => { + const { listCommands } = await import('./$.mjs'); + const commands = listCommands(); + console.log('Registered virtual commands:', commands.join(', ')); + } + }; + + // Auto-completion support + function autoComplete(line) { + const completions = [ + // REPL commands + 'help', 'exit', 'clear', '.commands', '.register', '.unregister', + // Common shell commands + 'ls', 'cd', 'pwd', 'echo', 'cat', 'grep', 'find', 'git', 'npm', 'node', + // $ syntax + '$`', 'await $`', + // JavaScript keywords + 'const', 'let', 'var', 'function', 'async', 'await', 'for', 'if', 'else' + ]; + + const hits = completions.filter((c) => c.startsWith(line)); + return [hits.length ? hits : completions, line]; + } + + // Handle line input + rl.on('line', async (input) => { + const trimmedInput = input.trim(); + + // Handle empty input + if (!trimmedInput) { + rl.prompt(); + return; + } + + // Handle multiline input + if (inMultilineMode) { + multilineBuffer += '\n' + input; + + // Check if multiline input is complete + if (isCompleteStatement(multilineBuffer)) { + await executeStatement(multilineBuffer); + multilineBuffer = ''; + inMultilineMode = false; + rl.setPrompt(REPL_PROMPT); + } else { + rl.setPrompt(CONTINUATION_PROMPT); + } + rl.prompt(); + return; + } + + // Check for REPL commands + if (replCommands[trimmedInput]) { + await replCommands[trimmedInput](); + rl.prompt(); + return; + } + + // Check if statement is complete + if (!isCompleteStatement(trimmedInput)) { + multilineBuffer = trimmedInput; + inMultilineMode = true; + rl.setPrompt(CONTINUATION_PROMPT); + rl.prompt(); + return; + } + + await executeStatement(trimmedInput); + rl.prompt(); + }); + + // Handle SIGINT (Ctrl+C) + rl.on('SIGINT', () => { + if (inMultilineMode) { + console.log('\n(Multiline input cancelled)'); + multilineBuffer = ''; + inMultilineMode = false; + rl.setPrompt(REPL_PROMPT); + rl.prompt(); + } else { + console.log('\nUse "exit" or Ctrl+D to quit'); + rl.prompt(); + } + }); + + // Handle EOF (Ctrl+D) + rl.on('close', () => { + console.log('\nGoodbye!'); + process.exit(0); + }); + + // Start the REPL + rl.prompt(); + + // Keep the REPL running + return new Promise(() => {}); // Never resolves, REPL runs until exit +} + +function isCompleteStatement(statement) { + try { + // Simple heuristic: check for unmatched brackets/braces + let braceCount = 0; + let bracketCount = 0; + let parenCount = 0; + let inString = false; + let stringChar = ''; + let inTemplate = false; + let templateDepth = 0; + + for (let i = 0; i < statement.length; i++) { + const char = statement[i]; + const prevChar = i > 0 ? statement[i - 1] : ''; + + // Handle string literals + if (!inTemplate && (char === '"' || char === "'") && prevChar !== '\\') { + if (!inString) { + inString = true; + stringChar = char; + } else if (char === stringChar) { + inString = false; + stringChar = ''; + } + continue; + } + + if (inString && !inTemplate) continue; + + // Handle template literals + if (char === '`' && prevChar !== '\\') { + if (!inTemplate) { + inTemplate = true; + templateDepth = 1; + } else { + templateDepth--; + if (templateDepth === 0) { + inTemplate = false; + } + } + continue; + } + + if (inTemplate && char === '`') { + templateDepth++; + continue; + } + + if (inTemplate) continue; + + // Count brackets + if (char === '{') braceCount++; + else if (char === '}') braceCount--; + else if (char === '[') bracketCount++; + else if (char === ']') bracketCount--; + else if (char === '(') parenCount++; + else if (char === ')') parenCount--; + } + + // Statement is complete if all brackets are matched + return braceCount === 0 && bracketCount === 0 && parenCount === 0 && !inString && !inTemplate; + } catch (e) { + // If we can't parse it, assume it's incomplete + return false; + } +} + +async function executeStatement(statement) { + try { + // Create a sandboxed evaluation context with $ available + const context = { + $, + console, + process, + require, + __dirname: process.cwd(), + __filename: 'repl' + }; + + // Wrap in async function to handle await + const wrappedStatement = ` + return (async () => { + ${statement} + })(); + `; + + // Create function with context + const contextKeys = Object.keys(context); + const contextValues = Object.values(context); + const AsyncFunction = Object.getPrototypeOf(async function(){}).constructor; + const func = new AsyncFunction(...contextKeys, wrappedStatement); + + // Execute with context + const result = await func(...contextValues); + + // Display result if not undefined + if (result !== undefined) { + if (result && typeof result === 'object') { + // Pretty print objects + console.log(JSON.stringify(result, null, 2)); + } else { + console.log(result); + } + } + + } catch (error) { + console.error(`Error: ${error.message}`); + if (process.env.COMMAND_STREAM_VERBOSE === 'true') { + console.error(error.stack); + } + } +} \ No newline at end of file diff --git a/tests/dev-mode.test.mjs b/tests/dev-mode.test.mjs new file mode 100644 index 0000000..4857111 --- /dev/null +++ b/tests/dev-mode.test.mjs @@ -0,0 +1,113 @@ +#!/usr/bin/env node + +import { describe, it, expect } from 'bun:test'; +import './test-helper.mjs'; +import { $ } from '../src/$.mjs'; +import { spawn } from 'child_process'; +import path from 'path'; +import { fileURLToPath } from 'url'; +import fs from 'fs'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const cliPath = path.join(__dirname, '../bin/cli.mjs'); + +describe('Dev Mode', () => { + it('should have dev method available on $ object', () => { + expect(typeof $.dev).toBe('function'); + }); + + it('should start dev mode via CLI', async () => { + const devProcess = spawn('node', [cliPath, 'dev'], { + stdio: ['pipe', 'pipe', 'pipe'], + env: { ...process.env, NODE_ENV: 'test' } + }); + + let output = ''; + let errorOutput = ''; + + devProcess.stdout.on('data', (data) => { + output += data.toString(); + }); + + devProcess.stderr.on('data', (data) => { + errorOutput += data.toString(); + }); + + // Wait for dev mode to start + await new Promise((resolve) => { + const checkStarted = () => { + if (output.includes('command-stream Development Mode')) { + resolve(); + } else { + setTimeout(checkStarted, 100); + } + }; + setTimeout(checkStarted, 1000); + }); + + expect(output).toContain('command-stream Development Mode'); + expect(output).toContain('Working directory:'); + expect(output).toContain('Watching patterns:'); + expect(output).toContain('Development mode active'); + + // Kill the process + devProcess.kill('SIGTERM'); + + await new Promise((resolve) => { + devProcess.on('exit', resolve); + }); + }, 10000); + + it('should display help when called without arguments', async () => { + const helpProcess = spawn('node', [cliPath], { + stdio: ['pipe', 'pipe', 'pipe'] + }); + + let output = ''; + let errorOutput = ''; + + helpProcess.stdout.on('data', (data) => { + output += data.toString(); + }); + + helpProcess.stderr.on('data', (data) => { + errorOutput += data.toString(); + }); + + await new Promise((resolve) => { + helpProcess.on('exit', resolve); + }); + + expect(output).toContain('command-stream - Modern shell utility library'); + expect(output).toContain('Usage:'); + expect(output).toContain('npx command-stream repl'); + expect(output).toContain('npx command-stream dev'); + }, 5000); + + it('should start REPL when dev mode is called with repl: true', async () => { + // This test would ideally test the programmatic API + // We'll test that the method exists and can be called + const devOptions = { repl: false, watch: ['*.test.js'] }; + + // Start dev mode in a child process to avoid hanging the test + const devProcess = spawn('node', ['-e', ` + import { $ } from './src/$.mjs'; + console.log('Dev method exists:', typeof $.dev === 'function'); + process.exit(0); + `], { + stdio: ['pipe', 'pipe', 'pipe'], + cwd: path.join(__dirname, '..') + }); + + let output = ''; + devProcess.stdout.on('data', (data) => { + output += data.toString(); + }); + + await new Promise((resolve) => { + devProcess.on('exit', resolve); + }); + + expect(output).toContain('Dev method exists: true'); + }, 5000); +}); \ No newline at end of file diff --git a/tests/repl.test.mjs b/tests/repl.test.mjs new file mode 100644 index 0000000..690e49e --- /dev/null +++ b/tests/repl.test.mjs @@ -0,0 +1,174 @@ +#!/usr/bin/env node + +import { describe, it, expect } from 'bun:test'; +import './test-helper.mjs'; +import { spawn } from 'child_process'; +import path from 'path'; +import { fileURLToPath } from 'url'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const cliPath = path.join(__dirname, '../bin/cli.mjs'); + +describe('REPL', () => { + it('should start REPL and respond to help command', async () => { + const repl = spawn('node', [cliPath, 'repl'], { + stdio: ['pipe', 'pipe', 'pipe'], + env: { ...process.env, NODE_ENV: 'test' } + }); + + let output = ''; + let errorOutput = ''; + + repl.stdout.on('data', (data) => { + output += data.toString(); + }); + + repl.stderr.on('data', (data) => { + errorOutput += data.toString(); + }); + + // Wait for REPL to start + await new Promise((resolve) => { + const checkStarted = () => { + if (output.includes('command-stream REPL')) { + resolve(); + } else { + setTimeout(checkStarted, 100); + } + }; + checkStarted(); + }); + + expect(output).toContain('command-stream REPL'); + expect(output).toContain('Interactive shell environment'); + + // Send help command + repl.stdin.write('help\n'); + + // Wait for help output + await new Promise((resolve) => { + const checkHelp = () => { + if (output.includes('Available commands:')) { + resolve(); + } else { + setTimeout(checkHelp, 100); + } + }; + setTimeout(checkHelp, 100); + }); + + expect(output).toContain('Available commands:'); + expect(output).toContain('help'); + expect(output).toContain('exit'); + + // Send exit command + repl.stdin.write('exit\n'); + + await new Promise((resolve, reject) => { + repl.on('close', (code) => { + resolve(code); + }); + repl.on('error', reject); + }); + + expect(output).toContain('Goodbye!'); + }, 10000); + + it('should execute $ commands in REPL', async () => { + const repl = spawn('node', [cliPath, 'repl'], { + stdio: ['pipe', 'pipe', 'pipe'], + env: { ...process.env, NODE_ENV: 'test' } + }); + + let output = ''; + let errorOutput = ''; + + repl.stdout.on('data', (data) => { + output += data.toString(); + }); + + repl.stderr.on('data', (data) => { + errorOutput += data.toString(); + }); + + // Wait for REPL to start + await new Promise((resolve) => { + const checkStarted = () => { + if (output.includes('command-stream REPL')) { + resolve(); + } else { + setTimeout(checkStarted, 100); + } + }; + checkStarted(); + }); + + // Send a simple $ command + repl.stdin.write('await $`echo "hello from repl"`\n'); + + // Wait for command execution + await new Promise((resolve) => { + const checkExecution = () => { + if (output.includes('hello from repl')) { + resolve(); + } else { + setTimeout(checkExecution, 100); + } + }; + setTimeout(checkExecution, 500); + }); + + expect(output).toContain('hello from repl'); + + // Exit + repl.stdin.write('exit\n'); + await new Promise((resolve) => repl.on('close', resolve)); + }, 15000); + + it('should handle .commands REPL command', async () => { + const repl = spawn('node', [cliPath, 'repl'], { + stdio: ['pipe', 'pipe', 'pipe'], + env: { ...process.env, NODE_ENV: 'test' } + }); + + let output = ''; + + repl.stdout.on('data', (data) => { + output += data.toString(); + }); + + // Wait for REPL to start + await new Promise((resolve) => { + const checkStarted = () => { + if (output.includes('command-stream REPL')) { + resolve(); + } else { + setTimeout(checkStarted, 100); + } + }; + checkStarted(); + }); + + // Send .commands + repl.stdin.write('.commands\n'); + + // Wait for commands output + await new Promise((resolve) => { + const checkCommands = () => { + if (output.includes('Registered virtual commands:')) { + resolve(); + } else { + setTimeout(checkCommands, 100); + } + }; + setTimeout(checkCommands, 500); + }); + + expect(output).toContain('Registered virtual commands:'); + expect(output).toContain('cd'); // Should show built-in commands + + // Exit + repl.stdin.write('exit\n'); + await new Promise((resolve) => repl.on('close', resolve)); + }, 10000); +}); \ No newline at end of file