diff --git a/README.md b/README.md index 4cefed9..0e16459 100644 --- a/README.md +++ b/README.md @@ -18,11 +18,13 @@ These features work right now: * Browse the [AO User Manual](https://git.coalitionofinvisiblecolleges.org/autonomousorganization/ao-manual) and automatically download and keep it updated * Manages your AO configuration file for you +* Interactive install wizard install the AO for you * Wraps the functionality of (some of) Zen's Alchemy suite of scripts (system configuration, AO installation) * `ao-cli` can self-update to the newest version * Run AO unit tests to verify the up-to-spec functioning of the system's running AO API server * Easily view installed/running status of optional AO features * Add `ao` alias for `ao-cli` (under Features→ao-cli) +* Detects your OS, with support for Debian/Ubuntu, Arch/Manjaro, and Fedora (MacOS planned) ## Upcoming Features @@ -30,7 +32,7 @@ These features are planned and many are mocked up in the menus: * Join the AO .onion bootstrapping network and find public AO chatrooms p2p over tor * Easily install/uninstall and turn on/off optional AO features -* Operate essential AO client features (like creating and sending cards p2p via tor) +* Operate essential AO client features (view and create priorities, create and send cards p2p via tor) * Easily install and configure your AO server installation * Easily use hardware-owner-only god-mode features for your AO server including resetting any password or deleting any member * Easily monitor your AO server status and start/stop the service @@ -54,6 +56,8 @@ These features are planned and many are mocked up in the menus: ## Version History +* 0.1.1 AO install wizard partway done, reorganized project repos, 'Check AO install' feature works +* 0.1.0 View, create or recall, upboat and downboat priorities; partial AO install wizard * 0.0.9 Features menu loaded from module file for each feature; view top priority; menu cleanup * 0.0.8 Added self-update feature and --version/-v arg * 0.0.6 User manual downloads and updates automatically from [official ao-manual repo](https://git.coalitionofinvisiblecolleges.org/autonomousorganization/ao-manual) diff --git a/index.js b/index.js index 670d780..56f8fed 100644 --- a/index.js +++ b/index.js @@ -2,40 +2,26 @@ import chalk from 'chalk' import inquirer from 'inquirer' import { execSync } from 'child_process' -import { detectOS, updateSoftware, createAoDirectories, installRequired, setNodeVersion } from './scripts/system.js' + +// Import ao-cli core features +import { exitIfRoot, detectOS, updateSoftware } 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 { unicornPortal, asciiArt, clearScreen } from './scripts/console.js' +import { welcome, exclaim, farewell, yesOrNo } from './scripts/welcome.js' +import useAoMenu from './scripts/ao.js' +import aoInstallWizard, { chooseAoVersion, checkAo } from './scripts/wizard.js' +import testsMenu from './scripts/tests.js' +import { headerStyle } from './scripts/styles.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' +import { featuresMenu } from './scripts/features/index.js' +import manual, { printManualPage, manualFolderAsMenu, AO_MANUAL_PATH } from './scripts/features/manual.js' +import aoCli from './scripts/features/ao-cli.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) {} -} +// Enable keyboard shortcut interruption of inquirer menus +import './scripts/keyboard.js' // Prints the AO Main Menu and executes the user's choice async function mainMenu() { @@ -47,37 +33,45 @@ async function mainMenu() { 'Manual', 'Exit' ] - const answer = await inquirer.prompt({ - name: 'main_menu', - type: 'list', - message: 'Please choose:', - choices: mainMenuChoices, - pageSize: mainMenuChoices.length - }) + let answer + try { + answer = await inquirer.prompt({ + name: 'main_menu', + type: 'list', + message: 'Please choose:', + choices: mainMenuChoices, + pageSize: mainMenuChoices.length + }) + } catch(error) { + if (error === 'EVENT_INTERRUPTED') { + console.log('\nESC') + answer = { main_menu: 'Exit' } + } + } let previousChoice switch(answer.main_menu) { case 'AO': while(await useAoMenu()) {} break + case 'Alchemy': + while(await adminMenu()) {} + 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)) { + if(!manual.isInstalled()) { console.log("Downloading the AO manual...") - if(features.manual.install()) { + if(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() + manual.update() } await printManualPage(AO_MANUAL_PATH) // Fencepost case - print overview page do { @@ -92,152 +86,53 @@ async function mainMenu() { 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 = [ + { name: 'Check AO install', value: 'check_AO' }, 'Update system software', + //'Update AO', + //{ name: 'Install AO prerequisites', value: 'prereqs' }, // move to feature module? calls installRequired() 'AO install wizard', - 'Switch AO target server', - 'Import/export state/decks', - 'Watch logs now', + //'Switch AO target server', + //'Switch AO database', + 'Switch AO version', + //'Check AO service', + //'Start/Stop AO service', + //'Import/export state/decks', + //'Watch logs now', 'Tests', - 'Update remote AOs', + //'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, - }) + let answer + try { + answer = await inquirer.prompt({ + name: 'admin_menu', + type: 'list', + message: 'Please choose:', + choices: adminChoices, + pageSize: adminChoices.length, + }) + } catch(error) { + if (error === 'EVENT_INTERRUPTED') { + console.log('\nESC') + return false + } + } switch(answer.admin_menu) { + case 'check_AO': + await checkAo() + break case 'Update system software': updateSoftware() break case 'AO install wizard': await aoInstallWizard() break - case 'Install other AO version': + case 'Switch AO version': await chooseAoVersion() break case 'Switch AO target server': @@ -255,276 +150,13 @@ async function adminMenu() { 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()) + console.log(await aoCli.version()) return false } return true @@ -536,19 +168,18 @@ async function main() { const nodePath = process.argv[0] const aoCliPath = process.argv[1] const args = process.argv.slice(2) - let shouldTerminate = !await handleArgs(args) + const shouldTerminate = !await handleArgs(args) if(shouldTerminate) { process.exit(0) } - // No root allowed (todo) + // No root allowed exitIfRoot() // Loading screen, display some quick info during the fun animation - distro = aoEnv('DISTRO') - if(!distro) { - distro = detectOS() - setAoEnv('DISTRO', distro) + const DISTRO = aoEnv('DISTRO') + if(!DISTRO) { + setAoEnv('DISTRO', detectOS()) } if(checkAoEnvFile()) { console.log('AO .env file exists at', AO_ENV_FILE_PATH) @@ -557,6 +188,19 @@ async function main() { } await unicornPortal(650) + if(!aoEnv('AO_FIRST_RUN')) { + clearScreen() + asciiArt() + console.log('\n' + exclaim(), 'It looks like this is the first time you\'re running ao-cli on this computer! ao-cli stands for Autonomous Organization-Command Line Interface.\n\nWould you like to run the AO install wizard? It will walk you through setting up an AO server instance so you can use the AO on this computer as a full node. (Press Ctrl-C at any time to exit.)\n') + const pleaseInstall = await yesOrNo('Start the AO install wizard?', true) + setAoEnv('AO_FIRST_RUN', true) + if(pleaseInstall) { + await aoInstallWizard() + } else { + console.log('You can start the AO install wizard at any time from the Configure menu.') + } + } + // Main AO title screen and flavor text clearScreen() asciiArt() diff --git a/package-lock.json b/package-lock.json index 441aa72..9033206 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,6 +17,7 @@ "gradient-string": "^2.0.1", "hash.js": "^1.1.7", "inquirer": "^8.2.4", + "inquirer-interrupted-prompt": "^1.0.2", "marked": "^4.0.16", "marked-terminal": "^5.1.1", "nanospinner": "^1.1.0", @@ -922,6 +923,11 @@ "node": ">=12.0.0" } }, + "node_modules/inquirer-interrupted-prompt": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/inquirer-interrupted-prompt/-/inquirer-interrupted-prompt-1.0.2.tgz", + "integrity": "sha512-9RPmteKpUzUzwRW9nplHOWe6LAp4d5rE/5Yr3BdgpRspGN/ORXSUuIjEEJiwpYpRYMaVSC4wcsYMS5Gzic8egw==" + }, "node_modules/inquirer/node_modules/chalk": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", @@ -2785,6 +2791,11 @@ } } }, + "inquirer-interrupted-prompt": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/inquirer-interrupted-prompt/-/inquirer-interrupted-prompt-1.0.2.tgz", + "integrity": "sha512-9RPmteKpUzUzwRW9nplHOWe6LAp4d5rE/5Yr3BdgpRspGN/ORXSUuIjEEJiwpYpRYMaVSC4wcsYMS5Gzic8egw==" + }, "is-arrayish": { "version": "0.2.1", "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", diff --git a/package.json b/package.json index 59a56b7..8507e77 100644 --- a/package.json +++ b/package.json @@ -2,6 +2,10 @@ "name": "@autonomousorganization/ao-cli", "version": "0.1.0", "description": "An interactive command-line interface (CLI) tool to help you install, use, and administer an AO instance.", + "repository": { + "type": "git", + "url": "http://git.coalitionofinvisiblecolleges.org:3009/autonomousorganization/ao-cli.git" + }, "main": "index.js", "bin": "./index.js", "type": "module", @@ -30,6 +34,7 @@ "gradient-string": "^2.0.1", "hash.js": "^1.1.7", "inquirer": "^8.2.4", + "inquirer-interrupted-prompt": "^1.0.2", "marked": "^4.0.16", "marked-terminal": "^5.1.1", "nanospinner": "^1.1.0", diff --git a/scripts/ao.js b/scripts/ao.js new file mode 100644 index 0000000..1b57199 --- /dev/null +++ b/scripts/ao.js @@ -0,0 +1,133 @@ +// ao-cli includes an AO client that makes calls to a locla or remote AO server. +// The ao-cli client has no store so it makes frequent (hopefully very quick) calls to get exactly the information it needs. +// This requires us to make sure the AO API server's REST API is concise and efficient. +// The only places ao-cli should call out to an AO server (i.e., use the API in api.js) are in this Use AO menu and in the Tests menu. +import inquirer from 'inquirer' +import { aoEnv } from './settings.js' +import { isLoggedIn, loginPrompt, logout } from './session.js' +import { getTopPriorityText } from './priority.js' +import { startPublicBootstrap } from './bootstrap.js' +import { AO_DEFAULT_HOSTNAME } from './api.js' +import { cardMenu } from './cards.js' +import { connectMenu } from './connect.js' +import { headerStyle } from './styles.js' + +// Prints the Use AO Menu and executes the user's choice. Using the AO as a client occurs only under this menu item (except Tests menu). +export default 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', + 'Connect', + ) + } + aoMenuChoices.push( + loggedIn ? 'Log Out' : 'Log In', + 'Back to Main Menu' + ) + let answer + try { + answer = await inquirer.prompt({ + name: 'ao_menu', + type: 'list', + message: 'Please choose:', + choices: aoMenuChoices, + pageSize: aoMenuChoices.length + }) + } catch(error) { + if (error === 'EVENT_INTERRUPTED') { + console.log('\nESC') + return false + } + } + 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 'Connect': + while(await connectMenu()) {} + 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 +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)") + } + //answers['chat_menu'] = 'Enable p2p bootstrap' + } + const chatChoices = [ + { name: 'Join public chatroom', value: 'browse_chatrooms', short: 'public chatrooms' }, + { name: 'Join chatroom', value: 'join_chat', short: 'join chat' }, + 'Address Book', + 'Back to AO Menu', + ] + console.log(`\n${headerStyle('AO Public Chatrooms')}\n`) + let answer + try { + answer = await inquirer.prompt({ + name: 'chat_menu', + type: 'list', + message: 'Please choose:', + choices: chatChoices + }) + } catch(error) { + if (error === 'EVENT_INTERRUPTED') { + console.log('\nESC') + return false + } + } + switch(answer.chat_menu) { + case 'browse_chatrooms': + console.log('Not yet implemented') + break + case 'join_chat': + 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 AO Menu': + return false + } + return true +} \ No newline at end of file diff --git a/scripts/api.js b/scripts/api.js index 62b9cce..9dfc4b6 100644 --- a/scripts/api.js +++ b/scripts/api.js @@ -22,8 +22,25 @@ let currentMemberId = aoEnv('AO_CLI_SESSION_MEMBERID') let currentSessionId = aoEnv('AO_CLI_SESSION_ID') let currentSessionToken = aoEnv('AO_CLI_SESSION_TOKEN') -// Performs a post request to the specified endpoint, sending the given payload -export async function postRequest(endpoint, payload, verbose = true) { +// Performs a GET request to the specified endpoint, sending the given payload +export async function getRequest(endpoint, payload = null, alternateHost = null, verbose = true) { + const target = alternateHost || HOSTNAME + try { + if(payload) { + return await request + .get(target + endpoint) + .send(payload) + } else { + return await request.get(target + endpoint) + } + } catch (err) { + if(verbose) console.log('request failed', err) + return null + } +} + +// Performs a POST request to the specified endpoint, sending the given payload +export async function postRequest(endpoint, payload = null, verbose = true) { if (!currentSessionToken) { if(verbose) console.log('Session token not set, API not ready.') return new Promise(() => null) @@ -81,6 +98,34 @@ export async function nameAo(newName) { }) } +// Requests the public bootstrap list by making a public (not logged in) GET request to this or the specified server +export async function getAoBootstrapList(serverOnion = null) { + const result = await getRequest('/bootstrap', undefined, serverOnion, false) + if(!result || !result.ok || result.body.addresses.length < 1) { + return null + } + return result.body.addresses +} + +// Gets the bootsrap list from the specified or our server, then recursively bootstraps from each address on the list +// The AO network is small right now so this shouldn't cause any problems for a while +export async function bootstrap(serverOnion = null) { + if(!serverOnion) serverOnion = HOSTNAME + let alreadyQueried = [ serverOnion ] + let onionList = await getAoBootstrapList(serverOnion) + if(!onionList) { + return null + } + for(let i = 0; i < onionList.length; i++) { + const onion = onionList[i] + let more = await bootstrap(onion) + if(!more) continue + more = more.filter(onion => !onionList.concat(alreadyQueried).includes(onion)) + onionList.concat(more) + } + return onionList +} + export async function connectToAo(address, secret) { return await postEvent({ type: 'ao-outbound-connected', diff --git a/scripts/cards.js b/scripts/cards.js index ee7f67a..43c2c18 100644 --- a/scripts/cards.js +++ b/scripts/cards.js @@ -1,8 +1,8 @@ // Cards module - everything related to cards should go here (database install is automatic for AO server so no feature module) import inquirer from 'inquirer' -import { aoEnv } from './settings.js' -import { getCard, getCardByName, createCard, prioritizeCard, completeCard, uncheckCard, refocusCard } from './api.js' +import { getCardByName, createCard, prioritizeCard } from './api.js' import { headerStyle } from './styles.js' +import { prioritiesMenu } from './priority.js' // The card menu is complex and so has been split into this separate file export async function cardMenu() { @@ -13,157 +13,32 @@ export async function cardMenu() { { name: 'Browse full deck', value: 'browse', short: 'browse' }, // archive? (10,000) 'Back to AO Menu' ] - const answer = await inquirer.prompt({ - name: 'card_menu', - type: 'list', - message: 'Please choose:', - choices: cardChoices, - pageSize: cardChoices.length, - }) - switch(answer.card_menu) { - case 'priorities': - while(await prioritiesMenu()) {} - break - case 'subcards': - while(await subcardsMenu()) {} - break - case 'browse': - while(await browseMenu()) {} - break - default: - return false - } - return true -} - -// Displays the priorities of the given taskId in a menu. Selecting a card shows a menu for that card. If taskId is null, member card is used. -async function prioritiesMenu(taskId = null) { - console.log(`\n${headerStyle('My Priorities')}`) - let prioritiesChoices = [] - - const memberId = aoEnv('AO_CLI_SESSION_MEMBERID') - if(!memberId) { - console.log('Not logged in.') - return false - } - if(!taskId) { - // Get the priorities of my member card - taskId = memberId - } - const fetchedCards = await getCard(taskId, 'priorities') - if(!fetchedCards || fetchedCards.length < 1) { - console.log('Failed to fetch member card, this is bad.') - return false - } - const card = fetchedCards[0] - const priorityCards = fetchedCards.slice(1) // First card is member card itself - let priorities = card.priorities.slice() - priorities.reverse() - console.log('You have', priorityCards.length, 'priorities:') - prioritiesChoices = priorities.map((priorityTaskId, i) => { - const priorityCard = priorityCards.find(p => p.taskId === priorityTaskId) - if(!priorityCard) { - return 'Missing card, repair your database' - } - return { - name: priorityCard.name, - value: { index: i, card: priorityCard }, - short: priorityCard.name.substring(0, 70) + priorityCard.name.length >= 70 ? '...' : '' - } - }) - prioritiesChoices.push( - { name: 'Create priority', value: 'create_here', short: 'new priority' }, - { name: 'Back to AO Menu', value: false, short: 'back' } - ) - const answer = await inquirer.prompt({ - name: 'priorities_menu', - type: 'rawlist', - message: 'Please choose:', - choices: prioritiesChoices, - loop: false - }) - switch(answer.priorities_menu) { - case false: - return false - case 'create_here': - let previousCardCreatedText - do { - console.log('previousCardCreatedText is', previousCardCreatedText) - previousCardCreatedText = await createCardInteractive() - } while(previousCardCreatedText != '\n') - return true - case 'Missing card, repair your database': - console.log('Database repair yet implemented, sorry.') - return true - } - let chosenTask = answer.priorities_menu.card - const chosenTaskId = chosenTask.taskId - let previousAnswer - do { - previousAnswer = await priorityCardMenu(chosenTask, answer.priorities_menu.index) - if(previousAnswer) { - const fetchedCards = await getCard(chosenTaskId, false) - if(!fetchedCards || fetchedCards.length < 1) { - console.log('The card has disappeared. Maybe it was deleted, or cards held by no one are automatically cleaned up every five minutes.') + try { + const answer = await inquirer.prompt({ + name: 'card_menu', + type: 'list', + message: 'Please choose:', + choices: cardChoices, + pageSize: cardChoices.length, + }) + switch(answer.card_menu) { + case 'priorities': + while(await prioritiesMenu()) {} + break + case 'subcards': + while(await subcardsMenu()) {} + break + case 'browse': + while(await browseMenu()) {} + break + default: return false - } - chosenTask = fetchedCards[0] } - } while(previousAnswer !== false) - console.log('Card menu not yet implemented.') - return true -} - -// Short action-oriented menu for cards in the priorities list -// Index is the position of the card in the list that it is in, used for fencepost case to display upboat contextually -async function priorityCardMenu(card, index) { - if(!card) { - console.log('priorityCardMenu: card is required.') - return false - } - const taskId = card.taskId - const memberId = aoEnv('AO_CLI_SESSION_MEMBERID') - if(!memberId) { - console.log('Not logged in.') - return false - } - const isChecked = card.claimed.includes(memberId) - console.log(`\n${headerStyle('Priority: ' + card.name)}`) - let priorityChoices = [] - if(index != 0) { - priorityChoices.push({ name: 'Upboat', value: 'upboat', short: 'upboat' }) - } - priorityChoices.push( - { name: isChecked ? 'Uncheck' : 'Check off', value: 'check', short: 'check!' }, - { name: 'Downboat', value: 'downboat', short: 'downboat' }, - //{ name: 'Browse within', value: 'browse', short: 'browse' } - { name: 'Back to Priorities', value: false, short: 'back' } - ) - const answer = await inquirer.prompt({ - name: 'priority_card_menu', - type: 'list', - message: 'Please choose:', - choices: priorityChoices, - pageSize: priorityChoices.length, - }) - switch(answer.priority_card_menu) { - case 'check': - if(isChecked) { - await uncheckCard(taskId) - } else { - await completeCard(taskId) - } - break - case 'upboat': - await prioritizeCard(taskId, memberId) - return false - case 'downboat': - await refocusCard(taskId, memberId) - return false - case 'browse': - break - default: + } catch(error) { + if (error === 'EVENT_INTERRUPTED') { + console.log('\nESC') return false + } } return true } @@ -209,4 +84,4 @@ async function createCardInteractive(prioritized = true) { console.log('card does not exist yet. creating...', answer.new_card_text) const result = await createCard(answer.new_card_text, false, true) return answer.new_card_text -} \ No newline at end of file +} diff --git a/scripts/connect.js b/scripts/connect.js new file mode 100644 index 0000000..ab4277f --- /dev/null +++ b/scripts/connect.js @@ -0,0 +1,215 @@ +// Each AO API server can connect peer-to-peer over Tor. Tor addresses are unique and data is end-to-end encrypted. +import inquirer from 'inquirer' +import { headerStyle } from './styles.js' +import { aoEnv, setAoEnv } from './settings.js' +import { isLoggedIn } from './session.js' +import { isInstalled } from './features/tor.js' +import { connectToAo, getAoBootstrapList, bootstrap } from './api.js' +import { roger } from './welcome.js' + +// Prints a menu to connect your AO to other AOs and manage connections +export async function connectMenu() { + console.log(`\n${headerStyle('AO P2P')}`) + const PUBLIC_BOOTSTRAP_ENABLED = aoEnv('PUBLIC_BOOTSTRAP_ENABLED') + let publicBootstrapMenuItem = { name: 'Enable p2p bootstrap', value: 'enable_bootstrap' } + if(PUBLIC_BOOTSTRAP_ENABLED) { + publicBootstrapMenuItem = { name: 'Disable p2p bootstrap', value: 'disable_bootstrap' } + } + const connectChoices = [ + { name: 'Connect to AO', value: 'connect', short: 'p2p connect'}, + { name: 'View connections', value: 'connections' }, + { name: 'Bootstrap now', value: 'bootstrap' }, + publicBootstrapMenuItem, + { name: 'Back to AO Menu', value: false } + ] + let answer + try { + answer = await inquirer.prompt({ + name: 'connect_menu', + type: 'list', + message: 'Please choose:', + choices: connectChoices, + pageSize: connectChoices.length, + }) + } catch(error) { + if (error === 'EVENT_INTERRUPTED') { + console.log('\nESC') + return false + } + } + switch(answer.connect_menu) { + case 'connect': + await connectInteractive() + break + case 'connections': + const onionList = await getAoBootstrapList() + console.log('The AO server has connections to:') + console.log(onionList.join('\n')) + break + case 'bootstrap': + const bootstrappedOnionList = await bootstrap() + if(!bootstrappedOnionList || bootstrappedOnionList.length < 1) { + console.log('Failed to fetch AO bootstrap server list.') + } else { + console.log('All known .onion addresses in neighborhood:') + console.log(bootstrappedOnionList.join('\n')) + } + break + case 'enable_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_bootstrap': + setAoEnv('PUBLIC_BOOTSTRAP_ENABLED', false) + console.log(roger(), 'Disabled public bootstrapping.') + break + default: + return false + } + return true +} + +// Tells the AO server you are connected to connect to the AO server at the given .onion with the given connection secret string +// Any client logged in to the AO can tell it to connect to another AO. This could be a security issue if there is a bad actor server. +async function connectInteractive() { + const loggedIn = isLoggedIn() + console.log('The AO server you are logged in to can connect peer-to-peer via tor to another AO server to join chatrooms, send cards, and sync file attachments. Tor is Tor Onion Routing, a secure, end-to-end encrypted way of routing anonymized internet traffic.') + if(!isInstalled()) { + console.log('It looks like your tor server isn\'t instaled and running. Go to Configure->Tor to set it up.') + return false + } + const memberId = aoEnv('AO_CLI_SESSION_MEMBERID') + if(!memberId) { + console.log('Not logged in.') + return false + } + console.log('To connect to another AO, you need it\'s Tor address, which ends in .onion, and its server secret. This information can be found on that AO\'s website. For convenience, it is combined in a single connection string separate by a colon. Please enter the entire string.') + const validateOnion = (onion) => { + const parts = onion.split('.') + if(parts.length != 2 || parts[0].length != 56 || parts[1] !== 'onion') { + console.log('\nInvalid onion address, an onion address is 56 chararcters followed by \'.onion\'. Press ESC to go back.') + return false + } + return true + } + const validateConnectionString = (connectionString) => { + const parts = connectionString.split(':') + if(parts.length != 2) { + console.log('Your connection string has too many or two few parts. It should have two part separated by a colon. Press ESC to go back.') + return false + } + + if(!validateOnion(parts[0])) { + return false + } + + if(parts[1].length != 64) { + console.log('The connection secret (second half of connection string) must be exactly 64 characters. Press ESC to go back.') + return false + } + return true + } + let answer + try { + answer = await inquirer.prompt({ + name: 'connection_string', + type: 'input', + message: 'Enter connection string of other AO:', + validate: validateConnectionString + }) + } catch(error) { + if (error === 'EVENT_INTERRUPTED') { + console.log('ESC') + return false + } + } + const [onion, secret] = answer.connection_string.split(':') + console.log('onion is', onion, 'and secret is', secret) + console.log('Attempting connect...') + const result = await connectToAo(onion, secret) + console.log('result is', result.body) + return true +} + +// Fetches a list of your AO server's p2p connections and displays it +async function connectionsMenu() { + console.log(`\n${headerStyle('AO P2P Connections')}`) + let connectionsChoices = [] + + const memberId = aoEnv('AO_CLI_SESSION_MEMBERID') + if(!memberId) { + console.log('Not logged in.') + return false + } + const fetchedCards = await getCard(taskId, 'priorities') + if(!fetchedCards || fetchedCards.length < 1) { + console.log('Failed to fetch member card, this is bad.') + return false + } + const card = fetchedCards[0] + const priorityCards = fetchedCards.slice(1) // First card is member card itself + let priorities = card.priorities.slice() + priorities.reverse() + console.log('You have', priorityCards.length, 'priorities:') + prioritiesChoices = priorities.map((priorityTaskId, i) => { + const priorityCard = priorityCards.find(p => p.taskId === priorityTaskId) + if(!priorityCard) { + return 'Missing card, repair your database' + } + return { + name: priorityCard.name, + value: { index: i, card: priorityCard }, + short: priorityCard.name.substring(0, 70) + priorityCard.name.length >= 70 ? '...' : '' + } + }) + prioritiesChoices.push( + { name: 'Create priority', value: 'create_here', short: 'new priority' }, + { name: 'Back to Deck', value: false, short: 'back' } + ) + let answer + try { + answer = await inquirer.prompt({ + name: 'priorities_menu', + type: 'rawlist', + message: 'Please choose:', + choices: prioritiesChoices, + loop: false + }) + } catch(error) { + if (error === 'EVENT_INTERRUPTED') { + console.log('\nESC') + return false + } + } + switch(answer.priorities_menu) { + case false: + return false + case 'create_here': + let previousCardCreatedText + do { + console.log('previousCardCreatedText is', previousCardCreatedText) + previousCardCreatedText = await createCardInteractive() + } while(previousCardCreatedText != '\n') + return true + case 'Missing card, repair your database': + console.log('Database repair yet implemented, sorry.') + return true + } + let chosenTask = answer.priorities_menu.card + const chosenTaskId = chosenTask.taskId + let previousAnswer + do { + previousAnswer = await priorityCardMenu(chosenTask, answer.priorities_menu.index) + if(previousAnswer) { + const fetchedCards = await getCard(chosenTaskId, false) + if(!fetchedCards || fetchedCards.length < 1) { + console.log('The card has disappeared. Maybe it was deleted, or cards held by no one are automatically cleaned up every five minutes.') + return false + } + chosenTask = fetchedCards[0] + } + } while(previousAnswer !== false) + console.log('Card menu not yet implemented.') + return true +} \ No newline at end of file diff --git a/scripts/console.js b/scripts/console.js index 21bfe05..f8507af 100644 --- a/scripts/console.js +++ b/scripts/console.js @@ -36,3 +36,11 @@ export async function spinnerWait(waitingMessage, doneMessage, ms = 1200) { await sleep(ms) spinner.success({ text: doneMessage || 'Done.' }) } + +// Starts a new spinner and returns a function that, when called, will stop it +export function spinner(waitingMessage = 'Please wait...', doneMessage = 'Done.') { + const spinner = createSpinner(waitingMessage).start() + return (doneMessageOverwrite = null) => { + spinner.success({text: doneMessageOverwrite || doneMessage}) + } +} \ No newline at end of file diff --git a/scripts/features/ao-cli.js b/scripts/features/ao-cli.js index 8a12622..b134185 100644 --- a/scripts/features/ao-cli.js +++ b/scripts/features/ao-cli.js @@ -3,6 +3,11 @@ import { fileURLToPath } from 'url' import path from 'path' import { loadJsonFile } from '../files.js' +// Can't include .json files without adding an experimental node flag, but we can use this workaround to use require, which works, instead +import { createRequire } from "module"; // Bring in the ability to create the 'require' method +const require = createRequire(import.meta.url); // construct the require method +const packageVersion = require("../../package.json").version + const __filename = fileURLToPath(import.meta.url) const __dirname = path.dirname(__filename) @@ -29,8 +34,7 @@ function installAoCli() { } async function getAoCliVersion() { - const packageJson = execSync('npx ao-cli -v') - return packageJson.version + return packageVersion } // Updates the globally-installed version of this package, ao-cli, using npm diff --git a/scripts/features/ao-server.js b/scripts/features/ao-server.js index c8ad1ae..08ce9cf 100644 --- a/scripts/features/ao-server.js +++ b/scripts/features/ao-server.js @@ -1,25 +1,49 @@ -import { execSync } from 'child_process' +import { execSync, exec } from 'child_process' +import { isFolder } from '../files.js' +import path from 'path' + +const AO_SERVER_PATH = path.join(process.env.HOME, 'ao-server') // Returns one of: off, installed, enabled, running, synced, error function serviceStatus() { + if(!aoIsInstalled()) { + return 'off' + } + let stdout try { - const stdout = execSync('systemctl status ao') - const isServiceRunning = stdout.includes('Active: active (running)') - if(isServiceRunning) return 'running' - else if(stdout.includes('error')) return 'error' - else if(stdout.includes('stopped')) return 'installed' + stdout = execSync('systemctl status ao') } catch(err) { - return 'error' + stdout = err.output.toString() } + if(stdout.includes('Unit ao.service could not be found.')) { + return 'installed' + } + const isServiceRunning = stdout.includes('Active: active (running)') + if(isServiceRunning) return 'running' + else if(stdout.includes('error')) return 'error' + else if(stdout.includes('stopped')) return 'installed' + else if(stdout.includes('inactive (dead')) return 'installed' return 'off' } -// Return true if the specified AO repo exists: ao-svelte or ao-3 are expected values -export function aoIsInstalled(version) { - console.log('aoIsInstalled not implemented yet') +// Downloads ao-server to ~/ao-server. Returns false if it fails, which usually means the folder already exists (update instead). +export function downloadAoServer() { + try { + execSync('git clone http://git.coalitionofinvisiblecolleges.org:3009/autonomousorganization/ao-server.git 2>&1') + } catch(err) { + switch(err.code) { + case 128: + return false + } + } return true } +// Return true if the ~/ao-server/.git folder exists +export function aoIsInstalled() { + return isFolder(path.join(AO_SERVER_PATH, '.git')) +} + function installAo(version) { if(!version) { version = aoEnv('AO_VERSION') @@ -29,11 +53,25 @@ function installAo(version) { console.log('No AO server/frontend version specified, defaulting to ao-svelte.') } } - console.log('todo: git clone the correct repo now') + downloadAoServer() +} + +export function updateAoServer() { + try { + const stdout = execSync('cd ' + AO_SERVER_PATH + ' && git pull origin main 2>&1') + if(stdout.includes('Already up to date.')) { + return + } + console.log('\nao-server was updated.') + } catch(error) { + console.log('git pull failed with error:', error) + } } export default { description: 'AO server instance on this computer', status: serviceStatus, install: installAo, + isInstalled: aoIsInstalled, + update: updateAoServer } diff --git a/scripts/features/borg.js b/scripts/features/borg.js index 046d696..addd5f4 100644 --- a/scripts/features/borg.js +++ b/scripts/features/borg.js @@ -3,5 +3,5 @@ export default { name: 'Borg', description: 'encrypted-in-transit, deduplicated incremental backup (over tor)', - status: () => 'off', -} \ No newline at end of file + status: () => null, +} diff --git a/scripts/features/certbot.js b/scripts/features/certbot.js index 03a402c..f86a360 100644 --- a/scripts/features/certbot.js +++ b/scripts/features/certbot.js @@ -1,5 +1,5 @@ export default { name: 'SSL/Certbot', description: 'HTTPS for public web AO', - status: () => 'Off', -} \ No newline at end of file + status: () => null, +} diff --git a/scripts/features/encryption.js b/scripts/features/encryption.js index 7385e24..a60c7d0 100644 --- a/scripts/features/encryption.js +++ b/scripts/features/encryption.js @@ -1,5 +1,5 @@ export default { name: 'Encryption', description: 'serverside secret messages', //encrypt messages to and from this computer', - status: () => 'off', -} \ No newline at end of file + status: () => null, +} diff --git a/scripts/features/files.js b/scripts/features/files.js index 3417f87..5cc2a92 100644 --- a/scripts/features/files.js +++ b/scripts/features/files.js @@ -1,5 +1,5 @@ export default { name: 'File hosting', description: 'file attachments on cards (sync p2p via tor with other AOs)', - status: () => 'off', -} \ No newline at end of file + status: () => null, +} diff --git a/scripts/features/glossary.js b/scripts/features/glossary.js index 73807d2..3339795 100644 --- a/scripts/features/glossary.js +++ b/scripts/features/glossary.js @@ -1,5 +1,5 @@ export default { name: 'Glossary', description: 'custom glossary', - status: () => 'off', + status: () => null, } diff --git a/scripts/features/index.js b/scripts/features/index.js index b591227..d0cff65 100644 --- a/scripts/features/index.js +++ b/scripts/features/index.js @@ -1,19 +1,173 @@ -// Import the features modules in this folder, which each add, remove, and admininster one AO feature -export { default as alchemy } from './alchemy.js' -export { default as 'ao-cli' } from './ao-cli.js' -export { default as 'ao-server' } from './ao-server.js' -export { default as bitcoin } from './bitcoin.js' -export { default as borg } from './borg.js' -export { default as certbot } from './certbot.js' -export { default as encryption } from './encryption.js' -export { default as files } from './files.js' -export { default as glossary } from './glossary.js' -export { default as jitsi } from './jitsi.js' -export { default as jubilee } from './jubilee.js' -export { default as lightning } from './lightning.js' -export { default as manual } from './manual.js' -export { default as nginx } from './nginx.js' -export { default as signal } from './signal.js' -export { default as themes } from './themes.js' -export { default as tor } from './tor.js' -export { default as 'youtube-dl' } from './youtube-dl.js' +// Re-export the features modules in this folder, which each add, remove, and admininster one AO feature (imported in project index.js) +// Also contains the Features menus to control these features in this directory +import chalk from 'chalk' +import inquirer from 'inquirer' +import fs from 'fs' +import { lsFolder } from '../files.js' +import { fileURLToPath } from 'url' +import path from 'path' +import { headerStyle, greenChalk } from '../styles.js' +import { spinner } from '../console.js' + +const __filename = fileURLToPath(import.meta.url) +const __dirname = path.dirname(__filename) + +const loadFeatures = async () => { + + let filenames = lsFolder(path.join(__dirname)) + let features = {} + for(let i = 0; i < filenames.length; i++) { + const filename = filenames[i] + if(filename === 'index.js') continue + const moduleShortname = filename.replace(/\.js$/, '') + const path = './' + filename + features[moduleShortname] = (await import(path)).default + } + return features +} + +const features = await loadFeatures() +export default features + +// Returns a colored capitalized status word +const styledStatus = (fullWord) => { + const lookup = { + unknown: ' ' + chalk.grey('Unknown') + ' ', + 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 +export async function featuresMenu(previousMenuChoice = 0) { + console.log(`\n${headerStyle('Configure AO Features')}`) + const stopSpinner = spinner('Loading status...') + let loadedFeatures = 0 + 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 status = feature.status() || 'Unknown' + if(status !== 'Unknown') { + loadedFeatures++ + } + const statusColumn = styledStatus(status) + const descriptionColumn = feature.description || '' + const choice = { name: nameColumn + statusColumn + descriptionColumn, value: featureKey, short: featureKey} + return choice + }) + featuresChoices.push( + 'Back to Main Menu' + ) + } else { + loadedFeatures = featuresChoices.filter(feature => { + return typeof feature === 'object' && !feature.name.includes('Unknown') + }).length + } + stopSpinner('Loaded status for ' + loadedFeatures + '/' + (featuresChoices.length - 1) + ' features.') + let answer + try { + answer = await inquirer.prompt({ + name: 'features_menu', + type: 'list', + message: 'Please choose:', + choices: featuresChoices, + default: previousMenuChoice, + pageSize: featuresChoices.length + }) + } catch(error) { + if (error === 'EVENT_INTERRUPTED') { + console.log('\nESC') + return false + } + } + 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' +export async function oneFeatureMenu(name, feature) { + console.log(`\n${headerStyle(name)}`) + if(feature.description && feature.description?.length >= 1) { + console.log('\n' + feature.description + '\n') + } + const featureChoices = [] + const stopSpinner = spinner('Loading status...') + const status = feature?.status() || false + stopSpinner('Loaded ' + name + ' status: ' + (feature.status() || 'Unknown') + '\n') + 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' + ) + let answer + try { + answer = await inquirer.prompt({ + name: 'feature_menu', + type: 'list', + message: 'Please choose:', + choices: featureChoices + }) + } catch(error) { + if (error === 'EVENT_INTERRUPTED') { + console.log('\nESC') + return false + } + } + 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 +} diff --git a/scripts/features/jitsi.js b/scripts/features/jitsi.js index 9845d3e..5c3b49b 100644 --- a/scripts/features/jitsi.js +++ b/scripts/features/jitsi.js @@ -1,5 +1,5 @@ export default { name: 'Jitsi', description: 'secure video chat', - status: () => 'off', + status: () => null, } diff --git a/scripts/features/jubilee.js b/scripts/features/jubilee.js index 53feed2..369c61c 100644 --- a/scripts/features/jubilee.js +++ b/scripts/features/jubilee.js @@ -1,5 +1,5 @@ export default { name: 'Jubilee', description: 'monthly points creation event', - status: () => 'off', + status: () => null, } diff --git a/scripts/features/manual.js b/scripts/features/manual.js index d7d2cd3..7a8468c 100644 --- a/scripts/features/manual.js +++ b/scripts/features/manual.js @@ -1,6 +1,15 @@ +// Functions for downloading, updating, and displaying the AO Manual, a hierarchy of markdown files +import chalk from 'chalk' +import inquirer from 'inquirer' import { execSync, exec } from 'child_process' -import { lsFolder } from '../files.js' -import { AO_MANUAL_PATH } from '../manual.js' +import { loadYamlMarkdownFile, lsFolder, isFolder } from '../files.js' +import { repeatString, centerLines } from '../strings.js' +import { headerStyle, manualTitleStyle } from '../styles.js' +import { basename } from 'path' +import { marked } from 'marked' +import TerminalRenderer from 'marked-terminal' + +export const AO_MANUAL_PATH = process.env.HOME + '/.ao/manual' export function manualStatus() { // There are at least eighteen items in the manual @@ -10,6 +19,19 @@ export function manualStatus() { return 'off' } +// Downloads the ao-manual repo to ~/.ao/manual/. Returns false if it fails, which usually means the folder already exists (update instead). +export function downloadManual() { + try { + execSync('git clone http://git.coalitionofinvisiblecolleges.org:3009/autonomousorganization/ao-manual.git ' + AO_MANUAL_PATH + ' 2>&1') + } catch(err) { + switch(err.code) { + case 128: + return false + } + } + return true +} + export async function updateManual() { exec('cd ' + process.env.HOME + '/.ao/manual && git pull origin main 2>&1', (error, stdout, stderr) => { //console.log('error:', error, 'stdout:', stdout, 'stderr:', stderr) @@ -23,17 +45,141 @@ export async function updateManual() { }) } -// Downloads the ao-manual repo to ~/.ao/manual/. Returns false if it fails, which usually means the folder already exists (update instead). -export function downloadManual() { - try { - execSync('git clone http://git.coalitionofinvisiblecolleges.org:3009/autonomousorganization/ao-manual.git ' + AO_MANUAL_PATH + ' 2>&1') - } catch(err) { - switch(err.code) { - case 128: - return false +// 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() +} + + marked.setOptions({ + renderer: new TerminalRenderer({ + showSectionPrefix: false, + }) +}) + +// 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 } } - return true + + // Fall back to using the file/folder name as the title + return formatManualTitleString(fileOrFolder) +} + +// Prints the specified manual page to the screen +export async function printManualPage(path, injectedTitle = '') { + if(isFolder(path)) { + path += '/index.md' + } + const dict = await loadYamlMarkdownFile(path) + const title = injectedTitle || dict?.meta?.title || formatManualTitleString(basename(path)) + const formattedTitle = manualTitleStyle(title).centerInLine(title.length).centerInConsole() + console.log('\n' + formattedTitle + '\n') + const renderedMarkdown = marked(dict?.tail).wordWrap().centerInConsole() + console.log(renderedMarkdown) +} + +// 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 +export 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) + if(previousMenuChoice >= menuChoices.length) { + previousMenuChoice = 0 + } + console.log(`\n${headerStyle(menuTitle)}`) + const answer = await inquirer.prompt({ + name: 'manual_menu', + type: 'rawlist', + message: 'Choose a topic:', + 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, answer.manual_menu) + const newBackOption = backOption === 'Back to Main Menu' ? 'Back to Table of Contents' : 'Back to ' + menuTitle + let previousChoice = 0 + do { + previousChoice = await manualFolderAsMenu(chosenPath, answer.manual_menu, newBackOption, previousChoice + 1) + } + while(previousChoice !== false) + return chosenMenuIndex } export default { @@ -41,5 +187,6 @@ export default { description: 'AO user manual', status: manualStatus, install: downloadManual, + isInstalled: () => manualStatus() === 'installed', update: updateManual -} +} \ No newline at end of file diff --git a/scripts/features/signal.js b/scripts/features/signal.js index 530dcb8..357539e 100644 --- a/scripts/features/signal.js +++ b/scripts/features/signal.js @@ -1,5 +1,5 @@ export default { name: 'Signal', description: 'secure notifications', - status: () => 'off', + status: () => null, } diff --git a/scripts/features/themes.js b/scripts/features/themes.js index e9e5500..2e4edef 100644 --- a/scripts/features/themes.js +++ b/scripts/features/themes.js @@ -1,5 +1,5 @@ export default { name: 'Themes', description: 'custom themes', - status: () => 'off', + status: () => null, } diff --git a/scripts/features/tor.js b/scripts/features/tor.js index 744e4be..84be360 100644 --- a/scripts/features/tor.js +++ b/scripts/features/tor.js @@ -14,7 +14,12 @@ export function torStatus() { return 'off' } +export function isInstalled() { + return torStatus() === 'running' +} + export default { description: 'connect AOs p2p', - status: torStatus -} \ No newline at end of file + status: torStatus, + isInstalled: isInstalled +} diff --git a/scripts/features/youtube-dl.js b/scripts/features/youtube-dl.js index 4056235..de232fb 100644 --- a/scripts/features/youtube-dl.js +++ b/scripts/features/youtube-dl.js @@ -1,4 +1,4 @@ export default { description: 'cache web videos', - status: () => 'off', + status: () => null, } diff --git a/scripts/files.js b/scripts/files.js index 2615cf2..d05ec60 100644 --- a/scripts/files.js +++ b/scripts/files.js @@ -43,3 +43,8 @@ export function lsFolder(path) { export function isFolder(path) { return Array.isArray(lsFolder(path)) } + +// This might also return true for folders, plz fix +export function isFile(path) { + return fs.existsSync(path) +} \ No newline at end of file diff --git a/scripts/keyboard.js b/scripts/keyboard.js new file mode 100644 index 0000000..5f8cb93 --- /dev/null +++ b/scripts/keyboard.js @@ -0,0 +1,10 @@ +// Hook to add keyboard shortcuts to all inquirer prompts +import inquirer from 'inquirer' +import InterruptedPrompt from 'inquirer-interrupted-prompt' + +InterruptedPrompt.replaceAllDefaults(inquirer) + +// Note that the above method can only detect one key per menu, apparently, and it can't tell you which one it detected in the callback. +// Might be worth looking into a more flexible UI library that integrates keyboard shortcuts with its menu library. +// Right now we are stuck with just using one key, the Escape key to go up a level in menus. Every menu must handle it or it can crash. +// The inquirer-interrupted-prompt documentation shows using a prompt with an array of prompt objects as args, but it didn't work for me. \ No newline at end of file diff --git a/scripts/manual.js b/scripts/manual.js deleted file mode 100644 index 78c271e..0000000 --- a/scripts/manual.js +++ /dev/null @@ -1,148 +0,0 @@ -// Functions for loading the AO Manual, a hierarchy of markdown files -import chalk from 'chalk' -import inquirer from 'inquirer' -import { loadYamlMarkdownFile, lsFolder, isFolder } from './files.js' -import { repeatString, centerLines } from './strings.js' -import { headerStyle, manualTitleStyle } from './styles.js' -import { basename } from 'path' -import { marked } from 'marked' -import TerminalRenderer from 'marked-terminal' - -export const AO_MANUAL_PATH = process.env.HOME + '/.ao/manual' - -// 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() -} - - marked.setOptions({ - renderer: new TerminalRenderer({ - showSectionPrefix: false, - }) -}) - -// 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) -} - -// Prints the specified manual page to the screen -export async function printManualPage(path, injectedTitle = '') { - if(isFolder(path)) { - path += '/index.md' - } - const dict = await loadYamlMarkdownFile(path) - const title = injectedTitle || dict?.meta?.title || formatManualTitleString(basename(path)) - const formattedTitle = manualTitleStyle(title).centerInLine(title.length).centerInConsole() - console.log('\n' + formattedTitle + '\n') - const renderedMarkdown = marked(dict?.tail).wordWrap().centerInConsole() - console.log(renderedMarkdown) -} - -// 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 -export 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) - if(previousMenuChoice >= menuChoices.length) { - previousMenuChoice = 0 - } - console.log(`\n${headerStyle(menuTitle)}`) - const answer = await inquirer.prompt({ - name: 'manual_menu', - type: 'rawlist', - message: 'Choose a topic:', - 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, answer.manual_menu) - const newBackOption = backOption === 'Back to Main Menu' ? 'Back to Table of Contents' : 'Back to ' + menuTitle - let previousChoice = 0 - do { - previousChoice = await manualFolderAsMenu(chosenPath, answer.manual_menu, newBackOption, previousChoice + 1) - } - while(previousChoice !== false) - return chosenMenuIndex -} diff --git a/scripts/priority.js b/scripts/priority.js index d006eeb..7efdd3f 100644 --- a/scripts/priority.js +++ b/scripts/priority.js @@ -1,8 +1,16 @@ +// Prioritize cards within other cards. Each card has a .priorities array of other taskIds. +import inquirer from 'inquirer' +import { headerStyle } from './styles.js' import { aoEnv } from './settings.js' -import { getCard } from './api.js' +import { getCard, prioritizeCard, completeCard, uncheckCard, refocusCard } from './api.js' // Prints the text (.name) of the first card prioritized in the logged-in users member card export async function getTopPriorityText() { + return (await getFirstPriorityCard())?.name +} + +// Makes an API request to get the first prioritized card in the member card of the logged-in user +async function getFirstPriorityCard() { // Get the first priority of my member card const memberId = aoEnv('AO_CLI_SESSION_MEMBERID') if(!memberId) { @@ -15,11 +23,153 @@ export async function getTopPriorityText() { if(!fetchedCards || fetchedCards.length < 2) { return 'None' } - const firstPriorityCard = fetchedCards[1] - return firstPriorityCard.name + return fetchedCards[1] } -// Makes an API request to get the first prioritized card in the member card of the logged-in user -async function getFirstPriorityCard() { - +// Displays the priorities of the given taskId in a menu. Selecting a card shows a menu for that card. If taskId is null, member card is used. +export async function prioritiesMenu(taskId = null) { + console.log(`\n${headerStyle('My Priorities')}`) + let prioritiesChoices = [] + + const memberId = aoEnv('AO_CLI_SESSION_MEMBERID') + if(!memberId) { + console.log('Not logged in.') + return false + } + if(!taskId) { + // Get the priorities of my member card + taskId = memberId + } + const fetchedCards = await getCard(taskId, 'priorities') + if(!fetchedCards || fetchedCards.length < 1) { + console.log('Failed to fetch member card, this is bad.') + return false + } + const card = fetchedCards[0] + const priorityCards = fetchedCards.slice(1) // First card is member card itself + let priorities = card.priorities.slice() + priorities.reverse() + console.log('You have', priorityCards.length, 'priorities:') + prioritiesChoices = priorities.map((priorityTaskId, i) => { + const priorityCard = priorityCards.find(p => p.taskId === priorityTaskId) + if(!priorityCard) { + return 'Missing card, repair your database' + } + return { + name: priorityCard.name, + value: { index: i, card: priorityCard }, + short: priorityCard.name.substring(0, 70) + priorityCard.name.length >= 70 ? '...' : '' + } + }) + prioritiesChoices.push( + { name: 'Create priority', value: 'create_here', short: 'new priority' }, + { name: 'Back to Deck', value: false, short: 'back' } + ) + let answer + try { + answer = await inquirer.prompt({ + name: 'priorities_menu', + type: 'rawlist', + message: 'Please choose:', + choices: prioritiesChoices, + loop: false + }) + } catch(error) { + if (error === 'EVENT_INTERRUPTED') { + console.log('\nESC') + return false + } + } + switch(answer.priorities_menu) { + case false: + return false + case 'create_here': + let previousCardCreatedText + do { + console.log('previousCardCreatedText is', previousCardCreatedText) + previousCardCreatedText = await createCardInteractive() + } while(previousCardCreatedText != '\n') + return true + case 'Missing card, repair your database': + console.log('Database repair yet implemented, sorry.') + return true + } + let chosenTask = answer.priorities_menu.card + const chosenTaskId = chosenTask.taskId + let previousAnswer + do { + previousAnswer = await priorityCardMenu(chosenTask, answer.priorities_menu.index) + if(previousAnswer) { + const fetchedCards = await getCard(chosenTaskId, false) + if(!fetchedCards || fetchedCards.length < 1) { + console.log('The card has disappeared. Maybe it was deleted, or cards held by no one are automatically cleaned up every five minutes.') + return false + } + chosenTask = fetchedCards[0] + } + } while(previousAnswer !== false) + console.log('Card menu not yet implemented.') + return true +} + +// Short action-oriented menu for cards in the priorities list +// Index is the position of the card in the list that it is in, used for fencepost case to display upboat contextually +async function priorityCardMenu(card, index) { + if(!card) { + console.log('priorityCardMenu: card is required.') + return false + } + const taskId = card.taskId + const memberId = aoEnv('AO_CLI_SESSION_MEMBERID') + if(!memberId) { + console.log('Not logged in.') + return false + } + const isChecked = card.claimed.includes(memberId) + console.log(`\n${headerStyle('Priority: ' + card.name)}`) + let priorityChoices = [] + if(index != 0) { + priorityChoices.push({ name: 'Upboat', value: 'upboat', short: 'upboat' }) + } + priorityChoices.push( + { name: isChecked ? 'Uncheck' : 'Check off', value: 'check', short: 'check!' }, + { name: 'Downboat', value: 'downboat', short: 'downboat' }, + //{ name: 'Browse within', value: 'browse', short: 'browse' } + { name: 'Back to Priorities', value: false, short: 'back' } + ) + let answer + try { + answer = await inquirer.prompt({ + name: 'priority_card_menu', + type: 'list', + message: 'Please choose:', + choices: priorityChoices, + pageSize: priorityChoices.length, + }) + } catch(error) { + if (error === 'EVENT_INTERRUPTED') { + console.log('\nESC') + return false + } + } + switch(answer.priority_card_menu) { + case 'check': + if(isChecked) { + await uncheckCard(taskId) + } else { + await completeCard(taskId) + } + break + case 'upboat': + await prioritizeCard(taskId, memberId) + return false + case 'downboat': + await refocusCard(taskId, memberId) + return false + case 'browse': + break + default: + return false + } + return true } diff --git a/scripts/settings.js b/scripts/settings.js index 80bd194..fb84e8e 100644 --- a/scripts/settings.js +++ b/scripts/settings.js @@ -24,11 +24,11 @@ export function aoEnv(variable) { envFileContents = fs.readFileSync(AO_ENV_FILE_PATH) } catch(err) { if(err.code === 'ENOENT') { - console.log('The .env file does not exist, so the requested value', variable, 'is empty.') + //console.log('The .env file does not exist, so the requested value', variable, 'is empty.') } else { console.log('Unknown error loading .env file in aoEnv, aborting.') - return null } + return null } const parsedFile = parse(envFileContents) diff --git a/scripts/styles.js b/scripts/styles.js index 4be5c4b..d97840f 100644 --- a/scripts/styles.js +++ b/scripts/styles.js @@ -4,7 +4,9 @@ import chalk from 'chalk' export const greenChalk = chalk.hex('#008800') export const headerStyle = chalk.blue.bold.underline export const manualTitleStyle = greenChalk.bold.underline +export const heading1 = chalk.bold.underline +export const heading2 = chalk.underline // Preformatted phrases that can be used in backticked console.log strings export const theAO = `the ${greenChalk.bold('AO')}` -export const theMenu = `the ${greenChalk.bold('Menu')}` \ No newline at end of file +export const theMenu = `the ${greenChalk.bold('Menu')}` diff --git a/scripts/system.js b/scripts/system.js index d7fc5ec..00d719c 100644 --- a/scripts/system.js +++ b/scripts/system.js @@ -2,6 +2,20 @@ import chalk from 'chalk' import { execSync } from 'child_process' +// Exits ao-cli if has been run with sudo or while logged in as root/superuser using su +export function exitIfRoot() { + try { + let loggedInUserUnixID = parseInt(execSync('echo $EUID').toString()) + if(loggedInUserUnixID === 0) { + 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) { + console.log('Error checking whether or not you are root. Continuing but please do not run ao-cli as root or sudo! It will install things with admin privileges and then you won\'t be able to access them.') + } +} + // Detects the operating system we are running on let distro export function detectOS() { @@ -73,7 +87,7 @@ export function updateSoftware() { } // Checks if the given package is installed using the standard repo for your detected OS -function isInstalled(packageName, group) { +export function isInstalled(packageName, group) { detectOS() if(!distro) { console.log("Your OS was not recognized, so nothing was updated, sorry.") @@ -165,59 +179,3 @@ function installIfNotInstalled(packageNameOrNames, verbose = true, group = false }) return { installed: packagesInstalled, failed: packagesFailed } } - -// Creates the directories to store the AO's database, memes, manual, and maybe other things, ~/.ao by standard -export function createAoDirectories() { - try { - execSync('mkdir -p $HOME/.ao/memes') - } catch(error) { - console.log('Error creating ~/.ao/memes directory. Maybe it already exists.') - } -} - -function installNvm() { - try { - execSync('[ -z $NVM_DIR ]') - execSync('source ~/Alchemy/ingredients/iron && install_nvm') - console.log(`Installed nvm.`) - return true - } catch(err) { - return false - } -} - -// Installs core dependencies required by Alchemy and the AO -export function installRequired() { - 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 on every OS - installIfNotInstalled(['curl', 'wget', 'git', 'make', 'sqlite3', 'python', 'autoconf-archive']) - installNvm() - - // Install OS-specific requirements - switch(distro) { - case 'debian': - // Some of these might not be required - installIfNotInstalled(['build-essential', 'zlib1g-dev', 'libtool-bin', 'autoconf', 'automake autotools-dev', 'libgmp-dev', 'libsqlite3-dev', 'python3', 'python3-mako', 'libsodium-dev', 'pkg-config', 'libev-dev', 'libcurl4-gnutls-dev', 'libssl-dev', 'fakeroot', 'devscripts']) - break - case 'arch': - installIfNotInstalled('base-devel', true, true) - installIfNotInstalled(['gmp', 'pkgconf', 'libev', 'python-mako', 'python-pip', 'net-tools', 'zlib', 'libsodium', 'gettext', 'nginx']) - break - case 'fedora': - installIfNotInstalled(['autoconf', 'automake', 'python3', 'python3-mako', 'pkg-config', 'fakeroot', 'devscripts']) - break - } - return true -} - -// Sets node to the current version used by the AO -export function setNodeVersion() { - execSync('source ~/Alchemy/ingredients/lead && source ~/Alchemy/ingredients/iron && set_node_to v16.13.0') -} diff --git a/scripts/tests.js b/scripts/tests.js index fd03df2..bd67396 100644 --- a/scripts/tests.js +++ b/scripts/tests.js @@ -2,6 +2,7 @@ // The tests actually happen so your database will be modified (future: allow switching databases or automatically switch) // The tests use an AO API file saved in the same directory; this file must be kept up-to-date // Maybe in the future a precompiled api.js created from api.ts can be hosted so that ao-cli does not have to compile any TypeScript +import inquirer from 'inquirer' import { createSession, logout } from './api.js' async function testLoginAndOut() { @@ -43,7 +44,36 @@ async function runAllTests() { await testLoginAndOut() } -export const tests = { +const tests = { "Run All Tests": runAllTests, "Test Login/Logout": testLoginAndOut } + +// Prints the AO Unit Tests Menu and executes the user's choice +export default 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') + let answer + try { + answer = await inquirer.prompt({ + name: 'tests_menu', + type: 'list', + message: 'Please choose:', + choices: testChoices + }) + } catch(error) { + if (error === 'EVENT_INTERRUPTED') { + console.log('\nESC') + return false + } + } + if(answer.tests_menu === 'Back to Main Menu') { + return false + } + const testFunction = tests[answer.tests_menu] + if(testFunction) await testFunction() + return true +} \ No newline at end of file diff --git a/scripts/welcome.js b/scripts/welcome.js index 5f5e1d9..72ff965 100644 --- a/scripts/welcome.js +++ b/scripts/welcome.js @@ -98,6 +98,17 @@ export function farewell() { console.log(chalk.yellow.bold(selectRandom(farewellMessages))) } +// Asks the given yes or no answer returns true or false for their response +export async function yesOrNo(prompt = 'Yes or no?', defaultAnswer = true) { + const answer = await inquirer.prompt({ + name: 'yes_or_no', + type: 'confirm', + message: prompt, + default: defaultAnswer + }) + return answer.yes_or_no +} + // Ask the user the given question and returns their textual response export async function askQuestionText(prompt = 'Please enter a string:', promptOptions = {}) { let options = { diff --git a/scripts/wizard.js b/scripts/wizard.js new file mode 100644 index 0000000..1cdd18f --- /dev/null +++ b/scripts/wizard.js @@ -0,0 +1,287 @@ +// Functions related to intelligently installing the AO as a whole. Specific additional feature modules are each a file under ./features. +import inquirer from 'inquirer' +import path from 'path' +import { execSync } from 'child_process' +import { aoEnv, setAoEnv } from './settings.js' +import { detectOS, updateSoftware, isInstalled } from './system.js' +import { isFolder, isFile } from './files.js' +import { aoIsInstalled } from './features/ao-server.js' +import { asciiArt } from './console.js' +import { headerStyle, heading2 } from './styles.js' +import features from './features/index.js' + +const AO_MEMES_PATH = path.join(process.env.HOME, '.ao/memes') + +const commonPackages = ['curl', 'wget', 'git', 'make', 'sqlite3', 'python', 'autoconf-archive'] +const debianPackages = ['build-essential', 'zlib1g-dev', 'libtool-bin', 'autoconf', 'automake autotools-dev', 'libgmp-dev', 'libsqlite3-dev', 'python3', 'python3-mako', 'libsodium-dev', 'pkg-config', 'libev-dev', 'libcurl4-gnutls-dev', 'libssl-dev', 'fakeroot', 'devscripts'] +const archPackages = ['gmp', 'pkgconf', 'libev', 'python-mako', 'python-pip', 'net-tools', 'zlib', 'libsodium', 'gettext', 'nginx'] // plus base-devel which is a group of packages +const fedoraPackages = ['autoconf', 'automake', 'python3', 'python3-mako', 'pkg-config', 'fakeroot', 'devscripts'] + +const minimalFeatures = [ 'ao-cli', 'tor', 'alchemy' ] +const standardFeatures = [ 'bitcoin', 'lightning', 'jitsi', 'files', 'youtube-dl', 'themes', 'glossary' ] +const fullFeatures = Object.keys(features).filter(feature => ![...minimalFeatures, ...standardFeatures].includes(feature)) + +// Friendly interactive install wizard walks you through the entire process of installing and configuring a version of the AO and its features +export default 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() { + let answer + try { + 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 } + ] + }) + } catch(error) { + if (error === 'EVENT_INTERRUPTED') { + console.log('\nESC') + return false + } + } + return answer.level_menu +} + +// Asks whether the user wants to install ao-svelte, ao-3, or ao-cli (only) and returns their choice +export async function chooseAoVersion() { + console.log(`\n${headerStyle('Choose AO Version')}`) + console.log('Active version:', aoEnv('AO_VERSION')) + let answer + try { + 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' + ] + }) + } catch(error) { + if (error === 'EVENT_INTERRUPTED') { + console.log('\nESC') + return false + } + } + 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 +} + +function checkAoDirectories() { + return isFolder(AO_MEMES_PATH) +} + +// Creates the directories to store the AO's database, memes, manual, and maybe other things, ~/.ao by standard +export function createAoDirectories() { + try { + execSync('mkdir -p ' + AO_MEMES_PATH) + } catch(error) { + console.log('Error creating ~/.ao/memes directory. Maybe it already exists.') + } +} + +// Returns true if nvm is installed +function checkNvm() { + try { + execSync('. ~/.nvm/nvm.sh && nvm -v') + return true + } catch(error) { + console.log(error.stdout.toString()) + return false + } +} + +function installNvm() { + try { + execSync('[ -z $NVM_DIR ]') + execSync('source ~/Alchemy/ingredients/iron && install_nvm') + console.log(`Installed nvm.`) + return true + } catch(err) { + return false + } +} + +// Returns an object containing a list of requirement: installed (boolean) +export function checkRequired() { + const distro = detectOS() + if(!distro) { + console.log("Your OS was not recognized, so nothing was checked, sorry.") + return false + } + let summary = {} + console.log('Checking AO prerequisites for ' + distro.toTitleCase() + '...') + // Check OS-specific requirements + const checkEachPackage = (packages) => { + packages.forEach(packageName => { + summary[packageName] = isInstalled(packageName) + }) + } + checkEachPackage(commonPackages) + summary['nvm'] = checkNvm() + switch(distro) { + case 'debian': + // Some of these might not be required + checkEachPackage(debianPackages) + break + case 'arch': + summary['base-devel'] = isInstalled('base-devel', true, true) + checkEachPackage(archPackages) + break + case 'fedora': + checkEachPackage(fedoraPackages) + break + } + return summary +} + +// Prints out a summary of the AO's installation status. Returns true if the AO is installed (Standard + ao-svelte or ao-3, not ao-cli-only) +export function checkAo() { + const width = 24 + // Print status of each required package individually + console.log(`\n${heading2('Required Software Packages')}`) + const summary = checkRequired() + Object.entries(summary).forEach(([packageName, isInstalled]) => { + console.log(packageName.padEnd(width) + (isInstalled ? 'Installed' : 'Missing')) + }) + const prerequisitesInstalled = Object.entries(summary).every(([packageName, isInstalled]) => isInstalled) + + // Check for existence of required directories + console.log(`\n${heading2('Folders & Config File')}`) + const requiredDirectoriesExist = checkAoDirectories() + console.log('~/.ao & ~/.ao/memes'.padEnd(width) + (requiredDirectoriesExist ? ' Created ' : 'Missing')) + + // Check for .env file + const aoEnvFilePath = path.join(process.env.HOME, '.ao/.env') + const hasEnvFile = isFile(aoEnvFilePath) + console.log('~/.ao/.env'.padEnd(width) + (hasEnvFile ? 'Initialized' : 'Blank'), '\n') + + // Check for ao-server folder with node_modules folder (can do a better check?) + console.log(`\n${heading2('AO Server + Client Version Installed')}`) + const homePathsToCheck = ['ao-server', 'ao-svelte', 'ao-3'] + let homePathsExist = [] + homePathsToCheck.forEach(folderName => { + const nodeModulesPath = path.join(process.env.HOME, folderName, 'node_modules') + const exists = isFolder(nodeModulesPath) + console.log(folderName.padEnd(width) + (exists ? 'Installed' : 'Not Installed')) + if(exists) { + homePathsExist.push(folderName) + } + }) + const hasAnAo = (homePathsExist.includes('ao-server') && homePathsExist.includes('ao-svelte')) || homePathsExist.includes('ao-3') + + // Check which packages are installed, and based on three additive lists of requirements, determine Minimal, Standard, or Full install + console.log(`\n${heading2('Optional Features')}`) + let optionalInstalls = [] + Object.entries(features).forEach(([shortname, feature]) => { + const name = feature?.name || shortname + const isInstalled = feature.hasOwnProperty('isInstalled') ? feature.isInstalled() : ['installed', 'enabled', 'running', 'synced'].includes(feature.status()) + if(isInstalled) optionalInstalls.push(shortname) + console.log(name.padEnd(width) + (isInstalled ? 'Installed' : 'Not Installed')) + }) + + console.log(`\n${heading2('Summary')}`) + let installAttained = null + const otherPrereqs = requiredDirectoriesExist && hasAnAo && hasEnvFile + if(otherPrereqs && prerequisitesInstalled && minimalFeatures.every(shortname => optionalInstalls.includes(shortname))) { + installAttained = 'Minimal' + } + if(installAttained && standardFeatures.every(shortname => optionalInstalls.includes(shortname))) { + installAttained = 'Standard' + } + if(installAttained === 'Standard' && fullFeatures.every(shortname => optionalInstalls.includes(shortname))) { + installAttained = 'Full' + } + console.log(optionalInstalls.length + '/' + Object.keys(features).length, 'optional features installed.') + if(!installAttained) { + console.log("You have not installed the AO; the required packages were not detected.") + } else { + console.log('You have the packages installed for a', installAttained, 'install.') + } + console.log('Selected AO_VERSION is', aoEnv('AO_VERSION')) + + // Is it possible to check if npm i has already been called in all the node project folders? +} + +// Installs core dependencies required by Alchemy and the AO +export function installRequired() { + const 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 on every OS + installIfNotInstalled(commonPackages) + installNvm() + + // Install OS-specific requirements + switch(distro) { + case 'debian': + // Some of these might not be required + installIfNotInstalled(debianPackages) + break + case 'arch': + installIfNotInstalled('base-devel', true, true) + installIfNotInstalled(archPackages) + break + case 'fedora': + installIfNotInstalled(fedoraPackages) + break + } + return true +} + +// Sets node to the current version used by the AO +function setNodeVersion() { + execSync('source ~/Alchemy/ingredients/lead && source ~/Alchemy/ingredients/iron && set_node_to v16.13.0') +}