deicidus
2 years ago
34 changed files with 1462 additions and 869 deletions
@ -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 |
||||
} |
@ -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 |
||||
} |
@ -1,5 +1,5 @@
|
||||
export default { |
||||
name: 'SSL/Certbot', |
||||
description: 'HTTPS for public web AO', |
||||
status: () => 'Off', |
||||
} |
||||
status: () => null, |
||||
} |
||||
|
@ -1,5 +1,5 @@
|
||||
export default { |
||||
name: 'Encryption', |
||||
description: 'serverside secret messages', //encrypt messages to and from this computer',
|
||||
status: () => 'off', |
||||
} |
||||
status: () => null, |
||||
} |
||||
|
@ -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, |
||||
} |
||||
|
@ -1,5 +1,5 @@
|
||||
export default { |
||||
name: 'Glossary', |
||||
description: 'custom glossary', |
||||
status: () => 'off', |
||||
status: () => null, |
||||
} |
||||
|
@ -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 |
||||
} |
||||
|
@ -1,5 +1,5 @@
|
||||
export default { |
||||
name: 'Jitsi', |
||||
description: 'secure video chat', |
||||
status: () => 'off', |
||||
status: () => null, |
||||
} |
||||
|
@ -1,5 +1,5 @@
|
||||
export default { |
||||
name: 'Jubilee', |
||||
description: 'monthly points creation event', |
||||
status: () => 'off', |
||||
status: () => null, |
||||
} |
||||
|
@ -1,5 +1,5 @@
|
||||
export default { |
||||
name: 'Signal', |
||||
description: 'secure notifications', |
||||
status: () => 'off', |
||||
status: () => null, |
||||
} |
||||
|
@ -1,5 +1,5 @@
|
||||
export default { |
||||
name: 'Themes', |
||||
description: 'custom themes', |
||||
status: () => 'off', |
||||
status: () => null, |
||||
} |
||||
|
@ -1,4 +1,4 @@
|
||||
export default { |
||||
description: 'cache web videos', |
||||
status: () => 'off', |
||||
status: () => null, |
||||
} |
||||
|
@ -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.
|
@ -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 |
||||
} |
@ -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…
Reference in new issue