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
JavaScript
/**
* 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()