diff --git a/deno.json b/deno.json index 8805dc10..5acb28b0 100644 --- a/deno.json +++ b/deno.json @@ -7,7 +7,6 @@ "db:export": "deno run -A scripts/db-export.ts", "db:import": "deno run -A scripts/db-import.ts", "db:migrate": "deno run -A scripts/db-migrate.ts", - "tribes-cli": "deno run -A tribes-cli/cli.ts", "nostr:pull": "deno run -A scripts/nostr-pull.ts", "debug": "deno run -A --inspect src/server.ts", "test": "deno test -A --junit-path=./deno-test.xml", @@ -68,7 +67,6 @@ "linkify-string": "npm:linkify-string@^4.1.1", "linkifyjs": "npm:linkifyjs@^4.1.1", "lru-cache": "npm:lru-cache@^10.2.2", - "node-ssh": "npm:node-ssh@13.2.0", "nostr-relaypool": "npm:nostr-relaypool2@0.6.34", "nostr-tools": "npm:nostr-tools@2.5.1", "nostr-wasm": "npm:nostr-wasm@^0.1.0", diff --git a/tribes-cli/cli.ts b/tribes-cli/cli.ts deleted file mode 100644 index 59eada19..00000000 --- a/tribes-cli/cli.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { tribe } from './tribe/mod.ts'; -import { remote } from './remote/mod.ts'; -import { Command, defaultIdentityFile } from './utils/mod.ts'; - -async function main() { - const tribes = new Command('tribes-cli', 'Create and manage Ditto Tribes servers.') - .subcommand(tribe) - .subcommand(remote) - .option('-i --identity-file', { - description: 'The ssh identity file to use. You will be prompted if this is not supplied.', - }); - - const { parsed } = tribes.parse(Deno.args); - parsed['identity-file'] = await defaultIdentityFile(parsed['identity-file'] as string); - - const [s, v] = parsed._.slice(1); - - let cmd = tribes; - if (s) cmd = tribes.getSubcommand(s); - if (v) cmd = cmd.getSubcommand(v); - - await cmd.doAction(parsed); -} - -if (import.meta.main) { - try { - await main(); - } catch (e) { - console.error('ERROR:', e.message); - Deno.exit(1); - } -} diff --git a/tribes-cli/new-tribe b/tribes-cli/new-tribe deleted file mode 100755 index 89fef3dc..00000000 --- a/tribes-cli/new-tribe +++ /dev/null @@ -1,47 +0,0 @@ -#!/usr/bin/env bash - -wait_keypress() { - echo "Press Enter to proceed..." - read -} - -if [ "$#" -ne 2 ]; then - echo "Usage: $0 " - exit 1 -fi - -INSTANCE_DOMAIN="$1" -UPLOADER_CFG="$2" -INSTANCE_NAME="$(echo "$INSTANCE_DOMAIN" | cut -d '.' -f 1)" -GLOBAL_DOMAIN="$(echo "$INSTANCE_DOMAIN" | cut -d '.' -f 2-)" - -if [ -z "${NSEC+x}" ]; then - echo "nsec not set; generating new nsec..." - NSEC="$(nak key generate | nak encode nsec)" - echo "New Ditto instance nsec is ${NSEC}. Note this down now!" -else - echo "nsec set; proceeding..." -fi - -wait_keypress - -echo todo confirm options -wait_keypress - -ssh -T "root@$GLOBAL_DOMAIN" << EOF -dokku apps:create $INSTANCE_NAME -dokku postgres:link dittodb $INSTANCE_NAME -dokku config:set --no-restart $INSTANCE_NAME DITTO_NSEC="$NSEC" -dokku config:set --no-restart $INSTANCE_NAME DITTO_UPLOADER="nostrbuild" -dokku config:set --no-restart $INSTANCE_NAME NOSTRBUILD_ENDPOINT="https://nostr.build/api/v2/upload/files" -dokku config:set $INSTANCE_NAME LOCAL_DOMAIN="https://$INSTANCE_DOMAIN" -EOF - -REMOTE_NAME="tribes-$INSTANCE_NAME" -if ! git remote get-url "$REMOTE_NAME" > /dev/null 2>&1; then - git remote add "$REMOTE_NAME" "dokku@$GLOBAL_DOMAIN:$INSTANCE_NAME" -fi - -if git push $REMOTE_NAME ditto-as-a-service:main; then - ssh "root@$GLOBAL_DOMAIN" "dokku letsencrypt:enable $INSTANCE_NAME" -fi \ No newline at end of file diff --git a/tribes-cli/remote/init.ts b/tribes-cli/remote/init.ts deleted file mode 100644 index 27102fcf..00000000 --- a/tribes-cli/remote/init.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { Command, makeSshAction, privkeyToPubkey } from '../utils/mod.ts'; - -export const init = new Command('init', 'Initialise a brand-new Ditto remote') - .option('-e --email', { - description: "The e-mail address to use for requesting Let's Encrypt certificates.", - required: true, - }) - .setAction(makeSshAction(async ({ arg, tribes, domain }) => { - const pubkey = await privkeyToPubkey(arg('identity-file')); - await tribes.do('DOKKU_SET_ADMIN_PUBKEY', pubkey); - await tribes.do('DOKKU_INSTALL_PLUGIN', 'postgres'); - await tribes.do('DOKKU_INSTALL_PLUGIN', 'letsencrypt'); - await tribes.do('DOKKU_CREATE_POSTGRES_SERVICE', 'dittodb'); - await tribes.do('DOKKU_SET_GLOBAL_DOMAIN', domain); - await tribes.do('DOKKU_LETSENCRYPT_SETUP_EMAIL', arg('email')); - await tribes.do('DOKKU_LETSENCRYPT_CRON'); - })); diff --git a/tribes-cli/remote/mod.ts b/tribes-cli/remote/mod.ts deleted file mode 100644 index c08c7336..00000000 --- a/tribes-cli/remote/mod.ts +++ /dev/null @@ -1,5 +0,0 @@ -import { Command } from '../utils/mod.ts'; -import { init } from './init.ts'; - -export const remote = new Command('remote', 'Manage a Ditto Tribes server') - .subcommand(init); diff --git a/tribes-cli/setup-remote b/tribes-cli/setup-remote deleted file mode 100755 index 4e8c1c4c..00000000 --- a/tribes-cli/setup-remote +++ /dev/null @@ -1,21 +0,0 @@ -#!/usr/bin/env bash - -if [ "$#" -ne 3 ]; then - echo "Usage: $0 " - exit 1 -fi - -REMOTE_ADDR="$1" -DOMAIN="$2" -LETSENCRYPT_EMAIL="$3" -DEPLOYMENT_PUBKEY="$(cat ~/.ssh/id_rsa.pub)" - -ssh -T "root@$REMOTE_ADDR" << EOF -echo "$DEPLOYMENT_PUBKEY" | dokku ssh-keys:add admin -sudo dokku plugin:install https://github.com/dokku/dokku-postgres.git -sudo dokku plugin:install https://github.com/dokku/dokku-letsencrypt.git -dokku postgres:create dittodb -dokku domains:set-global "$DOMAIN" -dokku letsencrypt:set --global email "$LETSENCRYPT_EMAIL" -dokku letsencrypt:cron-job --add -EOF diff --git a/tribes-cli/setup.ts b/tribes-cli/setup.ts deleted file mode 100644 index 8e77120d..00000000 --- a/tribes-cli/setup.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { parseUploaderConfig } from './uploader-config.ts'; - -function scream(...args: any[]) { - console.error('FATAL:', ...args); - Deno.exit(1); -} - -function missingEnv(what: string, v: string) { - scream(`${what} not set! Set the ${v} config variable before trying again.`); -} - -if (import.meta.main) { - const DITTO_NSEC = Deno.env.get('DITTO_NSEC'); - if (!DITTO_NSEC) missingEnv('Ditto instance nsec', 'DITTO_NSEC'); - - const LOCAL_DOMAIN = Deno.env.get('DITTO_DOMAIN'); - if (!LOCAL_DOMAIN) missingEnv('Domain value', 'DITTO_DOMAIN'); - - const uploaderConfig = Deno.env.get('DITTO_UPLOADER_CONFIG'); - if (!uploaderConfig) missingEnv('Uploader configuration', 'DITTO_UPLOADER_CONFIG'); - - let uploader: ReturnType; - try { - uploader = parseUploaderConfig(uploaderConfig!); - } catch (e) { - scream('Error decoding uploader config:', e.message || e.toString()); - } - - const vars = { - LOCAL_DOMAIN, - DITTO_NSEC, - ...uploader!, - }; - - const result = Object.entries(vars) - .reduce((acc, [key, value]) => value ? `${acc}${key}="${value}"\n` : acc, ''); - - await Deno.writeTextFile('./.env', result); -} diff --git a/tribes-cli/tribe/config.ts b/tribes-cli/tribe/config.ts deleted file mode 100644 index 94791bbf..00000000 --- a/tribes-cli/tribe/config.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { Command } from '../utils/mod.ts'; - -/** - * Change a tribe's configuration. - */ -export const config = new Command('config', "Modify a tribe's configuration"); diff --git a/tribes-cli/tribe/destroy.ts b/tribes-cli/tribe/destroy.ts deleted file mode 100644 index 1eda8494..00000000 --- a/tribes-cli/tribe/destroy.ts +++ /dev/null @@ -1,7 +0,0 @@ -/** - * Destroy a tribe. Non-reversible. - */ - -import { Command } from '../utils/mod.ts'; - -export const destroy = new Command('destroy', 'Destroy a tribe. NON-REVERSIBLE!'); diff --git a/tribes-cli/tribe/mod.ts b/tribes-cli/tribe/mod.ts deleted file mode 100644 index b98956cb..00000000 --- a/tribes-cli/tribe/mod.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { create } from './new.ts'; -import { config } from './config.ts'; -import { Command } from '../utils/mod.ts'; - -export const tribe = new Command('tribe', 'Create and manage tribes.') - .subcommand(create) - .subcommand(config); diff --git a/tribes-cli/tribe/new.ts b/tribes-cli/tribe/new.ts deleted file mode 100644 index 738ff997..00000000 --- a/tribes-cli/tribe/new.ts +++ /dev/null @@ -1,84 +0,0 @@ -import { generateSecretKey, nip19 } from 'nostr-tools'; -import { Command, makeSshAction } from '../utils/mod.ts'; -import question from 'question-deno'; -import { Conf } from '@/config.ts'; - -export const create = new Command('new', 'Create a new tribe.') - .option('-c --custom-domain', { - description: 'Do not use a subdomain of the tribes server; instead, use a custom domain.', - }) - .option('-s --subdomain', { - description: 'Use a subdomain of the tribes server to host the new Ditto instance.', - }) - .option('-N --nsec', { - description: 'The nsec to use for the Ditto instance. Will be generated if not present.', - }) - .setAction(makeSshAction(async ({ tribes, arg, domain: host }) => { - if (!arg('custom-domain') && !arg('subdomain')) { - throw new Error('tribes-cli: No domain provided for tribes instance!'); - } - - const instance = arg('subdomain') || arg('custom-domain'); - const domain = arg('subdomain') ? `${arg('subdomain')}.${host}` : arg('custom-domain'); - - let nsec = arg('nsec'); - if (!nsec) { - const pkey = generateSecretKey(); - nsec = nip19.nsecEncode(pkey); - console.log('nsec not supplied - generated it.'); - console.log('The nsec for the new Ditto instance is:'); - console.log(`\n${nsec}\n`); - console.log(`Note this down now! You will not see it again.`); - confirm('Press Enter to continue.'); - } - - console.log('Configuring uploader.'); - const uploader = await question('list', 'How should this server upload files?', [ - 'nostrbuild', - 'blossom', - 's3', - 'ipfs', - ]); - - if (!uploader) throw new Error('tribes-cli: uploader not set'); - - const uploaderCfg: Record = {}; - if (uploader === 'nostrbuild') { - uploaderCfg.NOSTRBUILD_ENDPOINT = await question('input', 'nostr.build endpoint', Conf.nostrbuildEndpoint); - } - if (uploader === 'blossom') { - uploaderCfg.BLOSSOM_SERVERS = await question( - 'input', - 'Blossom servers (comma separated)', - Conf.blossomServers.join(','), - ); - } - if (uploader === 's3') { - uploaderCfg.S3_ACCESS_KEY = await question('input', 'S3 access key', Conf.s3.accessKey); - uploaderCfg.S3_SECRET_KEY = await question('input', 'S3 secret key', Conf.s3.secretKey); - uploaderCfg.S3_ENDPOINT = await question('input', 'S3 endpoint', Conf.s3.endPoint); - uploaderCfg.S3_BUCKET = await question('input', 'S3 bucket', Conf.s3.bucket); - uploaderCfg.S3_REGION = await question('input', 'S3 region', Conf.s3.region); - uploaderCfg.S3_PATH_STYLE = String(await question('confirm', 'Use path style?', Conf.s3.pathStyle ?? false)); - const mediaDomain = await question('input', 'Media domain', `media.${domain}`); - uploaderCfg.MEDIA_DOMAIN = `https://${mediaDomain}`; - } - if (uploader === 'ipfs') { - uploaderCfg.IPFS_API_URL = await question('input', 'IPFS API URL', Conf.ipfs.apiUrl); - const mediaDomain = await question('input', 'Media domain', `media.${domain}`); - uploaderCfg.MEDIA_DOMAIN = `https://${mediaDomain}`; - } - - await tribes.do('DOKKU_CREATE_APP', instance); - await tribes.do('DOKKU_LINK_PG', instance, 'dittodb'); - await tribes.do('DOKKU_SET_CONFIG_VAR', instance, 'DITTO_UPLOADER', uploader); - await tribes.do('DOKKU_SET_CONFIG_VAR', instance, 'DITTO_NSEC', nsec); - await tribes.do('DOKKU_SET_CONFIG_VAR', instance, 'LOCAL_DOMAIN', `https://${domain}`); - for (const [key, val] of Object.entries(uploaderCfg)) { - await tribes.do('DOKKU_SET_CONFIG_VAR', instance, key, val || ''); - } - - await tribes.do('DOKKU_LOAD_GIT', instance, 'https://gitlab.com/soapbox-pub/ditto.git', 'ditto-as-a-service'); - await tribes.do('DOKKU_REBUILD', instance); - await tribes.do('DOKKU_LE_ENABLE', instance); - })); diff --git a/tribes-cli/tribe/update.ts b/tribes-cli/tribe/update.ts deleted file mode 100644 index f1161b35..00000000 --- a/tribes-cli/tribe/update.ts +++ /dev/null @@ -1,6 +0,0 @@ -/** - * Update a tribe's software. - */ - -export const update = () => { -}; diff --git a/tribes-cli/uploader-config.ts b/tribes-cli/uploader-config.ts deleted file mode 100644 index 91921fa7..00000000 --- a/tribes-cli/uploader-config.ts +++ /dev/null @@ -1,112 +0,0 @@ -import { base64 } from '@scure/base'; -import { z } from 'zod'; -import { Conf } from '@/config.ts'; -import question from 'question-deno'; - -const s3Schema = z.object({ - DITTO_UPLOADER: z.literal('s3'), - S3_ACCESS_KEY: z.string(), - S3_SECRET_KEY: z.string(), - S3_ENDPOINT: z.string().url(), - S3_BUCKET: z.string(), - S3_REGION: z.string(), - S3_PATH_STYLE: z.union([z.literal('true'), z.literal('false')]), - MEDIA_DOMAIN: z.string().url(), -}); - -const blossomSchema = z.object({ - DITTO_UPLOADER: z.literal('blossom'), - BLOSSOM_SERVERS: z.string().refine((value) => { - return value.split(',').every((server) => { - try { - new URL(server); - return true; - } catch { - return false; - } - }); - }, { message: 'All Blossom servers must be valid URLs' }), -}); - -const nostrBuildSchema = z.object({ - DITTO_UPLOADER: z.literal('nostrbuild'), - NOSTRBUILD_ENDPOINT: z.string().url(), -}); - -const ipfsSchema = z.object({ - DITTO_UPLOADER: z.literal('ipfs'), - IPFS_API_URL: z.string().url(), - MEDIA_DOMAIN: z.string().url(), -}); - -const localSchema = z.object({ - DITTO_UPLOADER: z.literal('local'), - UPLOADS_DIR: z.string().default(Conf.nostrbuildEndpoint), - MEDIA_DOMAIN: z.string().url(), -}); - -const uploaderSchema = z.union([ - nostrBuildSchema, - blossomSchema, - s3Schema, - ipfsSchema, - localSchema, -]); - -export function parseUploaderConfig(cfg: string) { - const decoded = new TextDecoder().decode(base64.decode(cfg!)); - const parsed = JSON.parse(decoded); - const validated = uploaderSchema.parse(parsed); - return validated; -} - -if (import.meta.main) { - const vars: Record = {}; - - const [domain] = Deno.args; - if (!domain) { - throw new Error('Domain is required!'); - } - - if (!domain.match(/^(https?):\/\/.+/)) { - throw new Error('Domain must begin with http(s)!'); - } - - vars.DITTO_UPLOADER = await question('list', 'How do you want to upload files?', [ - 'nostrbuild', - 'blossom', - 's3', - 'ipfs', - 'local', - ]); - - if (vars.DITTO_UPLOADER === 'nostrbuild') { - vars.NOSTRBUILD_ENDPOINT = await question('input', 'nostr.build endpoint', Conf.nostrbuildEndpoint); - } - if (vars.DITTO_UPLOADER === 'blossom') { - vars.BLOSSOM_SERVERS = await question('input', 'Blossom servers (comma separated)', Conf.blossomServers.join(',')); - } - if (vars.DITTO_UPLOADER === 's3') { - vars.S3_ACCESS_KEY = await question('input', 'S3 access key', Conf.s3.accessKey); - vars.S3_SECRET_KEY = await question('input', 'S3 secret key', Conf.s3.secretKey); - vars.S3_ENDPOINT = await question('input', 'S3 endpoint', Conf.s3.endPoint); - vars.S3_BUCKET = await question('input', 'S3 bucket', Conf.s3.bucket); - vars.S3_REGION = await question('input', 'S3 region', Conf.s3.region); - vars.S3_PATH_STYLE = String(await question('confirm', 'Use path style?', Conf.s3.pathStyle ?? false)); - const mediaDomain = await question('input', 'Media domain', `media.${domain}`); - vars.MEDIA_DOMAIN = `https://${mediaDomain}`; - } - if (vars.DITTO_UPLOADER === 'ipfs') { - vars.IPFS_API_URL = await question('input', 'IPFS API URL', Conf.ipfs.apiUrl); - const mediaDomain = await question('input', 'Media domain', `media.${domain}`); - vars.MEDIA_DOMAIN = `https://${mediaDomain}`; - } - if (vars.DITTO_UPLOADER === 'local') { - vars.UPLOADS_DIR = await question('input', 'Local uploads directory', Conf.uploadsDir); - const mediaDomain = await question('input', 'Media domain', `media.${domain}`); - vars.MEDIA_DOMAIN = `https://${mediaDomain}`; - } - - const encoded = base64.encode(new TextEncoder().encode(JSON.stringify(vars))); - console.log(encoded); -} diff --git a/tribes-cli/utils/command.ts b/tribes-cli/utils/command.ts deleted file mode 100644 index 735c8ea8..00000000 --- a/tribes-cli/utils/command.ts +++ /dev/null @@ -1,110 +0,0 @@ -import { getOptionName, Option, ParsedArgs } from './mod.ts'; -import { parseArgs } from '@std/cli'; -import { parseCommand } from './parsing.ts'; - -export class Command { - name: string; - description: string; - private action: (args: ParsedArgs) => void | Promise = (_) => {}; - options: Record = {}; - commands: Record = {}; - requiredOptions: string[] = []; - - constructor(name: string, description: string) { - this.name = name; - this.description = description; - } - - isValidSubcommand(key: string): boolean { - return Object.keys(this.commands).includes(key.toString()); - } - - getSubcommand(subcommand: string | number) { - if (!subcommand) { - throw new Error('tribes-cli: invalid subcommand'); - } - if (typeof subcommand === 'number') { - throw new Error('tribes-cli: subcommand cannot be a number'); - } - - if (this.isValidSubcommand(subcommand)) { - return this.commands[subcommand]; - } else { - throw new Error(`tribes-cli: ${subcommand} is not a valid subcommand`); - } - } - - setAction(action: Command['action']) { - this.action = action; - return this; - } - - async doAction(args: ParsedArgs) { - if (args.help) return console.log(this.help); - - for (const option of this.requiredOptions) { - if (!args[option]) throw new Error(`tribes-cli: missing required option ${option}`); - } - - return await this.action(args); - } - - subcommand(command: Command) { - if (this.isValidSubcommand(command.name)) { - throw new Error(`tribes-cli: ${command.name} is already a subcommand.`); - } - this.commands[command.name] = command; - return this; - } - - option(fmt: string, option: Option) { - this.options[fmt] = option; - if (option.required) this.requiredOptions.push(getOptionName(fmt)); - return this; - } - - parse(args: string[]) { - const parserArgs = parseCommand(this); - const parsed = parseArgs(args, parserArgs); - return { parsed, parserArgs }; - } - - get usage(): string { - const res = [`${this.name}`]; - const subcommands = Object.keys(this.commands); - if (subcommands.length) { - res.push(`<${subcommands.join('|')}>`); - } - return res.join(' '); - } - - get help(): string { - const lines = [ - `Usage: ${this.usage}`, - '', - `${this.description}`, - '', - ]; - - if (Object.keys(this.options).length > 0) { - lines.push('Options:'); - for (const [key, option] of Object.entries(this.options)) { - lines.push(` ${key}\t${option.description}`); - if ('default' in option) { - lines[lines.length - 1] += ` (default: ${option.default})`; - } - } - } - - lines.push(''); - - if (Object.keys(this.commands).length > 0) { - lines.push('Options:'); - for (const [, command] of Object.entries(this.commands)) { - lines.push(`${command.usage}\t${command.description}`); - } - } - - return lines.join('\n') + '\n'; - } -} diff --git a/tribes-cli/utils/mod.ts b/tribes-cli/utils/mod.ts deleted file mode 100644 index a9dd1951..00000000 --- a/tribes-cli/utils/mod.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { getOptionName, ParsedArgs } from './parsing.ts'; -import { Command } from './command.ts'; -import { defaultIdentityFile } from './ssh/identity.ts'; -import { makeSshAction, privkeyToPubkey } from './ssh/mod.ts'; - -export type Option = - & { description: string; required?: true } - & ({ - default?: string; - } | { - bool: true; - default?: boolean; - }); - -export const cleanArg = (arg: string) => { - return arg.replace(/^--?/g, ''); -}; - -export async function runLocally(cmd: string) { - console.log(cmd); - const command = new Deno.Command('bash', { - args: ['-c', cmd], - }); - - return command.output(); -} - -export { Command, defaultIdentityFile, getOptionName, makeSshAction, privkeyToPubkey }; -export type { ParsedArgs }; diff --git a/tribes-cli/utils/parsing.ts b/tribes-cli/utils/parsing.ts deleted file mode 100644 index 0f96a2a8..00000000 --- a/tribes-cli/utils/parsing.ts +++ /dev/null @@ -1,58 +0,0 @@ -import { cleanArg, Command } from './mod.ts'; -export type ParsedArgs = ReturnType['parsed']; - -export interface ParsedSubcommand { - string: string[]; - boolean: string[]; - alias: Record; - default: Record; -} - -export const defaultParsedCommand = (): ParsedSubcommand => { - return { - string: [], - boolean: ['help'], - alias: { help: ['h'] }, - default: {}, - }; -}; - -const getOptionAliases = (fmt: string) => { - const split = fmt.split(' ').toSorted((a, b) => b.length - a.length).map(cleanArg); - return split; -}; - -export const getOptionName = (fmt: string) => { - const split = getOptionAliases(fmt); - return split[0]; -}; - -export const parseCommand = (command: Command, existing?: Partial): ParsedSubcommand => { - const res = Object.assign(defaultParsedCommand(), existing); - - if (command.options) { - for (const option in command.options) { - const split = getOptionAliases(option); - const name = split[0]; - const body = command.options[option]; - if ('bool' in body) { - res.boolean.push(name); - } else { - res.string.push(name); - } - res.alias[name] = split; - } - } - - if (command.commands) { - for (const subcommand in command.commands) { - const parsed = parseCommand(command.commands[subcommand]); - Object.assign(res.alias, parsed.alias); - Object.assign(res.default, parsed.default); - res.boolean.push(...parsed.boolean); - res.string.push(...parsed.string); - } - } - - return res; -}; diff --git a/tribes-cli/utils/ssh/identity.ts b/tribes-cli/utils/ssh/identity.ts deleted file mode 100644 index 5cb83cfd..00000000 --- a/tribes-cli/utils/ssh/identity.ts +++ /dev/null @@ -1,60 +0,0 @@ -import { join } from '@std/path'; -import { expandGlob } from '@std/fs/expand-glob'; -import question from 'question-deno'; - -const resolveIdentityFile = async () => { - const home = Deno.env.get('HOME'); - if (!home) throw new Error('tribes-cli: unable to find default identity file'); - const path = join(home, '.ssh', '*.pub'); - const found: string[] = []; - for await (const file of expandGlob(path)) { - try { - const pkey = file.path.replace(/.pub$/, ''); - const info = await Deno.stat(pkey); - if (info.isFile) found.push(pkey); - } catch {} - } - if (found.length) { - const chosen = await question('list', 'There were multiple ssh keys found. Which do you want to use?', [ - ...found, - 'Something else', - ]); - if (chosen?.startsWith('/')) return chosen; - } - - const input = await question('input', 'Enter the path of the ssh identity file to use.'); - try { - const stat = await Deno.stat(input || ''); - if (stat.isFile) return input!; - } catch { - throw new Error(`tribes-cli: could not stat "${input}"`); - } - - throw new Error('tribes-cli: unable to find valid identity file, or selected path is a directory'); -}; - -export const defaultIdentityFile = async (supplied?: string) => { - const cached = localStorage.getItem('identity-file-path'); - if ( - cached && cached !== supplied && - await question('confirm', `Found previously-used identity file ${cached}. Use it?`, true) - ) { - return cached; - } - - let identityFile = ''; - - if (supplied) { - try { - const stat = await Deno.stat(supplied); - if (stat.isFile) identityFile = supplied; - } catch { - throw new Error(`tribes-cli: supplied identity file ${supplied} does not exist or is not a file.`); - } - } - - if (!identityFile) identityFile = await resolveIdentityFile(); - localStorage.setItem('identity-file-path', identityFile); - - return identityFile; -}; diff --git a/tribes-cli/utils/ssh/mod.ts b/tribes-cli/utils/ssh/mod.ts deleted file mode 100644 index 8a2c62f5..00000000 --- a/tribes-cli/utils/ssh/mod.ts +++ /dev/null @@ -1,118 +0,0 @@ -import { NodeSSH } from 'node-ssh'; -import { ParsedArgs, runLocally } from '../mod.ts'; - -const commands = { - DOKKU_SET_ADMIN_PUBKEY(identity: string) { - return `bash -c "echo \\"${identity}\\" | dokku ssh-keys:add admin"`; - }, - DOKKU_INSTALL_PLUGIN(plugin: string, opts: { source: 'dokku' | 'custom' } = { source: 'dokku' }) { - if (opts.source === 'dokku') { - return `dokku plugin:install https://github.com/dokku/dokku-${plugin}.git`; - } - return `dokku plugin:install ${plugin}`; - }, - DOKKU_CREATE_POSTGRES_SERVICE(name: string) { - return `dokku postgres:create ${name}`; - }, - DOKKU_SET_GLOBAL_DOMAIN(domain: string) { - return `dokku domains:set-global "${domain}"`; - }, - DOKKU_LETSENCRYPT_SETUP_EMAIL(email: string) { - return `dokku letsencrypt:set --global email "${email}"`; - }, - DOKKU_LETSENCRYPT_CRON() { - return 'dokku letsencrypt:cron-job --add'; - }, - DOKKU_CREATE_APP(name: string) { - return `dokku apps:create ${name}`; - }, - DOKKU_LINK_PG(app: string, service: string) { - return `dokku postgres:link ${service} ${app}`; - }, - DOKKU_SET_CONFIG_VAR(app: string, key: string, value: string, restart = false) { - return `dokku config:set ${restart ? '' : '--no-restart'} ${app} ${key}="${value}"`; - }, - DOKKU_LOAD_GIT(app: string, repo: string, pathspec?: string) { - return `dokku git:sync ${app} ${repo} ${pathspec || ''}`; - }, - DOKKU_LE_ENABLE(app: string) { - return `dokku letsencrypt:enable ${app}`; - }, - DOKKU_REBUILD(app: string) { - return `dokku ps:rebuild ${app}`; - }, -}; - -class TribesClient { - ssh: NodeSSH; - constructor(ssh: NodeSSH) { - this.ssh = ssh; - } - /** - * Execute a command on the Tribes remote, storing the output for use. Throws an error with stderr if the exit code was nonzero. - * @param name The name of the command to execute. - * @param args The args for the command. - * @returns The standard output of the command. - */ - async do(name: K, ...args: Parameters) { - // @ts-ignore this is okay because typescript enforces the correct args at the callsite - const cmd = commands[name](...args); - console.log(`=> ${cmd}`); - const res = await this.ssh.execCommand(cmd); - if (res.code) throw new Error(`Error executing ${name}: ${res.stderr}`); - return res.stdout; - } - /** - * Execute a command on the remote, printing the output after execution. - * @param name The name of the command to execute. - * @param args The args for the command. - * @returns The standard output of the command. - */ - async loud(name: K, ...args: Parameters) { - const stdout = await this.do(name, ...args); - console.log(stdout); - } - async disconnect() { - this.ssh.dispose(); - } -} - -interface HandlerOptions { - args: ParsedArgs; - arg: (name: string) => string; - tribes: TribesClient; - domain: string; -} - -export const makeSshAction = (handler: (opts: HandlerOptions) => Promise | void) => { - return async (args: ParsedArgs) => { - const arg = (name: string) => args[name] as string; - const domain = args._[0] as string; - const tribes = await connect(domain, arg('identity-file')); - await handler({ args, arg, tribes, domain }); - console.log('Done.'); - tribes.disconnect(); - }; -}; - -const ssh = new NodeSSH(); -const connect = async (host: string, identityFile: string) => { - if (ssh.isConnected() && ssh.connection?.host === host) return new TribesClient(ssh); - await ssh.connect({ - host, - username: 'root', - privateKeyPath: identityFile, - }); - return new TribesClient(ssh); -}; - -export const privkeyToPubkey = async (path: string) => { - const result = await runLocally(`ssh-keygen -y -f ${path}`); - const decoder = new TextDecoder(); - if (result.code) { - throw new Error( - `tribes-cli: error generating public key from private key ${path}:` + ` ${decoder.decode(result.stderr)}`, - ); - } - return decoder.decode(result.stdout); -};