An interactive command-line interface (CLI) tool to help you install, use, and administer an AO instance.
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

712 lines
24 KiB

2 years ago
#!/usr/bin/env node
import inquirer from 'inquirer'
import chalk from 'chalk'
import chalkAnimation from 'chalk-animation'
import gradient from 'gradient-string'
import figlet from 'figlet'
import { createSpinner } from 'nanospinner'
import { execSync } from 'child_process'
import { runAllTests, testDict } from './scripts/tests.js'
const MANUAL_PATH = '/home/deicidus/ao-svelte/static/manual'
let distro
let memberName
// Chalk styles
const greenChalk = chalk.hex('#008800')
const headerStyle = chalk.blue.bold.underline
const manualTitleStyle = greenChalk.bold.underline
// Preformatted phrases that can be used in backticked console.log strings
const theAO = `the ${greenChalk.bold('AO')}`
const theMenu = `the ${greenChalk.bold('Menu')}`
// Different sets of messages that can be randomly selected from
const greetingMessages = ['Portaling!', 'You are a Unicorn!', "Here we go!", "Wow!", "AO Loading...", "Powering Up!", "Unicorn Portal", "Don't Panic!"]
const welcomeMessages = [
`You turn a corner and are suddenly back in the halls of ${theAO}.`,
`You make the sign of ${theAO} and are whisked away on a gust of divine wind.`,
`You take a closer look at the folder you are in and realize it is a room.`,
`You draw an alchemical symbol and open a doorway into ${theAO}. You step through.`,
`"Ah, there you are again!" The old man greets you as you step through the portal.`,
`A line of doge-masked worshippers glide by. By the time you exit the trance, you are in ${theAO}. Doge bless.`,
`You spraypaint an ${greenChalk.bold('A')} superimposed on an ${greenChalk.bold('O')} and step through the portal.`,
`You receive a phone call. You answer it. You are in ${theAO}.`,
`You dab fiercely, and when you raise your head, you are in ${theAO}.`,
`A ship arrives and takes you out to sea. The ship sinks and you somehow wash up safely in ${theAO}.`,
`You are reading in the Library when you find a strange book. Whatever you read next in the book is ${theAO}.`,
`You plant a magic seed and it grows into a great tree. Climbing up into its branches, you know ${theAO} is here.`,
`In the late afternoon, a warm sunbeam highlights motes of dust over your book. These motes are ${theAO}.`,
`A black cat crosses your path. Most people wouldn't notice, but you know you have entered the AO.`,
`Dipping your brush in ink, you draw a perfect circle. This is ${theAO}.`,
`You are offered a choice between two pills. However, you have secretly built up an immunity to both pills, and trick your opponent into taking one. Inconceviably, you are in ${theAO}.`,
`A young man with spiky hair and golden skin appears before you in a halo of light. He guides you to ${theAO}.`,
]
const menuMessages = [
`You see ${theMenu}:`,
`A page of aged paper wafts into your hand. On it is written ${theMenu}:`,
`A minstrel walks by playing a haunting melody, and in its harmonies you hear the refrain of ${theMenu}:`,
`You pick up an apple from a nearby table and take a bite. You suddenly recall the words of ${theMenu}:`,
`With a screeching sound, a page containing ${theMenu} slowly emerges, line-by-line, from a dot-matrix printer:`,
`In the shadows cast in the cavernous space, you see the forms of ${theMenu}:`,
`With a low hum, standing waves appear on the reflecting pool at your feet. Impossibly, they spell out ${theMenu}:`,
`You see a box of candies labeled 'Eat Me'. Why not? you think. You find ${theMenu} printed on the wrapper:`,
`You see an ornate bottle labeled 'Drink Me'. Why not? you think. You take a sip and shrink to the size of a doormouse. Now you can read ${theMenu} scratched into the wainscoating:`,
`Cretaceous jungle plants tower overhead, overgrown from the safari room. The ancient fractals of the branches prefigure the structure of ${theMenu}:`,
]
const exclamationMessages = [
'Woah there!',
'Shiver me timbers!',
'iNtErEsTiNg!',
'Turing\'s ghost!',
'Frack!',
'Frell!',
'Good grief!',
'With great power comes great responsibility.',
]
const farewellMessages = [
'Goodbye!',
'Goodbye! Goodbye! Goodbye...!',
'The AO will always be with you.',
'Please return soon; the AO needs your virtue.',
'The AO will await your return.',
'Doge bless.',
'Please remember the AO throughout your day as a ward against evil.',
'Remember your PrioriTEA™.',
'Know thyself.',
'With great power comes great responsibility.',
'The AO is a state of mind.'
]
// Returns a random int between min and max (inclusive)
function randomInt(min, max) {
min = Math.ceil(min);
max = Math.floor(max);
return Math.floor(Math.random() * (max - min + 1)) + min;
}
// Returns a random item from the given array
function selectRandom(arrayToChooseFrom) {
return arrayToChooseFrom[randomInt(0, arrayToChooseFrom.length - 1)]
}
// Waits for the given number of milliseconds (or a brief pause by default)
const sleep = (ms = 550) => new Promise((r) => setTimeout(r, ms))
// This does not work
function exitIfRoot() {
try {
execSync('[ "$EUID" -eq 0 ]')
console.log(`${chalk.red.bold(selectRandom(exclamationMessages))} Seems you're running this script as a superuser.`)
console.log('That might cause some issues with permissions and whatnot. Run this script as your default user (without sudo) and I\'ll ask you when I need superuser permissions')
process.exit(1)
} catch(err) {}
}
// Check for an AO env file at ~/.ao/.env and returns true if it exists
function loadAoEnvFile() {
try {
execSync('[ -f "~/.ao/.env" ]')
console.log('AO .env file exists at ~/.ao/.env')
return true
} catch(err) {
console.log('AO .env file does not exist at ~/.ao/.env')
return false
}
}
// Displays a brief randomly-selected rainbow-animated phrase
async function unicornPortal(ms) {
const randomGreetingMessage = selectRandom(greetingMessages)
const rainbowTitle = chalkAnimation.rainbow(randomGreetingMessage + '\n')
await sleep(ms)
rainbowTitle.stop()
}
// Prints the given message to the screen in the given ASCII art style. Here is a list of decent styles:
const asciiFonts = ['Standard', 'Digital', 'Bubble', 'Script', 'Mini', 'Banner', 'Alphabet', 'Avatar', 'Chunky', 'Computer', 'Contessa', 'Gothic', 'Invita', 'Lockergnome', 'Madrid', 'Morse', 'Moscow', 'Pawp', 'Pepper', 'Pyramid', 'Rectangles', 'Shadow', 'Short', 'Slant', 'Small', 'Stampatello', 'Stop', 'Straight', 'Thick', 'Thin', 'Weird']
async function asciiArt(message, style) {
const randomFont = selectRandom(asciiFonts)
let art = figlet.textSync(message || 'Autonomous Organization', { font: randomFont })
art = centerLines(art)
console.log(gradient.pastel.multiline(art))
}
// Clears the console
function clearScreen() {
console.clear()
}
// Prints a random RPG-style welcome message to contextualize the AO experience and the main menu
async function welcome() {
const randomWelcomeMessage = selectRandom(welcomeMessages)
const randomMenuMessage = selectRandom(menuMessages)
const welcomeMessage = (' ' + randomWelcomeMessage + ' ' + randomMenuMessage).wordWrap(process.stdout.columns - 2)
// todo: line breaks would be more accurate if there were a function to count the length of a string minus the formatting codes (regex)
// right now the invisible formatting characters are counted so lines are wrapped early
console.log('\n' + welcomeMessage)
}
// Prints the AO Main Menu and executes the user's choice
async function mainMenu() {
console.log(`\n${headerStyle('AO Main Menu')}\n`)
const answer = await inquirer.prompt({
name: 'main_menu',
type: 'list',
message: 'Please choose:',
choices: [
'Alchemy',
'Deck',
'Admin',
'Tests',
'Manual',
'Log Out',
'Exit',
]
})
switch(answer.main_menu) {
case 'Alchemy':
while(await alchemyMenu()) {}
break
case 'Deck':
await todoList('My Todo List', ['Add full AO install process to ao-cli in convenient format', 'Add AO server unit tests to ao-cli', 'Get groceries', 'Play music every day'])
break
case 'Admin':
while(await adminMenu()) {}
break
case 'Tests':
while(await testsMenu()) {}
break
case 'Manual':
await printManualPage(MANUAL_PATH) // Fencepost case - print overview page
let previousChoice
do {
previousChoice = await manualFolderAsMenu(MANUAL_PATH, 'AO User Manual', 'Back to Main Menu', previousChoice)
} while(previousChoice)
break
case 'Log Out':
await spinnerWait('Logging out... (just kidding)')
break
case 'Exit':
console.log(chalk.yellow.bold(selectRandom(farewellMessages)))
await sleep(310)
return false
}
return true
}
// Prints the AO Admin Menu and executes the user's choice
async function adminMenu() {
console.log(`\n${headerStyle('AO Admin Menu')}`)
const adminChoices = [
'Install \'ao\' alias for \'ao-cli\'',
'Check AO install',
'Update AO',
'Configure AO features',
'Switch AO database',
'Import/Export state/decks',
'Check AO service',
'Watch logs now',
'Start/Stop AO service',
'Back to Main Menu'
]
const answer = await inquirer.prompt({
name: 'admin_menu',
type: 'list',
message: 'Please choose:',
choices: adminChoices,
pageSize: adminChoices.length,
})
switch(answer.admin_menu) {
case adminChoices[0]:
installAoAlias()
break
case adminChoices[1]:
case adminChoices[2]:
case adminChoices[3]:
while(await featuresMenu()) {}
break
case adminChoices[4]:
case adminChoices[5]:
case adminChoices[6]:
case adminChoices[7]:
case adminChoices[8]:
console.log("Not yet implemented.")
break
default:
return false
}
return true
}
// Prints the AO Unit Tests Menu and executes the user's choice
async function testsMenu() {
console.log(`\n${headerStyle('AO Unit Tests')}`)
let testChoices = Object.entries(testDict).map(([menuTitle, testFunction]) => {
return menuTitle
})
testChoices.push('Back to Main Menu')
const answer = await inquirer.prompt({
name: 'tests_menu',
type: 'list',
message: 'Please choose:',
choices: testChoices
})
if(answer.tests_menu === 'Back to Main Menu') {
return false
}
const testFunction = testDict[answer.tests_menu]
if(testFunction) await testFunction()
return true
}
// Prints the AO Admin Menu and executes the user's choice
async function alchemyMenu() {
console.log(`\n${headerStyle('Alchemy')}`)
const alchemyChoices = [
'Update software',
'Install AO prerequisites',
'Check bitcoin status',
'Back to Main Menu'
]
const answer = await inquirer.prompt({
name: 'alchemy_menu',
type: 'list',
message: 'Please choose:',
choices: alchemyChoices
})
switch(answer.alchemy_menu) {
case alchemyChoices[0]:
updateSoftware()
break
case alchemyChoices[1]:
installRequired()
break
case alchemyChoices[2]:
let stdout = execSync('source ~/Alchemy/ingredients/lead && source ~/Alchemy/ingredients/gold && bitcoin_is_synced')
console.log(`${stdout}`)
break
default:
return false
}
return true
}
// Prints the Configure AO Features menu and executes the user's choice
async function featuresMenu() {
console.log(`\n${headerStyle('Configure AO Features')}`)
const features = [
'nginx host AO publicly over the world wide web',
'SSL/Certbot HTTPS for public web AO',
'Tor connect AOs p2p',
'Bitcoin payments',
'Lightning payments',
'Jitsi secure video chat',
'Signal notifications',
'File hosting file attachments on cards',
'youtube-dl cache web videos',
'Borg backup',
'Encryption serverside secret messages',
'Themes custom themes',
'Glossary custom glossary',
'Jubilee monthly points creation event',
'Back to Main Menu'
]
const answer = await inquirer.prompt({
name: 'features_menu',
type: 'list',
message: 'Please choose:',
choices: features,
pageSize: features.length
})
switch(answer.features_menu) {
case 'Back to Main Menu':
return false
default:
console.log("Not yet implemented")
return true
}
return true
}
// Ask the user for their name and returns it
async function askName() {
const answer = await inquirer.prompt({
name: 'member_name',
type: 'input',
message: 'What username would you like?'
})
return answer.member_name
}
// Prints the given todoItems (array of strings) and allows items to be checked and unchecked
async function todoList(title, todoItems) {
console.log(`\n${headerStyle(title)}`)
const answer = await inquirer.prompt({
name: 'todo_list',
type: 'checkbox',
message: 'Check or uncheck items with Spacebar:',
choices: todoItems
})
}
// Detects which version(s) of the AO are installed (ao-3, ao-react, or ao-v)
function detectAoVersion() {
}
// Detects the operating system we are running on
function detectOS() {
let distro
try {
execSync('[ -f "/etc/debian_version" ]')
distro = 'debian'
console.log(`${greenChalk('Debian')}, Ubuntu, or Raspbian OS detected.`)
} catch(err) {}
try {
execSync('[ -f "/etc/arch-release" ]')
distro = 'arch'
console.log(`${greenChalk('Arch or Manjaro-based')} OS detected.`)
} catch(err) {}
try {
execSync('[ -f "/etc/fedora-release" ]')
distro = 'fedora'
console.log(`${greenChalk('Fedora')} OS detected.`)
} catch(err) {}
try {
execSync('[ $(uname | grep -c "Darwin") -eq 1 ]')
distro = 'mac'
console.log(`${greenChalk('MacOS')} detected.`)
} catch(err) {}
if(!distro) {
console.log("Your OS was not recognized, sorry.")
process.exit(1)
}
return distro
}
// Runs the correct command to update all your software for any recognized OS
function updateSoftware() {
distro = detectOS()
if(!distro) {
console.log("Your OS was not recognized, so nothing was updated, sorry.")
return false
}
console.log('Updating your software from repositories...')
console.log(`(You may need to input your ${chalk.blue.bold("'sudo' password")} here)`)
switch(distro) {
case 'debian':
execSync('sudo apt update && sudo apt autoremove && sudo apt upgrade')
break
case 'arch':
execSync('sudo pacman -Syu --noconfirm')
break
case 'fedora':
execSync('sudo dnf update && sudo dnf upgrade')
break
case 'mac':
execSync('install && sudo brew update')
break
}
return true
}
// Adds a line to .bashrc to make 'ao' an alias for 'ao-cli', to simplify using the AO from the command line
function installAoAlias() {
try {
execSync('grep "ao=\'ao-cli\'" ~/.bashrc')
console.log('You can already type \'ao\' to launch ao-cli; the alias line already exists in ~/.bashrc.')
} catch(err) {
execSync('echo alias ao=\'ao-cli\' >> .bashrc')
console.log('Added alias line to ~/.bashrc. You can now type \'ao\' to launch ao-cli.')
}
}
// Installs core dependencies required by Alchemy and the AO
function installRequired() {
distro = detectOS()
if(!distro) {
console.log("Your OS was not recognized, so nothing was installed, sorry.")
return false
}
console.log('Installing Alchemy and AO installation process core dependencies (fast if already installed)...')
console.log(`(You may need to input your ${chalk.blue.bold("'sudo' password")} here)`)
// Install OS-specific requirements
switch(distro) {
case 'debian':
execSync('sudo apt install build-essential')
// Some of these might not be required
execSync('source ~/Alchemy/ingredients/lead && install_if_needed sqlite3 zlib1g-dev libtool-bin autoconf autoconf-archive automake autotools-dev libgmp-dev libsqlite3-dev python python3 python3-mako libsodium-dev build-essential pkg-config libev-dev libcurl4-gnutls-dev libssl-dev fakeroot devscripts')
break
case 'arch':
try {
execSync('[[ ! $(pacman -Qg base-devel) ]]')
execSync('sudo pacman -S base-devel --noconfirm')
} catch(err) {}
execSync('source ~/Alchemy/ingredients/lead && install_if_needed python gmp sqlite3 autoconf-archive pkgconf libev python-mako python-pip net-tools zlib libsodium gettext nginx')
break
case 'fedora':
execSync('source ~/Alchemy/ingredients/lead && install_if_needed sqlite3 autoconf autoconf-archive automake python python3 python3-mako pkg-config fakeroot devscripts')
break
}
// Install on every OS
execSync('source ~/Alchemy/ingredients/lead && install_if_needed git wget make')
try {
execSync('[ -z $NVM_DIR ]')
execSync('source ingredients/iron && install_nvm')
console.log(`Installed nvm.`)
} catch(err) {}
return true
}
// Sets node to the current version used by the AO
function setNodeVersion() {
execSync('source ingredients/iron && set_node_to v16.13.0')
}
// Displays a spinner for 1.2 secconds with the given messages during and after the timer completes
async function spinnerWait(waitingMessage, doneMessage, ms = 1200) {
const spinner = createSpinner(waitingMessage || 'Please wait...').start()
await sleep(ms)
spinner.success({ text: doneMessage || 'Done.' })
}
// Loads the text of a file
import fs from 'fs'
import readYamlAndMarkdown from 'yaml-head-loader'
async function loadTextFile(path) {
return new Promise((resolve, reject) => {
fs.readFile(path, 'utf8', function(err, data) {
if (err) {
console.log('Reading file failed:', err)
return null
}
resolve(data)
})
})
}
// Loads the given text file and returns a dictionary of its contents parsed into a .header dict and .tail markdown content
async function loadYamlMarkdownFile(path) {
let text = await loadTextFile(path)
if(!text) {
return null
}
let dict = readYamlAndMarkdown(text)
return dict
}
// Loads and returns the list of contents of a folder as an array
function lsFolder(path) {
try {
return fs.readdirSync(path)
} catch(err) {
return null
}
}
// Returns true if the path is a folder
function isFolder(path) {
return Array.isArray(lsFolder(path))
}
// Adds a .toTitleCase() function to every string
String.prototype.toTitleCase = function () {
return this.replace(/\w\S*/g, function(txt){return txt.charAt(0).toUpperCase() + txt.substr(1).toLowerCase()})
}
String.prototype.wordWrap = function (width = 80) {
return this.replace(new RegExp(`(?:\\S(?:.{0,${width}}\\S)?(?:\\s+|-|$)|(?:\\S{${width}}))`, 'g'), s => `${s}\n`).slice(0, -1)
}
const repeatString = (str, n) => {
return new Array(1 + (n || 0)).join(str)
}
String.prototype.centerInConsole = function (width = 80) {
const consoleWidth = process.stdout.columns
const padding = Math.floor((consoleWidth - width) / 2)
const lines = this.split('\n')
const centered = lines.map(line => repeatString(" ", padding) + line)
return centered.join('\n')
}
// Centers a one-line string within the given number of characters by adding padding to the left
String.prototype.centerInLine = function (lineWidth, width = 80) {
const padding = Math.floor((width - lineWidth) / 2)
return repeatString(" ", padding) + this
}
const centerLines = (str) => {
const lines = str.split('\n')
const centered = lines.map(line => line.centerInLine(line.length, process.stdout.columns))
return centered.join('\n')
}
// Removes numbered prefix such as 12_ and .md suffix, replaces underscores with spaces, and adds titlecase
function formatManualTitleString(title) {
// Remove .md suffix
if(/\.md$/.exec(title)) {
title = title.substring(0, title.length - 3)
}
// Remove numbered prefix e.g., 12_
if(/^\d*_/.exec(title)) {
title = title.split('_').slice(1).join('_')
}
// Replace underscores with spaces
title = title.replaceAll('_', ' ')
return title.toTitleCase()
}
import { basename } from 'path'
import mdlogBuilder from 'mdlog'
// Prints the specified manual page to the screen
async function printManualPage(path) {
if(isFolder(path)) {
path += '/index.md'
}
const dict = await loadYamlMarkdownFile(path)
const title = dict?.meta?.title || formatManualTitleString(basename(path))
const formattedTitle = manualTitleStyle(title).centerInLine(title.length).centerInConsole()
console.log('\n' + formattedTitle + '\n')
const renderedMarkdown = mdlogBuilder.convert(dict?.tail.wordWrap()).join('\n').centerInConsole()
console.log(renderedMarkdown)
}
// Given a path and file/folder name, it returns the appropriate manual title
// If it's a folder and there is an index.js inside that has a title: field, that overrides the title
// Otherwise it's the filename or foldername, minus anything before the first underscore (_), in titlecase
async function loadManualTitle(path, fileOrFolder) {
// If it's a .md file, check inside for a title: field
if(/\.md$/.exec(fileOrFolder)) {
const indexTitle = (await loadYamlMarkdownFile(path + fileOrFolder))?.meta?.title
if(indexTitle) {
return indexTitle
}
}
// If it's a folder, check for a title: field in index.md and return if exists
if(isFolder(path + fileOrFolder)) {
const indexPath = path + fileOrFolder + '/index.md'
const indexTitle = (await loadYamlMarkdownFile(indexPath))?.meta?.title
if(indexTitle) {
return indexTitle
}
}
// Fall back to using the file/folder name as the title
return formatManualTitleString(fileOrFolder)
}
// Render the manual folder or a subfolder as a menu
// First the index.js is listed using the folder name or title loaded from inside the file as the menu item title
// Next, any other files not starting with a number are loaded and displayed in discovered/arbitrary order
// Next, items starting with 0_, then 1_, and so on are displayed in order. You can mix files and folders.
// Selecting a menu item renders it. For .md files it renders it and shows the same level Manual menu again.
// For folders, it goes into that folder and renders it as a manual menu folder
// This allows arbitrarily nested Manual menu folders to be explored in a standardized menu system
async function manualFolderAsMenu(path, menuTitle, backOption, previousMenuChoice = 0) {
if(!isFolder(path)) {
return false
}
if(path[path.length - 1] != '/') {
path += '/'
}
let menuItems = []
const folderContents = lsFolder(path)
if(folderContents.some(fileOrFolder => fileOrFolder === 'index.md')) {
const indexTitle = await loadManualTitle(path, 'index.md')
let indexMenuItem = {}
indexMenuItem[indexTitle] = 'index.md'
menuItems.push(indexMenuItem)
}
let unNumberedItems = []
let numberedItems = []
const sortedFolderContents = folderContents.sort((a, b) => a.localeCompare(b, undefined, { numeric: true }))
for(let i = 0; i < sortedFolderContents.length; i++) {
const fileOrFolder = sortedFolderContents[i]
if(fileOrFolder === 'index.md') {
continue
}
const potentialNumber = fileOrFolder.split('_')[0]
const initialNumber = parseInt(potentialNumber)
const title = await loadManualTitle(path, fileOrFolder)
const menuItem = {}
menuItem[title] = fileOrFolder
if(isNaN(initialNumber)) {
unNumberedItems.push(menuItem)
} else {
numberedItems.push(menuItem)
}
}
menuItems = menuItems.concat(unNumberedItems, numberedItems)
const menuChoices = menuItems.map(menuItem => Object.keys(menuItem)[0])
menuChoices.push(backOption)
console.log(`\n${headerStyle(menuTitle)}`)
const answer = await inquirer.prompt({
name: 'manual_menu',
type: 'rawlist',
message: 'Please choose:',
choices: menuChoices,
pageSize: menuChoices.length,
default: previousMenuChoice
})
const chosenMenuIndex = menuChoices.indexOf(answer.manual_menu)
if(answer.manual_menu === backOption) {
return false
}
const chosenPath = path + Object.values(menuItems.find(menuItem => Object.keys(menuItem)[0] === answer.manual_menu))[0]
await printManualPage(chosenPath)
const newBackOption = backOption === 'Back to Main Menu' ? 'Back to Table of Contents' : 'Back to ' + menuTitle
let previousChoice
do {
previousChoice = await manualFolderAsMenu(chosenPath, answer.manual_menu, newBackOption, previousChoice)
}
while(previousChoice !== false)
return chosenMenuIndex
}
// Main entry point
async function main() {
// No root allowed (todo)
exitIfRoot()
// Loading screen, display some quick info during the fun animation
distro = detectOS()
if(!loadAoEnvFile()) {
}
await unicornPortal(650)
// Main AO title screen and flavor text
clearScreen()
asciiArt()
await welcome()
// Main loop
while(await mainMenu()) {}
process.exit(0)
}
await main()