mirror of
https://gitlab.com/soapbox-pub/ditto.git
synced 2025-12-06 11:29:46 +00:00
tribes cli got its own repo
This commit is contained in:
parent
65e360d22a
commit
5bda39257f
18 changed files with 0 additions and 760 deletions
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
@ -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');
|
||||
}));
|
||||
|
|
@ -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);
|
||||
|
|
@ -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
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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");
|
||||
|
|
@ -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!');
|
||||
|
|
@ -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);
|
||||
|
|
@ -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);
|
||||
}));
|
||||
|
|
@ -1,6 +0,0 @@
|
|||
/**
|
||||
* Update a tribe's software.
|
||||
*/
|
||||
|
||||
export const update = () => {
|
||||
};
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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';
|
||||
}
|
||||
}
|
||||
|
|
@ -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 };
|
||||
|
|
@ -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;
|
||||
};
|
||||
|
|
@ -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;
|
||||
};
|
||||
|
|
@ -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);
|
||||
};
|
||||
Loading…
Add table
Reference in a new issue