UNPKG

zaincode

Version:

A modern, user-friendly CLI tool to manage projects, automate tasks, configure remotes, and push files with ease. Built for developers who want a lightweight yet powerful terminal experience.

459 lines (423 loc) 13.8 kB
#!/usr/bin/env node /** * bin/zain.js * Refactored CLI - single-file, modular, async, publish-ready * Improvements: * - Bracketed paste support (enable/disable) * - Safer hidden password input (restores raw mode on exit/ctrl+c) * - cmdAdd merges with existing index and dedupes paths * - execShell reports exit code and errors * - Proper cleanup on exit (SIGINT, SIGTERM, close) */ import readline from 'node:readline' import { stdin as input, stdout as output } from 'node:process' import { dirname } from 'node:path' import chalk from 'chalk' import fs from 'fs/promises' import { readdirSync, statSync, createReadStream } from 'node:fs' import path from 'path' import getAllPaths from '../utils/GetAllPaths.js' // keep your util files import commondExtracter from '../utils/CommondExtracter.js' import CheckType from '../Events/CheckType.js' import { spawn } from 'node:child_process' import axios from 'axios' import FormData from 'form-data' import { fileURLToPath } from 'url' const __filename = fileURLToPath(import.meta.url) const __dirname = dirname(__filename) const PROJECT_DIR = process.cwd() const ZAIN_DIR = path.join(PROJECT_DIR, '.zain') const INDEX_FILE = path.resolve(ZAIN_DIR, 'index.json') const CONFIG_FILE = path.resolve(ZAIN_DIR, 'config.json') const REMOTE_FILE = path.resolve(ZAIN_DIR, 'remote.json') const TOKEN_FILE = path.resolve(ZAIN_DIR, 'token.json') const PKG_FILE = path.join(__dirname, '.', 'package.json') // helpers const log = { info: (...args) => output.write(chalk.green(...args) + '\n'), warn: (...args) => output.write(chalk.yellow(...args) + '\n'), error: (...args) => output.write(chalk.red(...args) + '\n'), plain: (...args) => output.write(...args), } const safeReadJSON = async (filePath, fallback = null) => { try { const txt = await fs.readFile(filePath, 'utf8') if (!txt.trim()) return fallback return JSON.parse(txt) } catch (e) { return fallback } } const safeWriteJSON = async (filePath, data) => { await fs.mkdir(path.dirname(filePath), { recursive: true }) await fs.writeFile(filePath, JSON.stringify(data, null, 2), 'utf8') } const ensureZainDir = async () => { await fs.mkdir(ZAIN_DIR, { recursive: true }) } // bracketed paste helpers (enable/disable) const enableBracketedPaste = () => { try { if (output.isTTY) output.write('\x1b[?2004h') } catch (_) {} } const disableBracketedPaste = () => { try { if (output.isTTY) output.write('\x1b[?2004l') } catch (_) {} } // safer shell exec wrapper using spawn const execShell = command => new Promise(resolve => { try { const child = spawn(command, { shell: true, stdio: ['inherit', 'pipe', 'pipe'] }) child.stdout.on('data', d => output.write(`${d}`)) child.stderr.on('data', d => output.write(chalk.red(`${d}`))) child.on('close', code => { if (code !== 0) log.warn(`Command exited with code ${code}`) resolve(code) }) child.on('error', err => { log.error('Command failed:', err.message) resolve(1) }) } catch (err) { log.error('Command failed:', err.message) resolve(1) } }) // CLI command implementations const cmdInit = async () => { await ensureZainDir() await safeWriteJSON(INDEX_FILE, []) await safeWriteJSON(CONFIG_FILE, {}) await safeWriteJSON(REMOTE_FILE, {}) await safeWriteJSON(TOKEN_FILE, {}) log.info('Project initialized in .zain') } const cmdAdd = async arg => { if (!arg) { log.warn('zain add <file|folder|.>') return } const excludeDirs = ['node_modules', '.zain', '.git'] const root = PROJECT_DIR // helper to write deduped index (merge with existing) const writeIndex = async arr => { const prev = (await safeReadJSON(INDEX_FILE, [])) || [] const merged = Array.from( new Set( prev .concat(arr) .map(p => p.replace(/\\/g, '/')) .filter(Boolean) ) ) await safeWriteJSON(INDEX_FILE, merged) } if (arg === '.') { const all = await getAllPaths(root, root, excludeDirs) const rel = all.map(p => path.relative(root, p).replace(/\\/g, '/')) await writeIndex(rel) log.info('All files added!') return } const fullPath = path.join(root, arg) const type = await CheckType(fullPath) if (type === 'dir') { const paths = await getAllPaths(fullPath, root, excludeDirs) await writeIndex(paths.map(p => path.relative(root, p).replace(/\\/g, '/'))) log.info('Folder added!') } else if (type === 'file') { const rel = path.relative(root, fullPath).replace(/\\/g, '/') if (rel.split('/').includes('node_modules')) { log.warn('Skipped file inside node_modules') return } await writeIndex([rel]) log.info('File added!') } else { log.error('Invalid file or directory') } } const cmdRemoteAddOrigin = async urlFromCommand => { try { const url = new URL(urlFromCommand) const data = { origin: url.origin, push: urlFromCommand } await safeWriteJSON(REMOTE_FILE, data) log.info('Remote URL set successfully!') } catch (err) { log.error('Failed to set remote:', err.message) } } const cmdConfig = async raw => { // usage: zain config user.name "Zain" const parts = raw?.split(' ') || [] if (!parts[2]) { log.warn('Usage: zain config user.name "Your Name"') return } const key = parts[2] // expected user.name const value = commondExtracter(raw) // extracts quoted value const [rootKey, subKey] = key.split('.') if (rootKey !== 'user' || !['name', 'email'].includes(subKey) || !value) { log.error('Usage: zain config user.name "Zain"') return } const cfg = (await safeReadJSON(CONFIG_FILE, {})) || {} cfg.user = cfg.user || {} cfg.user[subKey] = value await safeWriteJSON(CONFIG_FILE, cfg) log.info(`Set user.${subKey} = "${value}"`) } // askHidden: safer TTY-aware hidden input prompt (uses rl.input, restores raw mode) const askHidden = (rl, question) => new Promise(resolve => { const stdin = rl.input const stdoutStream = rl.output // if input isn't a TTY, fall back to normal prompt if (!stdin.isTTY) { rl.question(question, answer => resolve(answer)) return } let buffer = '' const onData = key => { const s = String(key) // Enter / Ctrl+D if (s === '\r' || s === '\n' || s === '\u0004') { try { stdin.setRawMode(false) } catch (_) {} stdin.removeListener('data', onData) stdoutStream.write('\n') resolve(buffer) return } // Ctrl+C if (s === '\u0003') { try { stdin.setRawMode(false) } catch (_) {} stdin.removeListener('data', onData) stdoutStream.write('\n') resolve('') // treat as cancel return } // backspace handling if (s === '\u0008' || s === '\u007f') { if (buffer.length) buffer = buffer.slice(0, -1) } else { buffer += s } stdoutStream.clearLine(0) stdoutStream.cursorTo(0) stdoutStream.write(question + '*'.repeat(buffer.length)) } stdoutStream.write(question) try { stdin.setRawMode(true) } catch (_) {} stdin.resume() stdin.on('data', onData) }) const cmdLogin = async rl => { try { const remote = (await safeReadJSON(REMOTE_FILE, {})) || {} if (!remote.origin) { log.error('Remote URL not set. Use: zain remote add origin "<url>"') return } const email = await new Promise(res => rl.question('Email: ', res)) const password = await askHidden(rl, 'Password: ') // request const { data } = await axios.post(`${remote.origin}/login`, { email, password }) if (!data?.token) throw new Error('No token received') await ensureZainDir() await safeWriteJSON(TOKEN_FILE, { token: data.token }) log.info('Login successful!') } catch (err) { log.error('Login failed:', err.message) } } const cmdPush = async () => { try { const allPaths = (await safeReadJSON(INDEX_FILE, [])) || [] if (!allPaths.length) { log.error('No files to push. Use `zain add` first.') return } const remoteCfg = (await safeReadJSON(REMOTE_FILE, {})) || {} if (!remoteCfg.push) { log.error('Remote URL not set. Use: zain remote add origin "<url>"') return } const tokenObj = (await safeReadJSON(TOKEN_FILE, {})) || {} if (!tokenObj.token) { log.error('Not logged in. Use: zain login') return } const form = new FormData() // append files and corresponding paths entries for (const rel of allPaths) { const abs = path.join(PROJECT_DIR, rel) try { const st = statSync(abs) if (st.isFile()) { // append file stream and add an associated path field form.append('files', createReadStream(abs), { filename: rel.replace(/\\/g, '/') }) form.append('paths', rel.replace(/\\/g, '/')) } } catch (e) { // ignore missing files but warn log.warn(`Skipped missing: ${rel}`) } } const headers = { ...form.getHeaders(), Authorization: `Bearer ${tokenObj.token}` } await axios.post(remoteCfg.push, form, { headers, maxBodyLength: Infinity }) log.info('Push successful!') } catch (err) { log.error('Push failed:', err.message) } } const cmdShowVersion = async () => { try { const txt = await fs.readFile(PKG_FILE, 'utf8') const pkg = JSON.parse(txt) log.info(pkg.version || 'unknown') } catch (e) { log.error('Unable to read package.json') } } // CLI dispatch const handleLine = async (line, rl) => { const raw = line.trim() if (!raw) return const parts = raw.split(' ') if (parts[0] === 'zain') { const sub = parts[1] try { switch (sub) { case 'init': await cmdInit() break case 'add': await cmdAdd(parts.slice(2).join(' ') || parts[2]) break case 'remote': if (parts[2] === 'add' && parts[3] === 'origin') { const url = commondExtracter(raw) if (!url) log.warn('Please specify a URL in quotes!') else await cmdRemoteAddOrigin(url) } else { log.warn('Invalid remote command. Usage: zain remote add origin "<url>"') } break case 'config': await cmdConfig(parts.slice(1).join(' ')) break case 'push': await cmdPush() break case 'login': await cmdLogin(rl) break case '--version': case '-v': await cmdShowVersion() break default: log.warn('Unknown zain command') } } catch (err) { log.error('Error:', err.message) } } else if (parts[0] === 'cd') { try { const newPath = parts.slice(1).join(' ') || process.env.HOME || process.env.USERPROFILE process.chdir(newPath) } catch (e) { log.error('cd:', e.message) } } else if (parts[0] === 'ls' || parts[0] === 'dir') { try { const files = readdirSync(process.cwd(), { withFileTypes: true }) files.forEach(f => { log.plain(f.isDirectory() ? chalk.blue(f.name + '/') : f.name) log.plain(' ') }) log.plain('\n') } catch (e) { log.error('ls:', e.message) } } else { // pass-through to shell await execShell(raw) } } // completer that completes filesystem names (basic) const fileCompleter = line => { const tokens = line.trim().split(' ') const last = tokens[tokens.length - 1] || '' const isCd = tokens[0] === 'cd' || (tokens[0] === 'zain' && tokens[1] === 'add') try { const dir = process.cwd() const list = readdirSync(dir, { withFileTypes: true }).map(d => d.name) const hits = list.filter(f => f.startsWith(last)) // if only directories desired, filter const filtered = isCd ? hits.filter(n => statSync(path.join(dir, n)).isDirectory()) : hits return [filtered.length ? filtered : list.filter(n => n.startsWith(last)), last] } catch { return [[], last] } } // start interactive REPL const startRepl = () => { enableBracketedPaste() log.info('Hello from ZainCode Terminal 🚀') const rl = readline.createInterface({ input, output, prompt: `PS ${process.cwd()}> `, completer: fileCompleter, terminal: true, }) rl.on('line', async line => { await handleLine(line, rl) rl.setPrompt(`PS ${process.cwd()}> `) rl.prompt() }) rl.on('SIGINT', () => { log.plain('\n') try { disableBracketedPaste() } catch (_) {} rl.close() }) rl.on('close', () => { try { disableBracketedPaste() } catch (_) {} process.exit(0) }) rl.prompt() // Also ensure we clean up on process signals const cleanup = () => { try { disableBracketedPaste() } catch (_) {} // best-effort to restore terminal raw mode off try { if (input && typeof input.setRawMode === 'function') input.setRawMode(false) } catch (_) {} } process.on('SIGTERM', () => { cleanup() process.exit(0) }) process.on('uncaughtException', err => { cleanup() console.error('Uncaught exception:', err) process.exit(1) }) } startRepl()