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.
711 lines
24 KiB
711 lines
24 KiB
#!/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()
|
|
|