448 lines
12 KiB
TypeScript
448 lines
12 KiB
TypeScript
import fs from 'node:fs';
|
|
import os from 'node:os';
|
|
import path from 'node:path';
|
|
import bcrypt from 'bcrypt';
|
|
import inquirer from 'inquirer';
|
|
import yaml from 'js-yaml';
|
|
|
|
let envs = {
|
|
CLICKHOUSE_URL: '',
|
|
REDIS_URL: '',
|
|
DATABASE_URL: '',
|
|
DOMAIN_NAME: '',
|
|
COOKIE_SECRET: generatePassword(32),
|
|
RESEND_API_KEY: '',
|
|
EMAIL_SENDER: '',
|
|
};
|
|
|
|
type EnvVars = typeof envs;
|
|
|
|
const addEnvs = (env: Partial<EnvVars>) => {
|
|
envs = {
|
|
...envs,
|
|
...env,
|
|
};
|
|
};
|
|
|
|
function generatePassword(length: number) {
|
|
const charset =
|
|
'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
|
|
let password = '';
|
|
for (let i = 0, n = charset.length; i < length; ++i) {
|
|
password += charset.charAt(Math.floor(Math.random() * n));
|
|
}
|
|
return password;
|
|
}
|
|
|
|
function writeCaddyfile(domainName: string, basicAuthPassword: string) {
|
|
const caddyfileTemplatePath = path.resolve(
|
|
import.meta.dirname,
|
|
'caddy',
|
|
'Caddyfile.template'
|
|
);
|
|
const caddyfilePath = path.resolve(import.meta.dirname, 'caddy', 'Caddyfile');
|
|
fs.writeFileSync(
|
|
caddyfilePath,
|
|
fs
|
|
.readFileSync(caddyfileTemplatePath, 'utf-8')
|
|
.replaceAll('$DOMAIN_NAME', domainName.replace(/https?:\/\//, ''))
|
|
.replaceAll(
|
|
'$BASIC_AUTH_PASSWORD',
|
|
bcrypt.hashSync(basicAuthPassword, 10)
|
|
)
|
|
.replaceAll(
|
|
'$SSL_CONFIG',
|
|
domainName.includes('localhost:443') ? '\n\ttls internal' : ''
|
|
)
|
|
);
|
|
}
|
|
|
|
export interface DockerComposeFile {
|
|
version: string;
|
|
services: Record<
|
|
string,
|
|
{
|
|
image: string;
|
|
restart: string;
|
|
ports: string[];
|
|
volumes: string[];
|
|
depends_on?: Record<string, { condition: string }> | string[];
|
|
}
|
|
>;
|
|
volumes?: Record<string, unknown>;
|
|
}
|
|
|
|
const stripTrailingSlash = (str: string) =>
|
|
str.endsWith('/') ? str.slice(0, -1) : str;
|
|
|
|
function searchAndReplaceDockerCompose(replacements: [string, string][]) {
|
|
const dockerComposePath = path.resolve(
|
|
import.meta.dirname,
|
|
'docker-compose.yml'
|
|
);
|
|
const dockerComposeContent = fs.readFileSync(dockerComposePath, 'utf-8');
|
|
const dockerComposeReplaced = replacements.reduce(
|
|
(acc, [search, replace]) => acc.replaceAll(search, replace),
|
|
dockerComposeContent
|
|
);
|
|
|
|
fs.writeFileSync(dockerComposePath, dockerComposeReplaced);
|
|
}
|
|
|
|
function removeServiceFromDockerCompose(serviceName: string) {
|
|
const dockerComposePath = path.resolve(
|
|
import.meta.dirname,
|
|
'docker-compose.yml'
|
|
);
|
|
const dockerComposeContent = fs.readFileSync(dockerComposePath, 'utf-8');
|
|
|
|
// Parse the YAML file
|
|
const dockerCompose = yaml.load(dockerComposeContent) as DockerComposeFile;
|
|
|
|
// Remove the service
|
|
if (dockerCompose.services[serviceName]) {
|
|
delete dockerCompose.services[serviceName];
|
|
console.log(`Service '${serviceName}' has been removed.`);
|
|
} else {
|
|
console.log(`Service '${serviceName}' not found.`);
|
|
// return;
|
|
}
|
|
|
|
// filter depends_on
|
|
Object.keys(dockerCompose.services).forEach((service) => {
|
|
const serviceConfig = dockerCompose.services[service];
|
|
if (serviceConfig?.depends_on) {
|
|
if (Array.isArray(serviceConfig.depends_on)) {
|
|
// Handle legacy array format
|
|
serviceConfig.depends_on = serviceConfig.depends_on.filter(
|
|
(dep) => dep !== serviceName
|
|
);
|
|
} else {
|
|
// Handle new object format
|
|
if (serviceConfig.depends_on[serviceName]) {
|
|
delete serviceConfig.depends_on[serviceName];
|
|
}
|
|
}
|
|
}
|
|
});
|
|
|
|
// filter volumes
|
|
Object.keys(dockerCompose.volumes ?? {}).forEach((volume) => {
|
|
if (dockerCompose.volumes && volume.startsWith(serviceName)) {
|
|
delete dockerCompose.volumes[volume];
|
|
}
|
|
});
|
|
|
|
if (Object.keys(dockerCompose.volumes ?? {}).length === 0) {
|
|
delete dockerCompose.volumes;
|
|
}
|
|
|
|
// Convert the object back to YAML
|
|
const newYaml = yaml.dump(dockerCompose, {
|
|
lineWidth: -1,
|
|
});
|
|
fs.writeFileSync(dockerComposePath, newYaml);
|
|
}
|
|
|
|
function writeEnvFile(envs: EnvVars) {
|
|
const envTemplatePath = path.resolve(import.meta.dirname, '.env.template');
|
|
const envPath = path.resolve(import.meta.dirname, '.env');
|
|
const envTemplate = fs.readFileSync(envTemplatePath, 'utf-8');
|
|
|
|
const newEnvFile = envTemplate
|
|
.replace('$COOKIE_SECRET', envs.COOKIE_SECRET)
|
|
.replace('$CLICKHOUSE_URL', envs.CLICKHOUSE_URL)
|
|
.replace('$REDIS_URL', envs.REDIS_URL)
|
|
.replace('$DATABASE_URL', envs.DATABASE_URL)
|
|
.replace('$DATABASE_URL_DIRECT', envs.DATABASE_URL)
|
|
.replace('$DASHBOARD_URL', stripTrailingSlash(envs.DOMAIN_NAME))
|
|
.replace('$API_URL', `${stripTrailingSlash(envs.DOMAIN_NAME)}/api`)
|
|
.replace('$RESEND_API_KEY', envs.RESEND_API_KEY)
|
|
.replace('$EMAIL_SENDER', envs.EMAIL_SENDER);
|
|
|
|
fs.writeFileSync(
|
|
envPath,
|
|
newEnvFile
|
|
.split('\n')
|
|
.filter((line) => {
|
|
return !line.includes('=""');
|
|
})
|
|
.join('\n')
|
|
);
|
|
}
|
|
|
|
async function initiateOnboarding() {
|
|
const T = ' ';
|
|
const message = [
|
|
'',
|
|
'DISCLAIMER: This script is provided as-is and without warranty. Use at your own risk.',
|
|
'',
|
|
'',
|
|
'WORTH MENTIONING: This is an early version of the script and it may not cover all scenarios.',
|
|
' We recommend using our cloud service for production workloads until we release a stable version of self-hosting.',
|
|
'',
|
|
'',
|
|
"With that said let's get started! 🤠",
|
|
'',
|
|
`Hey and welcome to Openpanel's self-hosting setup! 🚀\n`,
|
|
'Before you continue, please make sure you have the following:',
|
|
`${T}1. Docker and Docker Compose installed on your machine.`,
|
|
`${T}2. A domain name that you can use for this setup and point it to this machine's ip`,
|
|
'For more information you can read our article on self-hosting at https://openpanel.dev/docs/self-hosting/self-hosting\n',
|
|
'',
|
|
'',
|
|
'Consider supporting us by becoming a supporter: https://openpanel.dev/supporter (pay what you want and help us keep the lights on)',
|
|
];
|
|
|
|
console.log(
|
|
'******************************************************************************\n'
|
|
);
|
|
console.log(message.join('\n'));
|
|
console.log(
|
|
'\n******************************************************************************'
|
|
);
|
|
|
|
// Domain name
|
|
|
|
const domain = await inquirer.prompt([
|
|
{
|
|
type: 'input',
|
|
name: 'DOMAIN_NAME',
|
|
message: "What's the domain name you want to use?",
|
|
default: process.env.DEBUG ? 'http://localhost' : undefined,
|
|
prefix: '🌐',
|
|
validate: (value) => {
|
|
if (value.startsWith('http://') || value.startsWith('https://')) {
|
|
return true;
|
|
}
|
|
|
|
return 'Please enter a valid domain name. Should start with "http://" or "https://"';
|
|
},
|
|
},
|
|
]);
|
|
|
|
addEnvs(domain);
|
|
|
|
// Dependencies
|
|
|
|
const dependenciesResponse = await inquirer.prompt([
|
|
{
|
|
type: 'checkbox',
|
|
name: 'dependencies',
|
|
message: 'Which of these dependencies will you need us to install?',
|
|
choices: ['Clickhouse', 'Redis', 'Postgres'],
|
|
default: ['Clickhouse', 'Redis', 'Postgres'],
|
|
prefix: '📦',
|
|
},
|
|
]);
|
|
|
|
if (!dependenciesResponse.dependencies.includes('Clickhouse')) {
|
|
const clickhouseResponse = await inquirer.prompt([
|
|
{
|
|
type: 'input',
|
|
name: 'CLICKHOUSE_URL',
|
|
message:
|
|
'Enter your ClickHouse URL (format: http://user:pw@host:port/db):',
|
|
default: process.env.DEBUG ? 'http://op-ch:8123/openpanel' : undefined,
|
|
},
|
|
]);
|
|
|
|
addEnvs(clickhouseResponse);
|
|
}
|
|
|
|
if (!dependenciesResponse.dependencies.includes('Redis')) {
|
|
const redisResponse = await inquirer.prompt([
|
|
{
|
|
type: 'input',
|
|
name: 'REDIS_URL',
|
|
message: 'Enter your Redis URL (format: redis://user:pw@host:port/db):',
|
|
default: process.env.DEBUG ? 'redis://op-kv:6379' : undefined,
|
|
},
|
|
]);
|
|
|
|
addEnvs(redisResponse);
|
|
}
|
|
|
|
if (!dependenciesResponse.dependencies.includes('Postgres')) {
|
|
const dbResponse = await inquirer.prompt([
|
|
{
|
|
type: 'input',
|
|
name: 'DATABASE_URL',
|
|
message:
|
|
'Enter your Database URL (format: postgresql://user:pw@host:port/db):',
|
|
default: process.env.DEBUG
|
|
? 'postgresql://postgres:postgres@op-db:5432/postgres?schema=public'
|
|
: undefined,
|
|
},
|
|
]);
|
|
|
|
addEnvs(dbResponse);
|
|
}
|
|
|
|
// Proxy
|
|
|
|
const proxyResponse = await inquirer.prompt([
|
|
{
|
|
type: 'list',
|
|
name: 'proxy',
|
|
message:
|
|
'Do you already have a web service setup or would you like us to install Caddy with SSL?',
|
|
choices: ['Install Caddy with SSL', 'Bring my own'],
|
|
},
|
|
]);
|
|
|
|
// OS
|
|
|
|
const cpus = await inquirer.prompt([
|
|
{
|
|
type: 'input',
|
|
name: 'CPUS',
|
|
default: Math.max(Math.floor(os.cpus().length / 2), 1),
|
|
message:
|
|
'How many workers do you want to spawn (in many cases 1-2 is enough)?',
|
|
validate: (value) => {
|
|
const parsed = Number.parseInt(value, 10);
|
|
|
|
if (Number.isNaN(parsed)) {
|
|
return 'Please enter a valid number';
|
|
}
|
|
|
|
if (parsed < 1) {
|
|
return 'Please enter a number greater than 0';
|
|
}
|
|
|
|
return true;
|
|
},
|
|
},
|
|
]);
|
|
|
|
const resend = await inquirer.prompt<{
|
|
RESEND_API_KEY: string;
|
|
}>([
|
|
{
|
|
type: 'input',
|
|
name: 'RESEND_API_KEY',
|
|
message: 'Enter your Resend API key (optional):',
|
|
},
|
|
]);
|
|
|
|
if (resend.RESEND_API_KEY) {
|
|
const emailSender = await inquirer.prompt<{
|
|
email: string;
|
|
}>([
|
|
{
|
|
type: 'input',
|
|
name: 'EMAIL_SENDER',
|
|
default: `no-reply@${envs.DOMAIN_NAME.replace(/https?:\/\//, '')}`,
|
|
message: 'The email which will be used to send out emails:',
|
|
validate: (value) => {
|
|
if (!value) {
|
|
return 'Field is required';
|
|
}
|
|
|
|
if (!value.includes('@')) {
|
|
return 'Please enter a valid email';
|
|
}
|
|
|
|
return true;
|
|
},
|
|
},
|
|
]);
|
|
|
|
addEnvs({
|
|
...resend,
|
|
...emailSender,
|
|
});
|
|
}
|
|
|
|
const basicAuth = await inquirer.prompt<{
|
|
password: string;
|
|
}>([
|
|
{
|
|
type: 'input',
|
|
name: 'password',
|
|
default: generatePassword(12),
|
|
message: 'Give a password for basic auth',
|
|
validate: (value) => {
|
|
if (!value) {
|
|
return 'Please enter a valid password';
|
|
}
|
|
|
|
if (value.length < 5) {
|
|
return 'Password should be atleast 5 characters';
|
|
}
|
|
|
|
return true;
|
|
},
|
|
},
|
|
]);
|
|
|
|
console.log('');
|
|
console.log('Creating .env file...\n');
|
|
|
|
writeEnvFile({
|
|
CLICKHOUSE_URL: envs.CLICKHOUSE_URL || 'http://op-ch:8123/openpanel',
|
|
REDIS_URL: envs.REDIS_URL || 'redis://op-kv:6379',
|
|
DATABASE_URL:
|
|
envs.DATABASE_URL ||
|
|
'postgresql://postgres:postgres@op-db:5432/postgres?schema=public',
|
|
DOMAIN_NAME: envs.DOMAIN_NAME,
|
|
COOKIE_SECRET: envs.COOKIE_SECRET,
|
|
RESEND_API_KEY: envs.RESEND_API_KEY || '',
|
|
EMAIL_SENDER: envs.EMAIL_SENDER || '',
|
|
});
|
|
|
|
console.log('Updating docker-compose.yml file...\n');
|
|
fs.copyFileSync(
|
|
path.resolve(import.meta.dirname, 'docker-compose.template.yml'),
|
|
path.resolve(import.meta.dirname, 'docker-compose.yml')
|
|
);
|
|
|
|
if (envs.CLICKHOUSE_URL) {
|
|
removeServiceFromDockerCompose('op-ch');
|
|
removeServiceFromDockerCompose('op-ch-migrator');
|
|
}
|
|
|
|
if (envs.REDIS_URL) {
|
|
removeServiceFromDockerCompose('op-kv');
|
|
}
|
|
|
|
if (envs.DATABASE_URL) {
|
|
removeServiceFromDockerCompose('op-db');
|
|
}
|
|
|
|
if (proxyResponse.proxy === 'Bring my own') {
|
|
removeServiceFromDockerCompose('op-proxy');
|
|
} else {
|
|
writeCaddyfile(envs.DOMAIN_NAME, basicAuth.password);
|
|
}
|
|
|
|
searchAndReplaceDockerCompose([['$OP_WORKER_REPLICAS', cpus.CPUS]]);
|
|
|
|
console.log(
|
|
[
|
|
'======================================================================',
|
|
'Here are some good things to know before you continue:',
|
|
'',
|
|
'1. Commands:',
|
|
'\t- ./start (example: ./start)',
|
|
'\t- ./stop (example: ./stop)',
|
|
'\t- ./logs (example: ./logs)',
|
|
'\t- ./rebuild (example: ./rebuild op-dashboard)',
|
|
'\t- ./update (example: ./update) pulls the latest docker images and restarts the service',
|
|
'',
|
|
'2. Danger zone!',
|
|
'\t- ./danger_wipe_everything (example: ./danger_wipe_everything)',
|
|
'',
|
|
'3. More about self-hosting: https://openpanel.dev/docs/self-hosting/self-hosting',
|
|
'======================================================================',
|
|
'',
|
|
`Start OpenPanel with "./start" inside the self-hosting directory`,
|
|
'',
|
|
'',
|
|
].join('\n')
|
|
);
|
|
}
|
|
|
|
initiateOnboarding();
|