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.
 
 
 

570 lines
19 KiB

#!/usr/bin/env node
import chalk from 'chalk'
import inquirer from 'inquirer'
import { execSync } from 'child_process'
import { detectOS, updateSoftware, createAoDirectories, installRequired, setNodeVersion } from './scripts/system.js'
import { checkAoEnvFile, aoEnv, setAoEnv, AO_ENV_FILE_PATH } from './scripts/settings.js'
import { unicornPortal, asciiArt, clearScreen, spinnerWait } from './scripts/console.js'
import { welcome, exclaim, roger, farewell } from './scripts/welcome.js'
import { printManualPage, manualFolderAsMenu, AO_MANUAL_PATH } from './scripts/manual.js'
import { isFolder, loadJsonFile } from './scripts/files.js'
import { sleep } from './scripts/util.js'
import { tests } from './scripts/tests.js'
import { headerStyle, greenChalk } from './scripts/styles.js'
import './scripts/strings.js'
// Import AO modular features
import * as features from './scripts/features/index.js'
import { aoIsInstalled } from './scripts/features/ao-server.js'
import { startPublicBootstrap } from './scripts/bootstrap.js'
import { isLoggedIn, loginPrompt, logout } from './scripts/session.js'
import { AO_DEFAULT_HOSTNAME } from './scripts/api.js'
import { getTopPriorityText } from './scripts/priority.js'
import { cardMenu } from './scripts/cards.js'
// These should become .env variables that are loaded intelligently
let distro
let memberName
// This does not work
function exitIfRoot() {
try {
execSync('if [ "$EUID" -eq 0 ] then echo 1')
console.log(`${chalk.red.bold(exclaim())} 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) {}
}
// Prints the AO Main Menu and executes the user's choice
async function mainMenu() {
console.log(`\n${headerStyle('AO Main Menu')}\n`)
let mainMenuChoices = [
'AO',
'Alchemy',
'Configure',
'Manual',
'Exit'
]
const answer = await inquirer.prompt({
name: 'main_menu',
type: 'list',
message: 'Please choose:',
choices: mainMenuChoices,
pageSize: mainMenuChoices.length
})
let previousChoice
switch(answer.main_menu) {
case 'AO':
while(await useAoMenu()) {}
break
case 'Configure':
do {
previousChoice = await featuresMenu(previousChoice)
} while(previousChoice !== false)
break
case 'Alchemy':
while(await adminMenu()) {}
break
case 'Manual':
if(!isFolder(AO_MANUAL_PATH)) {
console.log("Downloading the AO manual...")
if(features.manual.install()) {
console.log("Downloaded the AO Manual from the official git repo via http and saved to", AO_MANUAL_PATH + '.')
} else {
console.log('Failed to download the AO manual, sorry.')
return false
}
} else {
features.manual.update()
}
await printManualPage(AO_MANUAL_PATH) // Fencepost case - print overview page
do {
previousChoice = await manualFolderAsMenu(AO_MANUAL_PATH, 'AO User Manual', 'Back to Main Menu', previousChoice + 1)
} while(previousChoice !== false)
break
case 'Exit':
farewell()
await sleep(310)
return false
}
return true
}
// Prints the Use AO Menu and executes the user's choice
async function useAoMenu() {
const loggedIn = isLoggedIn()
console.log(`\n${headerStyle('AO')}\n`)
if(loggedIn) {
console.log('Logged in as:', aoEnv('AO_CLI_SESSION_USERNAME'))
const topPriority = await getTopPriorityText()
if(topPriority) {
console.log('Top priority:', topPriority)
} else {
console.log('Error contacting server, is your AO server running? AO features might not work.')
}
}
let aoMenuChoices = []
if(loggedIn) {
aoMenuChoices.push(
'Deck',
'Chat',
)
}
aoMenuChoices.push(
loggedIn ? 'Log Out' : 'Log In',
'Back to Main Menu'
)
const answer = await inquirer.prompt({
name: 'ao_menu',
type: 'list',
message: 'Please choose:',
choices: aoMenuChoices,
pageSize: aoMenuChoices.length
})
switch(answer.ao_menu) {
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'])
while(await cardMenu()) {}
break
case 'Chat':
while(await chatMenu()) {}
break
case 'Log In':
console.log('\nao-cli will use the AO API to log into the AO server at', (aoEnv('AO_CLI_TARGET_HOSTNAME') || AO_DEFAULT_HOSTNAME) + '.')
await loginPrompt()
break
case 'Log Out':
await logout()
//await spinnerWait('Logging out...')
break
case 'Back to Main Menu':
return false
}
return true
}
// Prints a menu that allows you to join the global AO chatrooms
let publicBootstrapStarted = false
async function chatMenu() {
let answers = {}
const PUBLIC_BOOTSTRAP_ENABLED = aoEnv('PUBLIC_BOOTSTRAP_ENABLED')
if(PUBLIC_BOOTSTRAP_ENABLED) {
// They previously enabled public bootstrapping, so check to make sure it is working and then hide the option
// todo: start and then verify functioning of p2p boostrap method here
// if it's already started don't start it again
if(!publicBootstrapStarted) {
console.log("\nBootstrapping public AO swarm...")
startPublicBootstrap()
console.log("Bootstrapped (just kidding)")
publicBootstrapStarted = true
}
//answers['chat_menu'] = 'Enable p2p bootstrap'
}
const publicBootstrapMenuItem = PUBLIC_BOOTSTRAP_ENABLED ? 'Disable p2p bootstrap' : 'Enable p2p bootstrap'
const chatChoices = [
publicBootstrapMenuItem,
'Join public chatroom',
'Join chatroom',
'Address Book',
'Back to Main Menu',
]
console.log(`\n${headerStyle('AO Public Chatrooms')}\n`)
const answer = await inquirer.prompt({
name: 'chat_menu',
type: 'list',
message: 'Please choose:',
choices: chatChoices
})
switch(answer.chat_menu) {
case 'Enable p2p bootstrap':
console.log('In order to join AO public chatrooms, AO uses the hyperswarm protocol. Joining hyperswarm may expose your IP address to other users. (For high-security installations, don\'t use public bootstrap: you can still add tor addresses to your address book manually and join those chatrooms by name.)')
setAoEnv('PUBLIC_BOOTSTRAP_ENABLED', true)
//message: Type \'public\' and press Enter to enable:')
break
case 'Disable p2p bootstrap':
setAoEnv('PUBLIC_BOOTSTRAP_ENABLED', false)
console.log(roger(), 'Disabled public bootstrapping.')
if(publicBootstrapStarted) {
// stop the bootstrap thing here
publicBootstrapStarted = false
}
break
case 'Join public chatroom':
console.log('Not yet implemented')
break
case 'Join chatroom':
console.log('Not yet implemented')
break
case 'Address Book':
console.log('The point of this address book is to make it possible to type short, one-word names and have them resolve to tor addresses.')
console.log('Name a piece of data by saying name=data. For example, doge=5uc41o1...onion. Then \'doge\' will return the .onion address.')
console.log('Querying with any synonym in a chain will return the final meanings they all point to.')
console.log('Keys can have multiple values.')
break
case 'Back to Main Menu':
return false
}
return true
}
// Prints the AO Admin Menu and executes the user's choice
// Maybe Alchemy menu should be installation and update, and admin menu should be more configuration & AO member admin
async function adminMenu() {
console.log(`\n${headerStyle('System Alchemy')}`)
const adminChoices = [
'Update system software',
'AO install wizard',
'Switch AO target server',
'Import/export state/decks',
'Watch logs now',
'Tests',
'Update remote AOs',
'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 'Update system software':
updateSoftware()
break
case 'AO install wizard':
await aoInstallWizard()
break
case 'Install other AO version':
await chooseAoVersion()
break
case 'Switch AO target server':
case 'Import/Export state/decks':
case 'Watch logs now':
case 'Update remote AOs':
console.log("Not yet implemented.")
break
case 'Tests':
while(await testsMenu()) {}
break
default:
return false
}
return true
}
// Friendly interactive install wizard walks you through the entire process of installing and configuring a version of the AO and its features
async function aoInstallWizard() {
asciiArt('AO Installer')
console.log('Welcome to the AO installer. The Coalition of Invisible Colleges is currently in licensing negotiations between the Autonomous Organization and Zen to acquire rights to display Zen\'s welcome text here, which is better than this irony.')
const level = await chooseInstallLevel()
if(!level) {
console.log('Install canceled.')
return
}
console.log('Proceeding with', level, 'installation.')
const version = await chooseAoVersion()
if(!version) {
console.log('Install canceled.')
return
}
// Ask them how they would like to host the AO (private on this computer only, public via tor only, public website via HTTPS/SSL)
updateSoftware()
createAoDirectories()
installRequired()
setNodeVersion()
if(!aoIsInstalled(version)) {
installAo(version)
}
//configureAO() // set required ENV variables (are any still required? make all optional?)
if(level === 'standard' || level === 'full') {
//if(!features.bitcoin.isInstalled()) features.bitcoin.install()
//if(!features.lightning.isInstalled()) features.lightning.install()
console.log('Skipping manual, tor, bitcoin, lightning, jitsi, and configuration of themes, glossary, jubilee (coming soon)')
}
if(level === 'full') {
console.log('Skipping youtube-dl, Signal, borg (coming soon)') //maybe can just loop through all feature modules here
}
console.log('Skipping SSL/Certbot (coming soon)') // Ask them at the beginning but do it here
console.log('The AO is installed.')
}
// Asks if the user wants to do a minimal, standard, or full install and returns their answer
async function chooseInstallLevel() {
const answer = await inquirer.prompt({
name: 'level_menu',
type: 'list',
message: 'What kind of installation?',
choices: [
{ name: 'Minimal'.padEnd(11) + 'only core AO web server', value: 'minimal', short: 'minimal install' },
{ name: 'Standard'.padEnd(11) + 'most AO features installed (recommended)', value: 'standard', short: 'standard install' },
{ name: 'Full'.padEnd(11) + 'all AO features installed', value: 'full', short: 'full install' },
{ name: 'Cancel', value: false }
]
})
return answer.level_menu
}
// Detects which version(s) of the AO are installed (ao-3, ao-react, or ao-v)
// todo: Maybe should move this to an option under the ao-server feature menu
function detectAoVersion() {
return aoEnv(AO_VERSION)
}
// Asks whether the user wants to install ao-svelte, ao-3, or ao-cli (only) and returns their choice
async function chooseAoVersion() {
console.log(`\n${headerStyle('Choose AO Version')}`)
console.log('Active version:', aoEnv('AO_VERSION'))
const answer = await inquirer.prompt({
name: 'version_menu',
type: 'list',
message: 'Please choose:',
choices: [
{ name: 'ao-svelte'.padEnd(12) + 'new and mobile-first, currently in prototype phase', value: 'ao-svelte', short: 'ao-svelte' },
{ name: 'ao-3'.padEnd(12) + 'the original, created in Vue 3, polished and bug-free', value: 'ao-3', short: 'ao-3' },
{ name: 'ao-cli only'.padEnd(12), value: 'ao-cli' },
'Cancel'
]
})
if(answer.version_menu === 'Cancel') {
return false
}
setAoEnv('AO_VERSION', answer.version_menu)
// todo: If the version has changed, install it now
console.log('AO version choice saved.')
return answer.version_menu
}
// 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(tests).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 = tests[answer.tests_menu]
if(testFunction) await testFunction()
return true
}
// Returns a colored capitalized status word
const styledStatus = (fullWord) => {
const lookup = {
off: ' ' + chalk.grey('Off') + ' ',
installed: chalk.blue('Installed') + ' ',
enabled: ' ' + greenChalk('Enabled') + ' ',
running: ' ' + greenChalk('Running') + ' ',
synced: ' ' + greenChalk('Synced') + ' ',
error: ' ' + chalk.red('Error') + ' '
}
return lookup[fullWord.toLowerCase()] + ' '
}
// Prints the Configure AO Features menu and executes the user's choice
let featuresChoices
async function featuresMenu(previousMenuChoice = 0) {
console.log(`\n${headerStyle('Configure AO Features')}`)
if(!featuresChoices) {
featuresChoices = Object.entries(features).map(([featureKey, feature]) => {
let featureName = featureKey
if(feature.hasOwnProperty('name') && feature.name.length >= 1) {
featureName = feature.name
}
const nameColumn = featureName.padEnd(17)
const statusColumn = styledStatus(feature.status())
const descriptionColumn = feature.description || ''
const choice = { name: nameColumn + statusColumn + descriptionColumn, value: featureKey, short: featureKey}
return choice
})
featuresChoices.push(
'Back to Main Menu'
)
}
const answer = await inquirer.prompt({
name: 'features_menu',
type: 'list',
message: 'Please choose:',
choices: featuresChoices,
default: previousMenuChoice,
pageSize: featuresChoices.length
})
if(answer.features_menu === 'Back to Main Menu') {
return false
}
const chosenFeature = features[answer.features_menu]
const chosenFeatureName = (chosenFeature.hasOwnProperty('name') && chosenFeature.name.length >= 1) ? chosenFeature.name : answer.features_menu
while(await oneFeatureMenu(chosenFeatureName, chosenFeature)) {}
return answer.features_menu
}
// Prints the menu options for a specific feature.
// Each feature module can export functions for status, install, version, update and these will be listed in the menu based on context
// If the module also has a menu: field, these menu items will each be appended if the name is truthy when calculated
// Prints all the standard-named features plus features listed under 'menu'
async function oneFeatureMenu(name, feature) {
console.log(`\n${headerStyle(name)}`)
const featureChoices = []
const status = feature?.status() || false
if(!status) {
console.log("This AO feature module lacks a status() function, not sure which menu items to display.")
return false
}
if(status === 'off') {
if(feature.hasOwnProperty('install')) {
featureChoices.push({ name: 'Install ' + name, value: 'install' })
menuItemCount++
}
} else {
if(feature.hasOwnProperty('update')) {
featureChoices.push({ name: 'Update ' + name, value: 'update'})
}
if(feature.hasOwnProperty('uninstall')) {
featureChoices.push({ name: 'Uninstall ' + name, value: 'uninstall'})
}
}
if(feature.hasOwnProperty('menu')) {
feature.menu.forEach(menuItem => {
const menuItemName = typeof menuItem.name === 'function' ? menuItem.name() : menuItem.name
if(!menuItemName) {
return
}
const menuItemValue = typeof menuItem.value === 'function' ? menuItem.value() : menuItem.value
// todo: uninstall option will go here also
featureChoices.push({ name: menuItemName, value: menuItemValue })
})
}
if(featureChoices.length < 1) {
console.log("Nothing to do yet on this feature, please check back soon.")
return false
}
featureChoices.push(
'Back to Features'
)
const answer = await inquirer.prompt({
name: 'feature_menu',
type: 'list',
message: 'Please choose:',
choices: featureChoices
})
if(answer.feature_menu === 'Back to Features') {
return false
}
if(Object.keys(feature).includes(answer.feature_menu)) {
await feature[answer.feature_menu]()
return true
}
console.log('Not yet implemented')
return true
}
// Prints the AO Admin Menu and executes the user's choice
async function aoInstallMenu() {
console.log(`\n${headerStyle('Alchemy')}`)
const aoServerChoices = [
{ name: 'Install AO prerequisites', value: 'prereqs' },
'Check AO install',
'Update AO',
'Check AO service',
'Start/Stop AO service',
'Switch AO database',
'Switch AO version',
'Back to Main Menu'
]
const answer = await inquirer.prompt({
name: 'install_menu',
type: 'list',
message: 'Please choose:',
choices: aoServerChoices,
pageSize: aoServerChoices.length
})
switch(answer.install_menu) {
case 'prereqs':
installRequired()
break
case 'Check AO install':
case 'Update AO':
case 'Check AO service':
case 'Start/Stop AO service':
case 'Switch AO database':
case 'Switch AO version':
console.log("Not yet implemented.")
return true
default:
return false
}
return true
}
// 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
})
}
// Returns false if a flag means the program should now terminate
// -v Print version info
async function handleArgs(args) {
switch (args[0]) {
case '--version':
case '-v':
console.log(await features['ao-cli'].version())
return false
}
return true
}
// Main entry point
async function main() {
// Print version info etc. no matter what
const nodePath = process.argv[0]
const aoCliPath = process.argv[1]
const args = process.argv.slice(2)
let shouldTerminate = !await handleArgs(args)
if(shouldTerminate) {
process.exit(0)
}
// No root allowed (todo)
exitIfRoot()
// Loading screen, display some quick info during the fun animation
distro = aoEnv('DISTRO')
if(!distro) {
distro = detectOS()
setAoEnv('DISTRO', distro)
}
if(checkAoEnvFile()) {
console.log('AO .env file exists at', AO_ENV_FILE_PATH)
} else {
console.log('AO .env file does not exist at', AO_ENV_FILE_PATH)
}
await unicornPortal(650)
// Main AO title screen and flavor text
clearScreen()
asciiArt()
await welcome()
// Main loop
while(await mainMenu()) {}
process.exit(0)
}
await main()