cabal-cli
Version:
588 lines (538 loc) • 17.9 kB
JavaScript
var Client = require('cabal-client')
var minimist = require('minimist')
var fs = require('fs')
var path = require('path')
var yaml = require('js-yaml')
var mkdirp = require('mkdirp')
var frontend = require('./neat-screen.js')
var chalk = require('chalk')
var captureQrCode = require('node-camera-qr-reader')
var fe = null
const onExit = require('signal-exit')
const { version: packageJSONVersion } = require('./package.json')
var args = minimist(process.argv.slice(2))
const version = getClientVersion()
// set terminal window title
process.stdout.write('\x1B]0;cabal\x07')
var rootdir = null
if (args.config && fs.statSync(args.config).isDirectory()) {
rootdir = path.join(args.config, `v${Client.getDatabaseVersion()}`)
} else if (args.config) {
rootdir = path.join(
path.dirname(path.resolve(args.config)),
`v${Client.getDatabaseVersion()}`
)
} else {
rootdir = Client.getCabalDirectory()
}
var rootconfig = `${rootdir}/config.yml`
var archivesdir = `${rootdir}/archives/`
const defaultMessageTimeformat = '%T'
const defaultMessageIndent = 'nick'
var usage = `Usage
Create a new cabal:
cabal --new
Create a new cabal and name it locally:
cabal --new --alias <name>
Join a cabal by its key:
cabal cabal://key
Join a cabal by an alias:
cabal <your saved --alias of a cabal>
Save a cabal, adding it to the list of cabals to autojoin:
cabal --save cabal://key
Join all of your saved cabals by running just \`cabal\`:
cabal
Join a cabal by a QR code:
cabal --qr
Options:
--seed Start a headless seed for the specified cabal key
--port Listen for cabal traffic on the passed in port (default: 13331)
--new Start a new cabal
--nick Your nickname
--alias Save an alias for the specified cabal. Used with --key.
--alias <name> --key <cabal>
--aliases Print out your saved cabal aliases
--cabals Print out your saved cabals
--forget Forgets the specified cabal. Works on aliases and keys persisted with --save
--clear Clears out all aliases
--save Save the specified cabal to the config
--save <cabal>
--key Specify a cabal key. Used with --alias.
--alias <name> --key <cabal>
--config Specify a full path to a cabal config
--qr Capture a frame from a connected camera to read a cabal key from a QR code
--temp Start the cli with a temporary in-memory database. Useful for debugging
--version Print out which version of cabal you're running
--help Print this help message
--message Publish a single message; then quit after \`timeout\`
--channel Channel name to publish to for \`message\` option; default: "default"
--timeout Delay in milliseconds to wait on swarm before quitting for \`message\` option; default: 5000
--type Message type set to message for \`message\` option; default: "chat/text"
Work in progress! Learn more at https://github.com/cabal-club
`
if (args.version || args.v) {
console.log(version)
process.exit(0)
}
if (args.help || args.h) {
process.stderr.write(usage)
process.exit(1)
}
var config
var cabalKeys = []
var configFilePath = findConfigPath()
mkdirp.sync(path.dirname(configFilePath))
var maxFeeds = 1000
// make sure the .cabal/v<databaseVersion> folder exists
mkdirp.sync(rootdir)
// create a default config in rootdir if it doesn't exist
if (!fs.existsSync(rootconfig)) {
saveConfig(rootconfig, {
cabals: [],
aliases: {},
preferredPort: 0,
cache: {},
frontend: {
messageTimeformat: defaultMessageTimeformat,
messageIndent: defaultMessageIndent
}
})
}
// Attempt to load local or homedir config file
try {
if (configFilePath) {
if (fs.existsSync(configFilePath)) {
config = yaml.safeLoad(fs.readFileSync(configFilePath, 'utf8'))
} else {
config = {}
}
if (!config.cabals) { config.cabals = [] }
if (!config.aliases) { config.aliases = {} }
if (!config.preferredPort) { config.preferredPort = 0 }
if (!config.cache) { config.cache = {} }
if (!config.frontend) { config.frontend = {} }
if (!config.frontend.messageTimeformat) {
config.frontend.messageTimeformat = defaultMessageTimeformat
}
if (!config.frontend.messageIndent) {
config.frontend.messageIndent = defaultMessageIndent
}
cabalKeys = config.cabals
}
} catch (e) {
logError(e)
process.exit(1)
}
const client = new Client({
maxFeeds: maxFeeds,
config: {
dbdir: archivesdir,
temp: args.temp,
preferredPort: args.port || config.preferredPort
},
commands: {
// todo: custom commands
more: {
help: () => 'adds more messages to the backlog of current channel',
category: ['misc'],
call: (cabal, res, arg) => {
fe.moreBacklog()
}
},
panes: {
help: () => 'set pane to navigate up and down in. panes: channels, cabals',
category: ['misc'],
call: (cabal, res, arg) => {
if (arg === '' || !['channels', 'cabals'].includes(arg)) return
fe.setPane(arg)
}
},
quit: {
help: () => 'exit the cabal process',
category: ['basics'],
call: (cabal, res, arg) => {
process.exit(0)
}
},
exit: {
help: () => 'exit the cabal process',
category: ['basics'],
call: (cabal, res, arg) => {
process.exit(0)
}
},
help: {
help: () => 'display this help message',
category: ['basics'],
call: (cabal, res, arg) => {
const hotkeysExplanation = `
ctrl-l
redraw the screen
ctrl-u
clear input line
ctrl-w
delete last word in input
up-arrow
cycle through command history
down-arrow
cycle through command history
ctrl-a, home
go to start of input line
ctrl-e, end
go to end of input line
ctrl-n
go to next channel
ctrl-p
go to previous channel
ctrl-r
go to next unread channel
pageup
scroll up through backlog
pagedown
scroll down through backlog
shift-pageup
scroll up through nicklist
shift-pagedown
scroll down through nicklist
alt-[1,9]
select channels 1-9
alt-n
tab between the cabals & channels panes
ctrl-{n,p}
move up/down channels/cabals
alt-l
toggle id suffixes on/off
`
const categories = new Set(['hotkeys'])
function printCategories () {
for (const cat of Array.from(categories).sort((a, b) => a.localeCompare(b))) {
fe.writeLine(`/help ${chalk.cyan(cat)}`)
}
}
var foundAliases = {}
const commands = {}
for (const key in cabal.client.commands) {
if (!cabal.client.commands[key].category) { continue }
cabal.client.commands[key].category.forEach(cat => {
if (!commands[cat]) commands[cat] = []
commands[cat].push(key)
categories.add(cat)
})
}
if (!arg) {
fe.writeLine('the help command is split into sections:')
printCategories()
} else if (arg && !categories.has(arg)) {
fe.writeLine(`${arg} is not a help section, try:`)
printCategories()
} else {
fe.writeLine(`help: ${chalk.cyan(arg)}`)
if (arg === 'hotkeys') {
fe.writeLine(hotkeysExplanation)
return
}
// print all commands from the category defined by `arg`
commands[arg].forEach(key => {
if (foundAliases[key]) { return }
const slash = chalk.gray('/')
let command = key
if (cabal.client.aliases[key]) {
foundAliases[cabal.client.aliases[key]] = true
command += `, ${slash}${cabal.client.aliases[key]}`
}
fe.writeLine(`${slash}${command}`)
fe.writeLine(` ${cabal.client.commands[key].help()}`)
})
}
}
}
},
persistentCache: {
read: async function (name, err) {
if (name in config.cache) {
var cache = config.cache[name]
if (cache.expiresAt < Date.now()) { // if ttl has expired: warn, but keep using
console.error(`${chalk.redBright('Note:')} the TTL for ${name} has expired`)
}
return cache.key
}
// dns record wasn't found online and wasn't in the cache
return null
},
write: async function (name, key, ttl) {
var expireOffset = +(new Date(ttl * 1000)) // convert to epoch time
var expiredTime = Date.now() + expireOffset
if (!config.cache) config.cache = {}
config.cache[name] = { key: key, expiresAt: expiredTime }
saveConfig(configFilePath, config)
}
}
})
// Close all cabals on exit.
onExit(function () {
for (const cabal of client.cabals.values()) {
cabal._destroy(() => {
})
}
})
if (args.clear) {
delete config.aliases
saveConfig(configFilePath, config)
process.stdout.write('Aliases cleared\n')
process.exit(0)
}
if (args.forget) {
let success = false
/* eslint no-inner-declarations: "off" */
function forgetCabal (k) {
const index = config.cabals.indexOf(k)
if (index >= 0) {
config.cabals.splice(index, 1)
success = true
}
}
if (config.aliases[args.forget]) {
const aliasedKey = config.aliases[args.forget]
success = true
delete config.aliases[args.forget]
// forget any potential reuses of the aliased key in config.cabals array
forgetCabal(aliasedKey)
}
// check if key is among saved cabals
if (!success) forgetCabal(args.forget)
if (success) {
saveConfig(configFilePath, config)
console.log(`${args.forget} has been forgotten`)
} else { console.log('no such cabal') }
process.exit(0)
}
if (args.aliases) {
var aliases = Object.keys(config.aliases)
if (aliases.length === 0) {
process.stdout.write("You don't have any saved aliases.\n\n")
process.stdout.write('Save an alias by running\n')
process.stdout.write(`${chalk.magentaBright('cabal: ')} ${chalk.greenBright('--key cabal://c001..c4b41')} `)
process.stdout.write(`${chalk.blueBright('--alias your-alias-name')}\n`)
} else {
aliases.forEach(function (alias) {
process.stdout.write(`${chalk.blueBright(alias)}\t\t ${chalk.greenBright(config.aliases[alias])}\n`)
})
}
process.exit(0)
}
if (args.cabals) {
var savedCabals = config.cabals
if (savedCabals.length === 0) {
process.stdout.write("You don't have any saved cabals.\n\n")
process.stdout.write('Save a cabal by running\n')
process.stdout.write(`${chalk.magentaBright('cabal: ')} ${chalk.greenBright('--save cabal://c001..c4b41')} `)
} else {
savedCabals.forEach(function (saved) {
process.stdout.write(`${chalk.greenBright(saved)}\n`)
})
}
process.exit(0)
}
if (args.alias && !args.new && !args.key) {
logError('the --alias option needs to be used together with --key')
process.exit(1)
}
// user wants to alias a cabal:// key with a name
if (args.alias && args.key) {
saveKeyAsAlias(args.key, args.alias)
process.exit(0)
}
if (args.port) {
const port = parseInt(args.port)
if (isNaN(port) || port < 0 || port > 65535) {
logError(`${args.port} is not a valid port number`)
process.exit(1)
}
args.port = port
}
if (args.key) {
// If a key is provided, place it at the top of the keys provided from the config
cabalKeys.unshift(args.key)
} else if (args.temp && args.temp.length > 0) {
// don't eat the key if it was passed in as `cabal --temp <key>`
cabalKeys = [args.temp]
} else if (args._.length > 0) {
// the cli was run as `cabal <alias|key> ... <alias|key>`
// replace keys from config with the keys from the args
cabalKeys = args._.map(getKey)
}
// join and save the passed in cabal keys
if (args.save) {
cabalKeys = args._.map(getKey)
if (args.save.length > 0) cabalKeys = cabalKeys.concat(getKey(args.save))
if (!cabalKeys.length) {
console.log(`${chalk.magentaBright('cabal:')} error, need cabal keys to save. example:`)
console.log(`${chalk.greenBright('cabal --save cabal://key')}`)
process.exit(1)
}
config.cabals = config.cabals.concat(cabalKeys)
saveConfig(configFilePath, config)
// output message about keys having been saved
if (cabalKeys.length === 1) {
console.log(`${chalk.magentaBright('cabal:')} saved ${chalk.greenBright(cabalKeys[0])}`)
} else {
console.log(`${chalk.magentaBright('cabal:')} saved the following keys:`)
cabalKeys.forEach((key) => { console.log(`${chalk.greenBright(key)}`) })
}
process.exit(0)
}
// try to initiate the frontend using either qr codes via webcam, using cabal keys passed via cli,
// or starting an entirely new cabal per --new
if (args.qr) {
console.log('Cabal is looking for a QR code...')
console.log('Press ctrl-c to stop.')
captureQrCode({ retry: true }).then((key) => {
if (key) {
console.log('\u0007') // system bell
start([key], config.frontend)
} else {
console.log('No QR code detected.')
process.exit(0)
}
}).catch((e) => {
console.error('Webcam capture failed. Have you installed the appropriate drivers? See the documentation for more information.')
console.error('Mac OSX: brew install imagesnap')
console.error('Linux: sudo apt-get install fswebcam')
})
} else if (cabalKeys.length || args.new) {
start(cabalKeys, config.frontend)
} else {
// no keys, no qr, and not trying to start a new cabal => print help info
process.stderr.write(usage)
process.exit(1)
}
function start (keys, frontendConfig) {
if (args.key && args.message) {
publishSingleMessage({
key: args.key,
channel: args.channel,
message: args.message,
messageType: args.type,
timeout: args.timeout
})
return
}
keys = Array.from(new Set(keys)) // remove duplicates
var pendingCabals = args.new ? [client.createCabal()] : keys.map(client.addCabal.bind(client))
Promise.all(pendingCabals).then(() => {
if (args.new) {
console.error(`created the cabal: ${chalk.greenBright('cabal://' + client.getCurrentCabal().key)}`) // log to terminal output (stdout is occupied by interface)
// allow saving newly created cabal as alias
if (args.alias) { saveKeyAsAlias(client.getCurrentCabal().key, args.alias) }
keys = [client.getCurrentCabal().key]
}
// edgecase: if the config is empty we remember the first joined cabals in it
if (!config.cabals.length) {
config.cabals = keys
saveConfig(configFilePath, config)
}
if (args.nick && args.nick.length > 0) client.getCurrentCabal().publishNick(args.nick)
if (!args.seed) {
fe = frontend({ client, frontendConfig })
} else {
const seedKeys = []
for (const details of client.cabals.keys()) {
seedKeys.push(details.key)
}
seedKeys.forEach((k) => {
console.log('Seeding', k)
console.log()
console.log('@: new peer')
console.log('x: peer left')
console.log('^: uploaded a chunk')
console.log('.: downloaded a chunk')
console.log()
trackAndPrintEvents(client._getCabalByKey(k))
})
}
}).catch((e) => {
if (!e || e.toString() === 'Error: dns failed to resolve') {
console.error("Error: Couldn't resolve one of the following cabal keys:", chalk.yellow(keys.join(' ')))
} else {
console.error(e)
}
process.exit(1)
})
}
function trackAndPrintEvents (cabal) {
cabal.ready(() => {
// Listen for feeds
cabal.kcore._logs.feeds().forEach(listen)
cabal.kcore._logs.on('feed', listen)
function listen (feed) {
feed.on('download', idx => {
process.stdout.write('.')
})
feed.on('upload', idx => {
process.stdout.write('^')
})
}
cabal.on('peer-added', () => {
process.stdout.write('@')
})
cabal.on('peer-dropped', () => {
process.stdout.write('x')
})
})
}
function getKey (str) {
// return key if what was passed in was a saved alias
if (str in config.aliases) { return config.aliases[str] }
// else assume it's a cabal key
return str
}
function logError (msg) {
console.error(`${chalk.red('cabal:')} ${msg}`)
}
function findConfigPath () {
var currentDirConfigFilename = '.cabal.yml'
if (args.config && fs.statSync(args.config).isDirectory()) {
return path.join(args.config, `v${Client.getDatabaseVersion()}`, 'config.yml')
} else if (args.config && fs.existsSync(args.config)) {
return args.config
} else if (fs.existsSync(currentDirConfigFilename)) {
return currentDirConfigFilename
}
return rootconfig
}
function saveConfig (path, config) {
// make sure config is well-formatted (contains all config options)
if (!config.cabals) { config.cabals = [] }
config.cabals = Array.from(new Set(config.cabals)) // dedupe array entries
if (!config.aliases) { config.aliases = {} }
const data = yaml.safeDump(config, {
sortKeys: true
})
fs.writeFileSync(path, data, 'utf8')
}
function saveKeyAsAlias (key, alias) {
config.aliases[alias] = key
saveConfig(configFilePath, config)
console.log(`${chalk.magentaBright('cabal:')} saved ${chalk.greenBright(key)} as ${chalk.blueBright(alias)}`)
}
function publishSingleMessage ({ key, channel, message, messageType, timeout }) {
console.log(`Publishing message to channel - ${channel || 'default'}: ${message}`)
client.addCabal(key).then(cabal => cabal.publishMessage({
type: messageType || 'chat/text',
content: {
channel: channel || 'default',
text: message
}
})
)
setTimeout(function () { process.exit(0) }, timeout || 5000)
}
function getClientVersion () {
if (packageJSONVersion) {
return packageJSONVersion
}
console
.error('failed to read cabal\'s package.json -- something is wrong with your installation')
process.exit(1)
}