Browse Source

AO install wizard partway done, reorganized project repos, 'Check AO install' feature works, misc polish

main
deicidus 2 years ago
parent
commit
645e9d2b59
  1. 6
      README.md
  2. 524
      index.js
  3. 11
      package-lock.json
  4. 5
      package.json
  5. 133
      scripts/ao.js
  6. 49
      scripts/api.js
  7. 175
      scripts/cards.js
  8. 215
      scripts/connect.js
  9. 8
      scripts/console.js
  10. 8
      scripts/features/ao-cli.js
  11. 60
      scripts/features/ao-server.js
  12. 2
      scripts/features/borg.js
  13. 2
      scripts/features/certbot.js
  14. 2
      scripts/features/encryption.js
  15. 2
      scripts/features/files.js
  16. 2
      scripts/features/glossary.js
  17. 192
      scripts/features/index.js
  18. 2
      scripts/features/jitsi.js
  19. 2
      scripts/features/jubilee.js
  20. 169
      scripts/features/manual.js
  21. 2
      scripts/features/signal.js
  22. 2
      scripts/features/themes.js
  23. 7
      scripts/features/tor.js
  24. 2
      scripts/features/youtube-dl.js
  25. 5
      scripts/files.js
  26. 10
      scripts/keyboard.js
  27. 148
      scripts/manual.js
  28. 160
      scripts/priority.js
  29. 4
      scripts/settings.js
  30. 2
      scripts/styles.js
  31. 72
      scripts/system.js
  32. 32
      scripts/tests.js
  33. 11
      scripts/welcome.js
  34. 287
      scripts/wizard.js

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

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

11
package-lock.json generated

@ -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",

5
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",

133
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
}

49
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',

175
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
}

215
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
}

8
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})
}
}

8
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

60
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
}

2
scripts/features/borg.js

@ -3,5 +3,5 @@
export default {
name: 'Borg',
description: 'encrypted-in-transit, deduplicated incremental backup (over tor)',
status: () => 'off',
status: () => null,
}

2
scripts/features/certbot.js

@ -1,5 +1,5 @@
export default {
name: 'SSL/Certbot',
description: 'HTTPS for public web AO',
status: () => 'Off',
status: () => null,
}

2
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',
status: () => null,
}

2
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',
status: () => null,
}

2
scripts/features/glossary.js

@ -1,5 +1,5 @@
export default {
name: 'Glossary',
description: 'custom glossary',
status: () => 'off',
status: () => null,
}

192
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
}

2
scripts/features/jitsi.js

@ -1,5 +1,5 @@
export default {
name: 'Jitsi',
description: 'secure video chat',
status: () => 'off',
status: () => null,
}

2
scripts/features/jubilee.js

@ -1,5 +1,5 @@
export default {
name: 'Jubilee',
description: 'monthly points creation event',
status: () => 'off',
status: () => null,
}

169
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
}
}
return true
// 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
}
export default {
@ -41,5 +187,6 @@ export default {
description: 'AO user manual',
status: manualStatus,
install: downloadManual,
isInstalled: () => manualStatus() === 'installed',
update: updateManual
}

2
scripts/features/signal.js

@ -1,5 +1,5 @@
export default {
name: 'Signal',
description: 'secure notifications',
status: () => 'off',
status: () => null,
}

2
scripts/features/themes.js

@ -1,5 +1,5 @@
export default {
name: 'Themes',
description: 'custom themes',
status: () => 'off',
status: () => null,
}

7
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
status: torStatus,
isInstalled: isInstalled
}

2
scripts/features/youtube-dl.js

@ -1,4 +1,4 @@
export default {
description: 'cache web videos',
status: () => 'off',
status: () => null,
}

5
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)
}

10
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.

148
scripts/manual.js

@ -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
}

160
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
}

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

2
scripts/styles.js

@ -4,6 +4,8 @@ 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')}`

72
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')
}

32
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
}

11
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 = {

287
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')
}
Loading…
Cancel
Save