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
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()
|