#!/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()