UNPKG

@underpostnet/underpost

Version:

Underpost Platform — end-to-end CI/CD and application-delivery toolchain CLI. Covers bare metal, Kubernetes, K3s, kubeadm, LXD, container/image orchestration, secrets, databases, cron jobs, monitoring, SSH, runners, PWA + Workbox delivery, and release orc

1,169 lines (1,042 loc) 66.3 kB
import fs from 'fs-extra'; import axios from 'axios'; import dotenv from 'dotenv'; import { pbcopy, shellExec } from '../src/server/process.js'; import { loggerFactory } from '../src/server/logger.js'; import { addApiConf, addClientConf, buildApiSrc, buildClientSrc, cloneConf, addWsConf, buildWsSrc, cloneSrcComponents, writeEnv, buildCliDoc, loadConf, } from '../src/server/conf.js'; import colors from 'colors'; import { program } from '../src/cli/index.js'; import { timer, getCapVariableName } from '../src/client/components/core/CommonJs.js'; import Underpost from '../src/index.js'; colors.enable(); const logger = loggerFactory(import.meta); logger.info('argv', process.argv); const [exe, dir, operator] = process.argv; try { switch (operator) { case 'add-nodejs-app-client-conf': { const toOptions = { deployId: process.argv[3], clientId: process.argv[4], host: process.argv[5], path: process.argv[6], }; const fromOptions = { deployId: process.argv[7], clientId: process.argv[8] }; addClientConf({ toOptions, fromOptions }); } break; case 'build-nodejs-conf-app': { const toOptions = { deployId: process.argv[3], clientId: process.argv[4] }; const fromOptions = { deployId: process.argv[5], clientId: process.argv[6] }; cloneConf({ toOptions, fromOptions }); } break; case 'clone-nodejs-src-client-components': { const fromOptions = { componentsFolder: process.argv[3] }; const toOptions = { componentsFolder: process.argv[4] }; cloneSrcComponents({ toOptions, fromOptions }); } break; case 'build-nodejs-src-app': { const toOptions = { deployId: process.argv[3], clientId: process.argv[4] }; const fromOptions = { deployId: process.argv[5], clientId: process.argv[6] }; buildClientSrc({ toOptions, fromOptions }); } break; case 'build-nodejs-conf-api': { const toOptions = { apiId: process.argv[3], deployId: process.argv[4], clientId: process.argv[5] }; const fromOptions = { apiId: process.argv[6], deployId: process.argv[7], clientId: process.argv[8] }; addApiConf({ toOptions, fromOptions }); } break; case 'build-nodejs-src-api': { const toOptions = { apiId: process.argv[3], deployId: process.argv[4], clientId: process.argv[5] }; const fromOptions = { apiId: process.argv[6], deployId: process.argv[7], clientId: process.argv[8] }; buildApiSrc({ toOptions, fromOptions }); } break; case 'build-nodejs-conf-ws': { const toOptions = { wsId: process.argv[3], deployId: process.argv[4], host: process.argv[5], paths: process.argv[6], }; const fromOptions = { wsId: process.argv[7], deployId: process.argv[8], host: process.argv[9], paths: process.argv[10], }; addWsConf({ toOptions, fromOptions }); } break; case 'build-nodejs-src-ws': { const toOptions = { wsId: process.argv[3], deployId: process.argv[4], host: process.argv[5], paths: process.argv[6], }; const fromOptions = { wsId: process.argv[7], deployId: process.argv[8], host: process.argv[9], paths: process.argv[10], }; buildWsSrc({ toOptions, fromOptions }); } break; case 'new-nodejs-app': { const deployId = process.argv[3]; const clientId = process.argv[4]; shellExec(`node bin/deploy build-nodejs-conf-app ${deployId} ${clientId}`); shellExec(`node bin/deploy build-nodejs-src-app ${deployId} ${clientId}`); await Underpost.repo.client(deployId); shellExec(`npm run dev ${deployId}`); } break; case 'test-new-api': { const port = process.argv[3]; const apiId = process.argv[4]; let url = `http://localhost:${port}/api/${apiId}`; { logger.info(`POST REQUEST`, url); const result = await axios.post(url, {}); url += '/' + result.data.data._id; logger.info(`POST RESULT ${url}`, result.data); } { logger.info(`GET REQUEST`, url); const result = await axios.get(url); logger.info(`GET RESULT ${url}`, result.data); } { logger.info(`DELETE REQUEST`, url); const result = await axios.delete(url); logger.info(`DELETE RESULT ${url}`, result.data); } } break; case 'new-nodejs-api': { const apiId = process.argv[3]; const deployId = process.argv[4]; const clientId = process.argv[5]; shellExec(`node bin/deploy build-nodejs-conf-api ${apiId} ${deployId} ${clientId}`); shellExec(`node bin/deploy build-nodejs-src-api ${apiId} ${deployId} ${clientId}`); } break; case 'new-nodejs-ws': { const wsId = process.argv[3]; const deployId = process.argv[4]; const host = process.argv[5]; const paths = process.argv[6]; shellExec(`node bin/deploy build-nodejs-conf-ws ${wsId} ${deployId} ${host} ${paths}`); shellExec(`node bin/deploy build-nodejs-src-ws ${wsId} ${deployId} ${host} ${paths}`); shellExec(`npm run dev ${deployId}`); } break; case 'update-dependencies': const files = await fs.readdir(`./engine-private/conf`, { recursive: true }); const originPackage = JSON.parse(fs.readFileSync(`./package.json`, 'utf8')); for (const relativePath of files) { const filePah = `./engine-private/conf/${relativePath.replaceAll(`\\`, '/')}`; if (filePah.split('/').pop() === 'package.json') { const deployPackage = JSON.parse(fs.readFileSync(filePah, 'utf8')); deployPackage.dependencies = originPackage.dependencies; deployPackage.devDependencies = originPackage.devDependencies; deployPackage.overrides = originPackage.overrides; fs.writeFileSync(filePah, JSON.stringify(deployPackage, null, 4), 'utf8'); } } break; case 'rename-package': { const name = process.argv[3]; const originPackage = JSON.parse(fs.readFileSync(`./package.json`, 'utf8')); originPackage.name = name; fs.writeFileSync(`./package.json`, JSON.stringify(originPackage, null, 4), 'utf8'); const originPackageLockJson = JSON.parse(fs.readFileSync(`./package-lock.json`, 'utf8')); originPackageLockJson.name = name; originPackageLockJson.packages[''].name = name; fs.writeFileSync(`./package-lock.json`, JSON.stringify(originPackageLockJson, null, 4), 'utf8'); break; } case 'set-repo': { const originPackage = JSON.parse(fs.readFileSync(`./package.json`, 'utf8')); originPackage.repository = { type: 'git', url: `git+https://github.com/${process.argv[3]}.git`, }; fs.writeFileSync(`./package.json`, JSON.stringify(originPackage, null, 4), 'utf8'); break; } case 'build-default-confs': { for (const deployId of fs .readFileSync(`./engine-private/deploy/dd.router`, 'utf8') .split(`,`) .concat(['dd-cron'])) { if (fs.existsSync(`./engine-private/conf/${deployId}/build/development/deployment.yaml`)) fs.copySync( `./engine-private/conf/${deployId}/build/development/deployment.yaml`, `./manifests/deployment/${deployId}-development/deployment.yaml`, ); if (fs.existsSync(`./engine-private/conf/${deployId}/build/development/proxy.yaml`)) fs.copySync( `./engine-private/conf/${deployId}/build/development/proxy.yaml`, `./manifests/deployment/${deployId}-development/proxy.yaml`, ); if (fs.existsSync(`./engine-private/conf/${deployId}/build/development/pv-pvc.yaml`)) fs.copySync( `./engine-private/conf/${deployId}/build/development/pv-pvc.yaml`, `./manifests/deployment/${deployId}-development/pv-pvc.yaml`, ); shellExec(`node bin new --dev --default-conf --deploy-id ${deployId}`); } break; } case 'update-authors': { // #### Ordered by first contribution. fs.writeFileSync( './AUTHORS.md', `# Authors ${shellExec(`git log | grep Author: | sort -u`, { stdout: true }).split(`\n`).join(`\n\n\n`)} #### Generated by [underpost.net](https://underpost.net)`, 'utf8', ); break; } case 'maas-db': { // DROP, ALTER, CREATE, WITH ENCRYPTED // sudo -u <user> -h <host> psql <db-name> shellExec(`DB_PG_MAAS_NAME=${process.env.DB_PG_MAAS_NAME}`); shellExec(`DB_PG_MAAS_PASS=${process.env.DB_PG_MAAS_PASS}`); shellExec(`DB_PG_MAAS_USER=${process.env.DB_PG_MAAS_USER}`); shellExec(`DB_PG_MAAS_HOST=${process.env.DB_PG_MAAS_HOST}`); shellExec( `sudo -i -u postgres psql -c "CREATE USER \"$DB_PG_MAAS_USER\" WITH ENCRYPTED PASSWORD '$DB_PG_MAAS_PASS'"`, ); shellExec( `sudo -i -u postgres psql -c "ALTER USER \"$DB_PG_MAAS_USER\" WITH ENCRYPTED PASSWORD '$DB_PG_MAAS_PASS'"`, ); const actions = ['LOGIN', 'SUPERUSER', 'INHERIT', 'CREATEDB', 'CREATEROLE', 'REPLICATION']; shellExec(`sudo -i -u postgres psql -c "ALTER USER \"$DB_PG_MAAS_USER\" WITH ${actions.join(' ')}"`); shellExec(`sudo -i -u postgres psql -c "\\du"`); shellExec(`sudo -i -u postgres createdb -O "$DB_PG_MAAS_USER" "$DB_PG_MAAS_NAME"`); shellExec(`sudo -i -u postgres psql -c "\\l"`); break; } case 'cli-docs': { const version = Underpost.version; const newVersion = process.argv[4] && !process.argv[4].startsWith('v') ? `v${process.argv[4]}` : process.argv[4] || version; buildCliDoc(program, process.argv[3] || version, newVersion); break; } case 'postgresql-17': { if (process.argv.includes('install')) { shellExec(`sudo dnf module reset postgresql -y`); shellExec(`sudo dnf -qy module disable postgresql`); shellExec( `sudo dnf install -y https://download.postgresql.org/pub/repos/yum/reporpms/EL-9-x86_64/pgdg-redhat-repo-latest.noarch.rpm`, ); shellExec(`sudo dnf -qy module disable postgresql`); shellExec(`sudo dnf install -y postgresql17 postgresql17-server postgresql17-contrib`); shellExec(`sudo /usr/pgsql-17/bin/postgresql-17-setup initdb`); } if (process.argv.includes('uninstall')) { shellExec(`sudo systemctl stop postgresql-17`); shellExec(`sudo systemctl disable postgresql-17`); // Remove PostgreSQL 17 packages and repo shellExec(`sudo dnf remove -y postgresql17 postgresql17-server postgresql17-contrib`); shellExec(`sudo rpm -e pgdg-redhat-repo-$(rpm -q pgdg-redhat-repo --qf '%{VERSION}-%{RELEASE}')`); shellExec(`sudo rm -f /etc/yum.repos.d/pgdg-redhat-*.repo`); // Clean up data, logs, config, and the postgres user shellExec(`sudo rm -rf /var/lib/pgsql/17 /var/log/pgsql`); shellExec(`sudo rm -rf /etc/postgresql`); } else { shellExec(`sudo systemctl enable postgresql-17`); shellExec(`sudo systemctl start postgresql-17`); } break; } case 'pg-list-db': { shellExec(`sudo -i -u postgres psql -c "\\l"`); break; } case 'pg-list-table': { shellExec(`sudo -i -u postgres psql -c "\\dt *.*"`); // schema_name.* break; } case 'pg-drop-db': { shellExec(`sudo -i -u postgres psql -c "DROP DATABASE ${process.argv[3]} WITH (FORCE)"`); shellExec(`sudo -i -u postgres psql -c "DROP USER ${process.argv[4]}"`); break; } case 'maas-stop': { shellExec(`sudo snap stop maas`); break; } case 'fastapi-models': { shellExec(`chmod +x ../full-stack-fastapi-template/backend/initial_data.sh`); shellExec(`../full-stack-fastapi-template/backend/initial_data.sh`); shellExec(`../full-stack-fastapi-template/backend/initial_data.sh`); break; } case 'fastapi': { // node bin/deploy fastapi reset // node bin/deploy fastapi reset build-back build-front secret run-back run-front // https://github.com/NonsoEchendu/full-stack-fastapi-project // https://github.com/fastapi/full-stack-fastapi-template const path = `../full-stack-fastapi-template`; const VITE_API_URL = `http://localhost:8000`; if (process.argv.includes('reset')) shellExec(`sudo rm -rf ${path}`); if (!fs.existsSync(path)) shellExec(`cd .. && git clone https://github.com/fastapi/full-stack-fastapi-template.git`); shellExec(`cd ${path} && git checkout . && git clean -f -d`); const password = fs.readFileSync(`/home/dd/engine/engine-private/postgresql-password`, 'utf8'); fs.writeFileSync( `${path}/.env`, fs .readFileSync(`${path}/.env`, 'utf8') .replace( `FIRST_SUPERUSER=admin@example.com`, `FIRST_SUPERUSER=${process.env.GITHUB_EMAIL || 'development@underpost.net'}`, ) .replace(`FIRST_SUPERUSER_PASSWORD=changethis`, `FIRST_SUPERUSER_PASSWORD=${password}`) .replace(`SECRET_KEY=changethis`, `SECRET_KEY=${password}`) .replace(`POSTGRES_DB=app`, `POSTGRES_DB=postgresdb`) .replace(`POSTGRES_USER=postgres`, `POSTGRES_USER=admin`) .replace(`POSTGRES_PASSWORD=changethis`, `POSTGRES_PASSWORD=${password}`), 'utf8', ); fs.writeFileSync( `${path}/backend/app/core/db.py`, fs .readFileSync(`${path}/backend/app/core/db.py`, 'utf8') .replace(` # from sqlmodel import SQLModel`, ` from sqlmodel import SQLModel`) .replace(` # SQLModel.metadata.create_all(engine)`, ` SQLModel.metadata.create_all(engine)`), 'utf8', ); fs.copySync(`./manifests/deployment/fastapi/initial_data.sh`, `${path}/backend/initial_data.sh`); fs.writeFileSync( `${path}/frontend/Dockerfile`, fs .readFileSync(`${path}/frontend/Dockerfile`, 'utf8') .replace('ARG VITE_API_URL=${VITE_API_URL}', `ARG VITE_API_URL='${VITE_API_URL}'`), 'utf8', ); fs.writeFileSync( `${path}/frontend/.env`, fs .readFileSync(`${path}/frontend/.env`, 'utf8') .replace(`VITE_API_URL=http://localhost:8000`, `VITE_API_URL=${VITE_API_URL}`) .replace(`MAILCATCHER_HOST=http://localhost:1080`, `MAILCATCHER_HOST=http://localhost:1081`), 'utf8', ); if (process.argv.includes('models')) { shellExec(`node bin/deploy fastapi-models`); break; } if (process.argv.includes('build-back')) { const imageName = `fastapi-backend:latest`; shellExec(`sudo podman pull docker.io/library/python:3.10`); shellExec(`sudo podman pull ghcr.io/astral-sh/uv:0.5.11`); shellExec(`sudo rm -rf ${path}/${imageName.replace(':', '_')}.tar`); const args = [ `node bin image --build --path ${path}/backend/`, `--image-name=${imageName} --image-out-path=${path}`, `--podman-save --${process.argv.includes('kubeadm') ? 'kubeadm' : 'kind'} --reset`, ]; shellExec(args.join(' ')); } if (process.argv.includes('build-front')) { const imageName = `fastapi-frontend:latest`; shellExec(`sudo podman pull docker.io/library/node:20`); shellExec(`sudo podman pull docker.io/library/nginx:1`); shellExec(`sudo rm -rf ${path}/${imageName.replace(':', '_')}.tar`); const args = [ `node bin image --build --path ${path}/frontend/`, `--image-name=${imageName} --image-out-path=${path}`, `--podman-save --${process.argv.includes('kubeadm') ? 'kubeadm' : 'kind'} --reset`, ]; shellExec(args.join(' ')); } if (process.argv.includes('secret')) { const namespace = process.argv.find((arg) => arg.startsWith('--namespace='))?.split('=')[1] || 'default'; { const secretSelector = `fastapi-postgres-credentials`; shellExec(`sudo kubectl delete secret ${secretSelector} -n ${namespace} --ignore-not-found`); shellExec( `sudo kubectl create secret generic ${secretSelector}` + ` --from-literal=POSTGRES_DB=postgresdb` + ` --from-literal=POSTGRES_USER=admin` + ` --from-file=POSTGRES_PASSWORD=/home/dd/engine/engine-private/postgresql-password` + ` --dry-run=client -o yaml | kubectl apply -f - -n ${namespace}`, ); } { const secretSelector = `fastapi-backend-config-secret`; shellExec(`sudo kubectl delete secret ${secretSelector} -n ${namespace} --ignore-not-found`); shellExec( `sudo kubectl create secret generic ${secretSelector}` + ` --from-file=SECRET_KEY=/home/dd/engine/engine-private/postgresql-password` + ` --from-literal=FIRST_SUPERUSER=${process.env.GITHUB_EMAIL || 'development@underpost.net'}` + ` --from-file=FIRST_SUPERUSER_PASSWORD=/home/dd/engine/engine-private/postgresql-password` + ` --dry-run=client -o yaml | kubectl apply -f - -n ${namespace}`, ); } } if (process.argv.includes('run-back')) { const namespace = process.argv.find((arg) => arg.startsWith('--namespace='))?.split('=')[1] || 'default'; shellExec(`sudo kubectl apply -f ./manifests/deployment/fastapi/backend-deployment.yml -n ${namespace}`); shellExec(`sudo kubectl apply -f ./manifests/deployment/fastapi/backend-service.yml -n ${namespace}`); } if (process.argv.includes('run-front')) { const namespace = process.argv.find((arg) => arg.startsWith('--namespace='))?.split('=')[1] || 'default'; shellExec(`sudo kubectl apply -f ./manifests/deployment/fastapi/frontend-deployment.yml -n ${namespace}`); shellExec(`sudo kubectl apply -f ./manifests/deployment/fastapi/frontend-service.yml -n ${namespace}`); } break; } case 'conda': { // set -e // ENV_NAME="${1:-cuda_env}" // eval "$(conda shell.bash hook)" // conda activate "${ENV_NAME}" shellExec( `export PATH="/root/miniconda3/bin:$PATH" && conda init && conda config --set auto_activate_base false`, ); shellExec(`conda env list`); break; } case 'kafka': { // https://medium.com/@martin.hodges/deploying-kafka-on-a-kind-kubernetes-cluster-for-development-and-testing-purposes-ed7adefe03cb const namespace = process.argv.find((arg) => arg.startsWith('--namespace='))?.split('=')[1] || 'kafka'; const imageName = `doughgle/kafka-kraft`; shellExec(`docker pull ${imageName}`); if (!process.argv.includes('kubeadm')) shellExec( `${process.argv.includes('kubeadm') ? `ctr -n k8s.io images import` : `kind load docker-image`} ${imageName}`, ); shellExec(`kubectl create namespace ${namespace} --dry-run=client -o yaml | kubectl apply -f -`); shellExec(`kubectl apply -f ./manifests/deployment/kafka/deployment.yaml -n ${namespace}`); // kubectl logs kafka-0 -n kafka | grep STARTED // kubectl logs kafka-1 -n kafka | grep STARTED // kubectl logs kafka-2 -n kafka | grep STARTED // kafka-topics.sh --create --topic my-topic --bootstrap-server kafka-svc:9092 // kafka-topics.sh --list --topic my-topic --bootstrap-server kafka-svc:9092 // kafka-topics.sh --delete --topic my-topic --bootstrap-server kafka-svc:9092 // kafka-console-producer.sh --bootstrap-server kafka-svc:9092 --topic my-topic // kafka-console-consumer.sh --bootstrap-server kafka-svc:9092 --topic my-topic break; } case 'nvidia-gpu-operator': { // https://docs.nvidia.com/datacenter/cloud-native/container-toolkit/latest/install-guide.html shellExec(`curl -s -L https://nvidia.github.io/libnvidia-container/stable/rpm/nvidia-container-toolkit.repo | \ sudo tee /etc/yum.repos.d/nvidia-container-toolkit.repo`); const NVIDIA_CONTAINER_TOOLKIT_VERSION = '1.17.8-1'; shellExec(`sudo dnf install -y \ nvidia-container-toolkit-${NVIDIA_CONTAINER_TOOLKIT_VERSION} \ nvidia-container-toolkit-base-${NVIDIA_CONTAINER_TOOLKIT_VERSION} \ libnvidia-container-tools-${NVIDIA_CONTAINER_TOOLKIT_VERSION} \ libnvidia-container1-${NVIDIA_CONTAINER_TOOLKIT_VERSION}`); // https://docs.nvidia.com/datacenter/cloud-native/gpu-operator/latest/getting-started.html const namespace = 'gpu-operator'; shellExec(`kubectl create ns ${namespace} --dry-run=client -o yaml | kubectl apply -f -`); shellExec(`kubectl label --overwrite ns ${namespace} pod-security.kubernetes.io/enforce=privileged`); shellExec(`helm repo add nvidia https://helm.ngc.nvidia.com/nvidia \ && helm repo update`); // shellExec(`helm install --wait --generate-name \ // -n gpu-operator --create-namespace \ // nvidia/gpu-operator \ // --version=v25.3.1 \ // --set toolkit.version=v1.16.1-ubi8`); shellExec(`helm install --wait --generate-name \ -n gpu-operator --create-namespace \ nvidia/gpu-operator \ --version=v25.3.1 \ --set driver.enabled=false \ --set driver.repository=nvcr.io/nvidia \ --set cdi.enabled=true \ --set cdi.default=true \ --set toolkit.env[0].name=CONTAINERD_CONFIG \ --set toolkit.env[0].value=/etc/containerd/config.toml \ --set toolkit.env[1].name=CONTAINERD_SOCKET \ --set toolkit.env[1].value=/run/containerd/containerd.sock \ --set toolkit.env[2].name=CONTAINERD_RUNTIME_CLASS \ --set toolkit.env[2].value=nvidia \ --set-string toolkit.env[3].name=CONTAINERD_SET_AS_DEFAULT \ --set-string toolkit.env[3].value=true`); // Check gpu drivers shellExec( `break;kubectl get nodes -o json | jq '.items[].metadata.labels | keys | any(startswith("feature.node.kubernetes.io"))'`, ); break; } case 'kubeflow-spark-operator': { // Use case: // Data Processing Pipelines: Used for ETL tasks where Spark can handle large data volumes efficiently. // Real-Time Analytics: Processing data from streaming sources (e.g., Kafka) for real-time analytics. // Machine Learning and Data Science: Training and deploying machine learning models at scale using Spark MLlib. shellExec(`helm repo add spark-operator https://kubeflow.github.io/spark-operator`); shellExec(`helm install spark-operator spark-operator/spark-operator \ --namespace spark-operator \ --create-namespace \ --wait`); const image = `spark:3.5.5`; shellExec(`sudo docker pull ${image}`); if (!process.argv.includes('kubeadm')) shellExec( `sudo ${ process.argv.includes('kubeadm') ? `ctr -n k8s.io images import` : `kind load docker-image` } ${image}`, ); const namespace = process.argv.find((arg) => arg.startsWith('--namespace='))?.split('=')[1] || 'default'; shellExec(`kubectl apply -f ./manifests/deployment/spark/spark-pi-py.yaml -n ${namespace}`); // Check the status of the Spark job: // kubectl get sparkapplications.sparkoperator.k8s.io -n default // kubectl get sparkapplication // Check case log: // kubectl logs -f spark-pi-python-driver // kubectl logs -f spark-pi-python-driver | grep Pi // kubectl describe sparkapplication spark-gpu-test // Uninstall: // kubectl delete sparkapplications.sparkoperator.k8s.io spark-pi-python -n default // helm delete spark-operator -n spark-operator // Gpu plugins: // https://github.com/NVIDIA/spark-rapids // RAPIDS Accelerator break; } case 'tls': { fs.mkdirSync(`./engine-private/ssl/localhost`, { recursive: true }); const targetDir = `./engine-private/ssl/${process.argv[3] ? process.argv[3] : 'localhost'}`; const domains = ['localhost', '127.0.0.1', '::1'].concat(process.argv[3] ? process.argv[3] : []); shellExec(`chmod +x ./scripts/ssl.sh`); shellExec(`./scripts/ssl.sh ${targetDir} "${domains.join(' ')}"`); break; } case 'build-envs': { const buildEnv = (privateEnvPath, originEnv, env) => { const privateEnv = dotenv.parse(fs.readFileSync(privateEnvPath, 'utf8')); for (const key of Object.keys(privateEnv)) { if (key in env) { console.warn(`Key ${key} already exists in origin env`); continue; } if (key in originEnv) { console.warn(`Key ${key} already exists in origin env`); env[key] = originEnv[key]; continue; } env[key] = `${key}`.toUpperCase().match('API') || `${key}`.toUpperCase().match('KEY') || `${key}`.toUpperCase().match('SECRET') || `${key}`.toUpperCase().match('TOKEN') || `${key}`.toUpperCase().match('PASSWORD') || `${key}`.toUpperCase().match('MAC') ? 'changethis' : isNaN(parseFloat(privateEnv[key])) ? `${privateEnv[key]}`.match(`@`) ? 'admin@default.net' : 'changethis' : privateEnv[key]; } return env; }; for (let envPath of ['.env.development', '.env.production', '.env.test']) { const originEnv = dotenv.parse(fs.readFileSync(`./${envPath}`, 'utf8')); let env = {}; env = buildEnv(`./engine-private/conf/dd-cron/${envPath}`, originEnv, env); env = buildEnv(`./engine-private/conf/dd-core/${envPath}`, originEnv, env); writeEnv(envPath, env); } break; } case 'sync-start': { const targetDeployId = process.argv[3] || 'dd'; const deployIds = targetDeployId === 'dd' ? fs.readFileSync(`./engine-private/deploy/dd.router`, 'utf8').split(',') : [targetDeployId]; const originPackageJson = JSON.parse(fs.readFileSync(`./package.json`, 'utf8')); for (const deployId of deployIds) { const packageJsonPath = `./engine-private/conf/${deployId}/package.json`; const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8')); packageJson.scripts = { start: `${originPackageJson.scripts.start} ${deployId}` }; fs.writeFileSync(packageJsonPath, JSON.stringify(packageJson, null, 4), 'utf8'); logger.info(`sync-start`, { deployId, start: packageJson.scripts.start }); } break; } case 'sync-envs': { for (const deployId of ['dd-cron'].concat( fs.readFileSync(`./engine-private/deploy/dd.router`, 'utf8').split(','), )) { for (const env of ['production', 'development', 'test']) { const _envObj = dotenv.parse(fs.readFileSync(`./engine-private/conf/${deployId}/.env.${env}`, 'utf8')); for (const env of []) { delete _envObj[env]; } writeEnv(`./engine-private/conf/${deployId}/.env.${env}`, _envObj); } } break; } case 'sync-conf': { const originPackageJson = JSON.parse(fs.readFileSync(`./package.json`, 'utf8')); for (const deployId of ['dd-cron'].concat( fs.readFileSync(`./engine-private/deploy/dd.router`, 'utf8').split(','), )) { for (const file of fs.readdirSync(`./engine-private/conf/${deployId}/`)) { const deployPackage = JSON.parse(fs.readFileSync(`./engine-private/conf/${deployId}/package.json`, 'utf8')); deployPackage.overrides = originPackageJson.overrides; deployPackage.dependencies = originPackageJson.dependencies; fs.writeFileSync( `./engine-private/conf/${deployId}/package.json`, JSON.stringify(deployPackage, null, 4), 'utf8', ); if (file.startsWith('conf.server') && file.endsWith('.json')) { const filePath = `./engine-private/conf/${deployId}/${file}`; const confObj = JSON.parse(fs.readFileSync(filePath, 'utf8')); for (const host of Object.keys(confObj)) { for (const path of Object.keys(confObj[host])) { } } // fs.writeFileSync(filePath, JSON.stringify(confObj, null, 4), 'utf8'); logger.info(`sync-conf`, { deployId, file }); } } } break; } case 'cyberia': { const { CyberiaDependencies } = await import(`../src/api/cyberia-server-defaults/cyberia-server-defaults.js`); for (const dep of Object.keys(CyberiaDependencies)) { const ver = CyberiaDependencies[dep]; shellExec(`npm install ${dep}@${ver}`); } break; } case 'pw': { const help = `node bin/deploy pw <script-path> <from-path-in-pod> [to-path-on-local]`; const scriptPath = process.argv[3]; const fromPath = process.argv[4]; const toPath = process.argv[5] ? process.argv[5] : fromPath ? `/tmp/${fromPath.split('/').pop()}` : ''; if (scriptPath === 'help') { logger.info(help); break; } if (fs.existsSync(toPath)) fs.removeSync(toPath); shellExec(`node bin/deploy pw-conf ${scriptPath}`); shellExec(`kubectl delete deployment playwright-server --ignore-not-found`); while (Underpost.kubectl.get('playwright-server').length > 0) { logger.info(`Waiting for playwright-server deployment to be deleted...`); await timer(1000); } shellExec(`kubectl apply -f manifests/deployment/playwright/deployment.yaml`); const id = 'playwright-server'; await Underpost.test.statusMonitor(id); const nameSpace = 'default'; const [pod] = Underpost.kubectl.get(id); const podName = pod.NAME; shellExec(`kubectl logs -f ${podName} -n ${nameSpace}`, { async: true, }); (async () => { while (!Underpost.kubectl.existsFile({ podName, path: fromPath })) { await timer(1000); logger.info(`Waiting for file ${fromPath} in pod ${podName}...`); } shellExec(`sudo kubectl cp ${nameSpace}/${podName}:${fromPath} ${toPath}`); if (toPath.match('.png') && fs.existsSync(toPath)) shellExec(`firefox ${toPath}`); })(); break; } case 'pw-conf': { const scriptPath = process.argv[3]; shellExec(`kubectl delete configmap playwright-script`); shellExec(`kubectl create configmap playwright-script \ --from-file=script.js=${scriptPath} \ --dry-run=client -o yaml | kubectl apply -f - `); break; } case 'dependabot': { dotenv.config({ path: `./engine-private/conf/dd-cron/.env.production`, override: true }); shellExec(`git fetch origin --prune`); const { stdout: branchOutput } = shellExec(`git branch -r`, { silent: true }); const dependabotBranches = branchOutput .split('\n') .map((b) => b.trim()) .filter((b) => b.startsWith('remotes/origin/dependabot/') || b.startsWith('origin/dependabot/')) .map((b) => b.replace(/^remotes\//, '').replace(/^origin\//, '')); if (dependabotBranches.length === 0) { logger.info('No remote dependabot branches found'); break; } logger.info('Found dependabot branches:', dependabotBranches); // Stash local changes to prevent checkout/merge conflicts const stashResult = shellExec(`git stash --include-untracked`, { silent: true }); const hasStash = !stashResult.stdout.includes('No local changes to save'); // Checkout master const checkoutResult = shellExec(`git checkout master`); if (checkoutResult.code !== 0) { logger.error('Failed to checkout master'); if (hasStash) shellExec(`git stash pop`); break; } // Pull latest master shellExec(`git pull origin master`); // Get repo URI from remote const remoteUrl = shellExec(`git config --get remote.origin.url`, { stdout: true, silent: true }).trim(); const gitUri = remoteUrl.replace(/.*github\.com[:/]/, '').replace(/\.git$/, ''); const mergedBranches = []; const failedBranches = []; for (const branch of dependabotBranches) { logger.info(`Merging branch: ${branch}`); const mergeResult = shellExec(`git merge origin/${branch}`); if (mergeResult.code === 0) { const isAlreadyMerged = mergeResult.stdout && mergeResult.stdout.includes('Already up to date'); mergedBranches.push({ branch, isAlreadyMerged }); } else { logger.error(`Failed to merge branch: ${branch}`); shellExec(`git merge --abort`, { silent: true }); failedBranches.push(branch); } } // Delete merged local and remote branches for (const { branch, isAlreadyMerged } of mergedBranches) { shellExec(`git branch -D ${branch}`, { silent: true, silentOnError: true }); // logger.info(`Deleting remote branch: ${branch}${isAlreadyMerged ? ' (already merged)' : ''}`); // shellExec(`git push https://${process.env.GITHUB_TOKEN}@github.com/${gitUri}.git --delete ${branch}`, { // disableLog: true, // }); } // Restore stashed changes if (hasStash) shellExec(`git stash pop`); logger.info( 'Merged branches:', mergedBranches.map((m) => m.branch), ); if (failedBranches.length > 0) logger.warn('Failed branches:', failedBranches); logger.info('Dependabot merge completed'); break; } case 'add-server-client': { // node bin/deploy add-server-client <clientId> <deployId> <host> <path> // Example: node bin/deploy add-server-client cecinasmarcelina dd-core cecinasmarcelina.com / // Example: node bin/deploy add-server-client cecinasmarcelina dd-core www.cecinasmarcelina.com / const clientId = process.argv[3]; const deployId = process.argv[4]; const rawHost = process.argv[5]; const path = process.argv[6]; // Normalize: strip www. prefix to get the base host const baseHost = rawHost.startsWith('www.') ? rawHost.slice(4) : rawHost; const mainHost = `www.${baseHost}`; const redirectHost = baseHost; loadConf('clean'); loadConf(); shellExec(`node bin/deploy clone-client default ${clientId} dd-default ${deployId}`); // default.net has the main client entry → maps to www.<host> (main) shellExec(`node bin/deploy clone-server default.net / ${mainHost} ${path} dd-default ${deployId} ${clientId}`); // www.default.net has the redirect/empty entry → maps to <host> (redirect) shellExec(`node bin/deploy clone-server www.default.net / ${redirectHost} ${path} dd-default ${deployId}`); fs.removeSync(`./engine-private/conf/dd-default`); break; } case 'clone-client': { // node bin/deploy clone-client <fromClientId> <toClientId> <fromDeployId> [toDeployId] // Example: node bin/deploy clone-client dogmadual mynewclient dd-core // Example: node bin/deploy clone-client dogmadual mynewclient dd-core dd-other // // Clones a client configuration and its src/ files with a new name. // If toDeployId is omitted, it defaults to fromDeployId (same deploy). // Steps: // - conf.client.json: clones the fromClientId entry as toClientId (renames identifiers) // - conf.ssr.json: clones the SSR entry under the new PascalCase name // - conf.server.json + dev variants: duplicates paths referencing fromClientId for toClientId // - src/client/components/<fromClientId>/ → src/client/components/<toClientId>/ (renames identifiers) // - src/client/services/<fromClientId>/ → src/client/services/<toClientId>/ (renames identifiers) // - src/client/ssr/head/<From>Scripts.js → <To>Scripts.js // - src/client/ssr/body/<From>*.js → <To>*.js (SplashScreen, etc.) // - src/client/ssr/mailer/<From>*.js → <To>*.js (VerifyEmail, RecoverEmail, etc.) // - src/client/<From>.index.js → <To>.index.js // - src/client/public/<fromClientId>/ → src/client/public/<toClientId>/ const fromClientId = process.argv[3]; const toClientId = process.argv[4]; const fromDeployId = process.argv[5]; const toDeployId = process.argv[6] || fromDeployId; if (!fromClientId || !toClientId || !fromDeployId) { logger.error('Usage: node bin/deploy clone-client <fromClientId> <toClientId> <fromDeployId> [toDeployId]'); logger.error('Example: node bin/deploy clone-client dogmadual mynewclient dd-core'); process.exit(1); } const fromCapName = getCapVariableName(fromClientId); const toCapName = getCapVariableName(toClientId); const confFromFolder = `./engine-private/conf/${fromDeployId}`; const confToFolder = `./engine-private/conf/${toDeployId}`; if (!fs.existsSync(confFromFolder)) { logger.error(`Source config folder not found: ${confFromFolder}`); process.exit(1); } if (!fs.existsSync(confToFolder)) { logger.error(`Target config folder not found: ${confToFolder}`); process.exit(1); } const formattedSrc = (src) => src.replaceAll(fromCapName, toCapName).replaceAll(fromClientId, toClientId); // 1. Clone conf.client.json entry { const clientConf = JSON.parse(fs.readFileSync(`${confToFolder}/conf.client.json`, 'utf8')); if (clientConf[toClientId]) { logger.warn(`conf.client.json: "${toClientId}" already exists, skipping`); } else { const fromClientConf = JSON.parse(fs.readFileSync(`${confFromFolder}/conf.client.json`, 'utf8')); if (!fromClientConf[fromClientId]) { logger.error(`conf.client.json: "${fromClientId}" not found in ${confFromFolder}`); process.exit(1); } clientConf[toClientId] = JSON.parse(formattedSrc(JSON.stringify(fromClientConf[fromClientId]))); fs.writeFileSync(`${confToFolder}/conf.client.json`, JSON.stringify(clientConf, null, 4), 'utf8'); logger.info(`conf.client.json: cloned "${fromClientId}" → "${toClientId}"`); } } // 2. Clone conf.ssr.json entry { const ssrConf = JSON.parse(fs.readFileSync(`${confToFolder}/conf.ssr.json`, 'utf8')); if (ssrConf[toCapName]) { logger.warn(`conf.ssr.json: "${toCapName}" already exists, skipping`); } else { const fromSsrConf = JSON.parse(fs.readFileSync(`${confFromFolder}/conf.ssr.json`, 'utf8')); if (fromSsrConf[fromCapName]) { ssrConf[toCapName] = JSON.parse(formattedSrc(JSON.stringify(fromSsrConf[fromCapName]))); fs.writeFileSync(`${confToFolder}/conf.ssr.json`, JSON.stringify(ssrConf, null, 4), 'utf8'); logger.info(`conf.ssr.json: cloned "${fromCapName}" → "${toCapName}"`); } else { logger.warn(`conf.ssr.json: "${fromCapName}" not found in ${confFromFolder}, skipping`); } } } // 3. Clone server conf entries (conf.server.json + dev variants) const cloneServerConfEntries = (filePath, label) => { if (!fs.existsSync(filePath)) return; const serverConf = JSON.parse(fs.readFileSync(filePath, 'utf8')); let count = 0; for (const host of Object.keys(serverConf)) { for (const path of Object.keys(serverConf[host])) { const entry = serverConf[host][path]; if (entry.client === fromClientId) { const newPath = path === '/' ? `/${toClientId}` : path.replace(fromClientId, toClientId); if (!serverConf[host][newPath]) { serverConf[host][newPath] = JSON.parse(formattedSrc(JSON.stringify(entry))); count++; } } } } if (count > 0) { fs.writeFileSync(filePath, JSON.stringify(serverConf, null, 4), 'utf8'); } logger.info(`${label}: cloned ${count} server path(s) for "${toClientId}"`); }; cloneServerConfEntries(`${confToFolder}/conf.server.json`, 'conf.server.json'); const devFiles = fs .readdirSync(confToFolder) .filter((f) => f.startsWith('conf.server.dev.') && f.endsWith('.json')); for (const devFile of devFiles) { cloneServerConfEntries(`${confToFolder}/${devFile}`, devFile); } // 4. Clone src/client/components/<fromClientId>/ → <toClientId>/ const srcFromFolder = `./src/client/components/${fromClientId}`; const srcToFolder = `./src/client/components/${toClientId}`; if (!fs.existsSync(srcFromFolder)) { logger.warn(`Source component folder not found: ${srcFromFolder}, skipping src clone`); } else if (fs.existsSync(srcToFolder)) { logger.warn(`Target component folder already exists: ${srcToFolder}, skipping src clone`); } else { fs.mkdirSync(srcToFolder, { recursive: true }); const files = fs.readdirSync(srcFromFolder, { recursive: true }); for (const relativePath of files) { const fromFilePath = `${srcFromFolder}/${relativePath}`; if (fs.statSync(fromFilePath).isDirectory()) { fs.mkdirSync(`${srcToFolder}/${formattedSrc(relativePath)}`, { recursive: true }); continue; } const toFileName = formattedSrc(relativePath); fs.writeFileSync(`${srcToFolder}/${toFileName}`, formattedSrc(fs.readFileSync(fromFilePath, 'utf8')), 'utf8'); } logger.info(`src/client/components: cloned ${srcFromFolder} → ${srcToFolder} (${files.length} files)`); } // 5. Clone src/client/services/<fromClientId>/ → <toClientId>/ { const svcFromFolder = `./src/client/services/${fromClientId}`; const svcToFolder = `./src/client/services/${toClientId}`; if (!fs.existsSync(svcFromFolder)) { logger.warn(`Source services folder not found: ${svcFromFolder}, skipping`); } else if (fs.existsSync(svcToFolder)) { logger.warn(`Target services folder already exists: ${svcToFolder}, skipping`); } else { fs.mkdirSync(svcToFolder, { recursive: true }); const svcFiles = fs.readdirSync(svcFromFolder, { recursive: true }); for (const relativePath of svcFiles) { const fromFilePath = `${svcFromFolder}/${relativePath}`; if (fs.statSync(fromFilePath).isDirectory()) { fs.mkdirSync(`${svcToFolder}/${formattedSrc(relativePath)}`, { recursive: true }); continue; } const toFileName = formattedSrc(relativePath); fs.writeFileSync( `${svcToFolder}/${toFileName}`, formattedSrc(fs.readFileSync(fromFilePath, 'utf8')), 'utf8', ); } logger.info(`src/client/services: cloned ${svcFromFolder} → ${svcToFolder} (${svcFiles.length} files)`); } } // 6. Clone SSR head scripts const fromScriptsPath = `./src/client/ssr/head/${fromCapName}Scripts.js`; const toScriptsPath = `./src/client/ssr/head/${toCapName}Scripts.js`; if (fs.existsSync(fromScriptsPath) && !fs.existsSync(toScriptsPath)) { fs.writeFileSync(toScriptsPath, formattedSrc(fs.readFileSync(fromScriptsPath, 'utf8')), 'utf8'); logger.info(`ssr/head: cloned ${fromCapName}Scripts.js → ${toCapName}Scripts.js`); } else if (fs.existsSync(toScriptsPath)) { logger.warn(`ssr/head: ${toCapName}Scripts.js already exists, skipping`); } // 7. Clone SSR body files { const ssrConf = JSON.parse(fs.readFileSync(`${confToFolder}/conf.ssr.json`, 'utf8')); const toSsr = ssrConf[toCapName]; if (toSsr && Array.isArray(toSsr.body)) { for (const bodyName of toSsr.body) { if (!bodyName.startsWith(toCapName)) continue; const fromBodyName = bodyName.replace(toCapName, fromCapName); const fromBodyPath = `./src/client/ssr/body/${fromBodyName}.js`; const toBodyPath = `./src/client/ssr/body/${bodyName}.js`; if (fs.existsSync(fromBodyPath) && !fs.existsSync(toBodyPath)) { fs.writeFileSync(toBodyPath, formattedSrc(fs.readFileSync(fromBodyPath, 'utf8')), 'utf8'); logger.info(`ssr/body: cloned ${fromBodyName}.js → ${bodyName}.js`); } else if (fs.existsSync(toBodyPath)) { logger.warn(`ssr/body: ${bodyName}.js already exists, skipping`); } } } } // 8. Clone SSR mailer files { const ssrConf = JSON.parse(fs.readFileSync(`${confToFolder}/conf.ssr.json`, 'utf8')); const toSsr = ssrConf[toCapName]; if (toSsr && toSsr.mailer && typeof toSsr.mailer === 'object') { for (const mailerName of Object.values(toSsr.mailer)) { if (!mailerName.startsWith(toCapName)) continue; const fromMailerName = mailerName.replace(toCapName, fromCapName); const fromMailerPath = `./src/client/ssr/mailer/${fromMailerName}.js`; const toMailerPath = `./src/client/ssr/mailer/${mailerName}.js`; if (fs.existsSync(fromMailerPath) && !fs.existsSync(toMailerPath)) { fs.writeFileSync(toMailerPath, formattedSrc(fs.readFileSync(fromMailerPath, 'utf8')), 'utf8'); logger.info(`ssr/mailer: cloned ${fromMailerName}.js → ${mailerName}.js`); } else if (fs.existsSync(toMailerPath)) { logger.warn(`ssr/mailer: ${mailerName}.js already exists, skipping`); } } } } // 9. Clone client index const fromIndexPath = `./src/client/${fromCapName}.index.js`; const toIndexPath = `./src/client/${toCapName}.index.js`; if (fs.existsSync(fromIndexPath) && !fs.existsSync(toIndexPath)) { fs.writeFileSync(toIndexPath, formattedSrc(fs.readFileSync(fromIndexPath, 'utf8')), 'utf8'); logger.info(`client: cloned ${fromCapName}.index.js → ${toCapName}.index.js`); } else if (fs.existsSync(toIndexPath)) { logger.warn(`client: ${toCapName}.index.js already exists, skipping`); } // 10. Clone public assets const fromPublicPath = `./src/client/public/${fromClientId}`; const toPublicPath = `./src/client/public/${toClientId}`; if (fs.existsSync(fromPublicPath) && !fs.existsSync(toPublicPath)) { fs.copySync(fromPublicPath, toPublicPath); logger.info(`public: cloned ${fromPublicPath} → ${toPublicPath}`); } else if (fs.existsSync(toPublicPath)) { logger.warn(`public: ${toPublicPath} already exists, skipping`); } // 11. Rebuild default conf shellExec(`node bin new --default-conf --deploy-id ${toDeployId}`); logger.info(`Rebuilt default conf for ${toDeployId}`); break; } case 'clone-server': { // node bin/deploy clone-server <fromHost> <fromPath> <toHost> <toPath> <fromDeployId> <toDeployId> // Example: node bin/deploy clone-server www.dogmadual.com / www.newsite.com / dd-core dd-other // Example: node bin/deploy clone-server www.nexodev.org / www.newdomain.org /app dd-core dd-core // // Clones a specific server host/path entry from one deploy's conf to another, // optionally renaming the host and path in the target. // - conf.server.json: copies the fromHost/fromPath entry to toHost/toPath // - conf.server.dev.*.json: same treatment for all dev variants const fromHost = process.argv[3]; const fromPath = process.argv[4]; const toHost = process.argv[5]; const toPath = process.argv[6]; const fromDeployId = process.argv[7]; const toDeployId = process.argv[8]; const overrideClientId = process.argv[9]; if (!fromHost || !fromPath || !toHost || !toPath || !fromDeployId || !toDeployId) { logger.error( 'Usage: node bin/deploy clone-server <fromHost> <fromPath> <toHost> <toPath> <fromDeployId> <toDeployId> [clientId]', ); logger.error( 'Example: node bin/deploy clone-server www.dogmadual.com / www.newsite.com / dd-core dd-other newsite', ); process.exit(1); } const confFromFolder = `./engine-private/conf/${fromDeployId}`; const confToFolder = `./engine-private/conf/${toDeployId}`; if (!fs.existsSync(confFromFolder)) { logger.error(`Source config folder not found: ${confFromFolder}`); process.exit(1); } if (!fs.existsSync(confToFolder)) { logger.error(`Target config folder not found: ${confToFolder}`); process.exit(1); } const cloneServerEntry = (fromFilePath, toFilePath, label) => { if (!fs.existsSync(fromFilePath)) { logger.warn(`${label}: source file not found, skipping`); return; } const fromServerConf = JSON.parse(fs.readFileSync(fromFilePath, 'utf8')); if (!fromServerConf[fromHost] || !fromServerConf[fromHost][fromPath]) { logger.warn(`${label}: "${fromHost}" "${fromPath}" not found in source, skipping`); return; } const toServerConf = fs.existsSync(toFilePath) ? JSON.parse(fs.readFileSync(toFilePath, 'utf8')) : {}; if (!toServerConf[toHost]) toServerConf[toHost] = {}; if (toServerConf[toHost][toPath]) { logger.warn(`${label}: "$