tribes cli got its own repo

This commit is contained in:
Siddharth Singh 2024-10-02 00:55:33 +05:30
parent 65e360d22a
commit 5bda39257f
No known key found for this signature in database
18 changed files with 0 additions and 760 deletions

View file

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

View file

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

View file

@ -1,47 +0,0 @@
#!/usr/bin/env bash
wait_keypress() {
echo "Press Enter to proceed..."
read
}
if [ "$#" -ne 2 ]; then
echo "Usage: $0 <instance domain> <uploader config>"
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

View file

@ -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');
}));

View file

@ -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);

View file

@ -1,21 +0,0 @@
#!/usr/bin/env bash
if [ "$#" -ne 3 ]; then
echo "Usage: $0 <dokku server address> <global domain> <email for letsencrypt>"
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

View file

@ -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<typeof parseUploaderConfig>;
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);
}

View file

@ -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");

View file

@ -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!');

View file

@ -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);

View file

@ -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<string, string | undefined> = {};
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);
}));

View file

@ -1,6 +0,0 @@
/**
* Update a tribe's software.
*/
export const update = () => {
};

View file

@ -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<string, string | undefined> = {};
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);
}

View file

@ -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<void> = (_) => {};
options: Record<string, Option> = {};
commands: Record<string, Command> = {};
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';
}
}

View file

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

View file

@ -1,58 +0,0 @@
import { cleanArg, Command } from './mod.ts';
export type ParsedArgs = ReturnType<Command['parse']>['parsed'];
export interface ParsedSubcommand {
string: string[];
boolean: string[];
alias: Record<string, string[]>;
default: Record<string, string | boolean>;
}
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>): 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;
};

View file

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

View file

@ -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<K extends keyof typeof commands>(name: K, ...args: Parameters<typeof commands[K]>) {
// @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<K extends keyof typeof commands>(name: K, ...args: Parameters<typeof commands[K]>) {
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> | 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);
};