UNPKG

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

3,365 lines 143 kB
/**
 * Provides utilities for building, loading, and managing server configurations,
 * deployment contexts, and service configurations (API, Client, WS).
 * @module src/server/conf.js
 * @namespace ServerConfBuilder
 */

import fs from 'fs-extra';
import dotenv from 'dotenv';
import {
  capFirst,
  getCapVariableName,
  getDirname,
  newInstance,
  orderAbc,
  orderArrayFromAttrInt,
  range,
  timer,
} from '../client/components/core/CommonJs.js';
import * as dir from 'path';
import net from 'net';
import crypto from 'crypto';
import colors from 'colors';
import { loggerFactory } from './logger.js';
import { writeEnv } from './environment.js';
import { shellExec } from './process.js';
import { UNDERPOST_GATEWAY, statusPageAssetPathFactory } from './underpost-gateway.js';
import { DefaultConf } from '../../conf.js';
import splitFile from 'split-file';
import Underpost from '../index.js';

colors.enable();

const logger = loggerFactory(import.meta);

/**
 * Prefix used in JSON configuration files to denote an environment variable reference.
 * Any string value in a config object that starts with this prefix will be resolved
 * to the corresponding `process.env` value at runtime.
 *
 * @constant {string}
 * @memberof ServerConfBuilder
 * @example
 * // In conf.server.json:
 * { "db": { "password": "env:MARIADB_PASSWORD" } }
 */
const ENV_REF_PREFIX = 'env:';

/**
 * Default deploy ID used when no deploy ID is specified.
 * @constant {string}
 * @memberof ServerConfBuilder
 */
const DEFAULT_DEPLOY_ID = 'dd-default';

/**
 * Resolves a standardized context key from host/path descriptors.
 * The key is used across DB, WS, mailer, and cache registries.
 *
 * @method resolveHostKeyContext
 * @param {{host?: string, path?: string}|string} [context={ host: '', path: '' }] - Context object or prebuilt key.
 * @returns {string} Host key context string.
 * @memberof ServerConfBuilder
 */
const resolveHostKeyContext = (context = { host: '', path: '' }) => {
  if (typeof context === 'string') return context;
  return `${context.host || ''}${context.path || ''}`;
};

/**
 * Recursively walks a configuration object and replaces every string value that
 * starts with {@link ENV_REF_PREFIX} (`"env:"`) with the corresponding
 * `process.env[VAR_NAME]` value.
 *
 * Non-string values and strings that do not start with the prefix are left untouched.
 *
 * Supports three reference formats:
 * - `"env:VAR_NAME"` — resolves to `process.env.VAR_NAME`, returns `''` if unset.
 * - `"env:VAR_NAME:default_value"` — resolves to `process.env.VAR_NAME`, falls back to `default_value` if unset.
 * - Type-coerced defaults:
 *   - `"env:VAR_NAME:int:465"` — resolved value is parsed as integer (`parseInt`), falls back to `465`.
 *   - `"env:VAR_NAME:bool:true"` — resolved value is coerced to boolean (`value !== 'false'`), falls back to `true`.
 *
 * @method resolveConfSecrets
 * @param {any} obj - The configuration object (or value) to resolve.
 * @returns {any} A **new** object with all `env:` references replaced by their runtime values.
 * @memberof ServerConfBuilder
 *
 * @example
 * // Given process.env.MARIADB_PASSWORD = 'supersecret'
 * resolveConfSecrets({ db: { password: 'env:MARIADB_PASSWORD' } });
 * // => { db: { password: 'supersecret' } }
 *
 * @example
 * // With default value fallback (env var not set)
 * resolveConfSecrets({ db: { provider: 'env:DB_PROVIDER:mongoose' } });
 * // => { db: { provider: 'mongoose' } }
 *
 * @example
 * // With int type coercion
 * resolveConfSecrets({ port: 'env:SMTP_PORT:int:465' });
 * // => { port: 465 }
 *
 * @example
 * // With bool type coercion
 * resolveConfSecrets({ secure: 'env:SMTP_SECURE:bool:true' });
 * // => { secure: true }
 */
const resolveConfSecrets = (obj) => {
  if (obj === null || obj === undefined) return obj;
  if (typeof obj === 'string') {
    if (obj.startsWith(ENV_REF_PREFIX)) {
      const ref = obj.slice(ENV_REF_PREFIX.length);
      // Support env:VAR_NAME:default_value syntax (first colon separates key from default)
      const colonIdx = ref.indexOf(':');
      const envKey = colonIdx !== -1 ? ref.slice(0, colonIdx) : ref;
      const defaultValue = colonIdx !== -1 ? ref.slice(colonIdx + 1) : undefined;
      const envValue = process.env[envKey];

      let resolved;
      if (envValue !== undefined) {
        resolved = envValue;
      } else if (defaultValue !== undefined) {
        resolved = defaultValue;
      } else {
        logger.warn(`resolveConfSecrets: environment variable "${envKey}" is not set (referenced as "${obj}")`);
        return '';
      }

      // Type coercion via prefix in default value: int:N or bool:B
      // Also apply coercion when an env value is present and a typed default is declared
      if (defaultValue !== undefined) {
        if (defaultValue.startsWith('int:')) {
          return parseInt(resolved, 10) || parseInt(defaultValue.slice(4), 10) || 0;
        }
        if (defaultValue.startsWith('bool:')) {
          const boolDefault = defaultValue.slice(5);
          if (envValue !== undefined) return envValue !== 'false';
          return boolDefault !== 'false';
        }
      }

      return resolved;
    }
    return obj;
  }
  if (Array.isArray(obj)) return obj.map((item) => resolveConfSecrets(item));
  if (typeof obj === 'object') {
    const resolved = {};
    for (const [key, value] of Object.entries(obj)) {
      resolved[key] = resolveConfSecrets(value);
    }
    return resolved;
  }
  return obj;
};

/**
 * Returns the private configuration folder for a given deploy ID.
 * Checks for a replica folder first, then falls back to the standard conf folder.
 *
 * @method getConfFolder
 * @param {string} deployId - The deploy ID.
 * @returns {string} The path to the private configuration folder.
 * @memberof ServerConfBuilder
 *
 * @example
 * getConfFolder('dd-myapp');
 * // => './engine-private/conf/dd-myapp'  (or './engine-private/replica/dd-myapp' if it exists)
 */
const getConfFolder = (deployId) => {
  return fs.existsSync(`./engine-private/replica/${deployId}`)
    ? `./engine-private/replica/${deployId}`
    : `./engine-private/conf/${deployId}`;
};

/**
 * Resolves the full path to a specific configuration JSON file for a deploy ID.
 * For `server` configs in development mode with a subConf, it will prefer the
 * dev-specific variant if it exists.
 *
 * @method getConfFilePath
 * @param {string} deployId - The deploy ID.
 * @param {string} confType - The configuration type (e.g. 'server', 'client', 'cron', 'ssr').
 * @param {string} [subConf=''] - Optional sub-configuration identifier (used for dev server variants).
 * @returns {string} The resolved path to the configuration JSON file.
 * @memberof ServerConfBuilder
 *
 * @example
 * getConfFilePath('dd-myapp', 'server');
 * // => './engine-private/conf/dd-myapp/conf.server.json'
 *
 * @example
 * // In development with subConf 'local':
 * getConfFilePath('dd-myapp', 'server', 'local');
 * // => './engine-private/conf/dd-myapp/conf.server.dev.local.json' (if it exists)
 */
const getConfFilePath = (deployId, confType, subConf = '') => {
  const folder = getConfFolder(deployId);
  // When no explicit subConf is given, fall back to the env var set by loadConf()
  const effectiveSubConf = subConf || process.env.DEPLOY_SUB_CONF || '';
  if (confType === 'server' && effectiveSubConf) {
    const devConfPath = `${folder}/conf.${confType}.dev.${effectiveSubConf}.json`;
    if (fs.existsSync(devConfPath)) return devConfPath;
  }
  return `${folder}/conf.${confType}.json`;
};

/**
 * Reads and parses a configuration JSON file for a given deploy ID and config type.
 * Optionally resolves `env:` secret references and/or applies replica expansion.
 *
 * @method readConfJson
 * @param {string} deployId - The deploy ID.
 * @param {string} confType - The configuration type (e.g. 'server', 'client', 'cron', 'ssr').
 * @param {object} [options={}] - Options.
 * @param {string} [options.subConf=''] - Sub-configuration identifier for dev variants.
 * @param {boolean} [options.resolve=false] - Whether to resolve `env:` references.
 * @param {boolean} [options.loadReplicas=false] - Whether to expand replica entries (server configs).
 * @returns {object} The parsed (and optionally resolved) configuration object.
 * @memberof ServerConfBuilder
 */
const readConfJson = (deployId, confType, options = {}) => {
  const filePath = getConfFilePath(deployId, confType, options.subConf || '');
  if (!fs.existsSync(filePath)) {
    throw new Error(`readConfJson: configuration file not found: ${filePath}`);
  }
  let parsed = JSON.parse(fs.readFileSync(filePath, 'utf8'));
  if (options.loadReplicas && confType === 'server') parsed = loadReplicas(deployId, parsed);
  if (options.resolve) parsed = resolveConfSecrets(parsed);
  return parsed;
};

/**
 * @class Config
 * @description Manages the configuration of the server.
 * This class provides a set of static methods to automate various
 * infrastructure operations, including NFS management, control server setup,
 * and system provisioning for different architectures.
 * @memberof ServerConfBuilder
 */
const Config = {
  /**
   * @method default
   * @description The default configuration of the server.
   * @memberof ServerConfBuilder
   */
  default: DefaultConf,
  /**
   * @method build
   * @description Builds the configuration of the server.
   * @param {string} [deployContext='dd-default'] - The deploy context.
   * @param {string} [deployList=''] - The deploy list.
   * @param {string} [subConf=''] - The sub configuration.
   * @memberof ServerConfBuilder
   */
  build: async function (deployContext = DEFAULT_DEPLOY_ID, deployList, subConf) {
    if (process.argv[2] && typeof process.argv[2] === 'string' && process.argv[2].startsWith('dd-'))
      deployContext = process.argv[2];
    else if (deployContext !== 'proxy' && process.env.DEPLOY_ID && process.env.DEPLOY_ID.startsWith('dd-'))
      deployContext = process.env.DEPLOY_ID;
    if (!subConf && process.argv[3] && typeof process.argv[3] === 'string') subConf = process.argv[3];

    Underpost.env.set('await-deploy', new Date().toISOString());
    if (deployContext.startsWith('dd-')) loadConf(deployContext, subConf);
    if (deployContext === 'proxy') await Config.buildProxy(deployList, subConf);
  },
  /**
   * @method deployIdFactory
   * @description Creates a new deploy ID.
   * @param {string} [deployId='dd-default']
   * @param {object} [options={ subConf: '', cluster: false }] - The options.
   * @memberof ServerConfBuilder
   */
  deployIdFactory: function (deployId = DEFAULT_DEPLOY_ID, options = { subConf: '', cluster: false }) {
    if (!deployId.startsWith('dd-')) deployId = `dd-${deployId}`;

    logger.info('Build deployId', deployId);

    const folder = `./engine-private/conf/${deployId}`;
    const repoName = `engine-${deployId.split('dd-')[1]}`;

    if (!fs.existsSync(folder)) fs.mkdirSync(folder, { recursive: true });

    const sharedEnvTemplate = fs.existsSync('./.env.example')
      ? fs.readFileSync('./.env.example', 'utf8')
      : fs.existsSync('./.env.production')
        ? fs.readFileSync('./.env.production', 'utf8')
        : '';

    const envTemplates = {
      production: fs.existsSync('./.env.production') ? fs.readFileSync('./.env.production', 'utf8') : sharedEnvTemplate,
      development: fs.existsSync('./.env.development')
        ? fs.readFileSync('./.env.development', 'utf8')
        : sharedEnvTemplate
          ? sharedEnvTemplate.replace('NODE_ENV=production', 'NODE_ENV=development').replace('PORT=3000', 'PORT=4000')
          : '',
      test: fs.existsSync('./.env.test')
        ? fs.readFileSync('./.env.test', 'utf8')
        : sharedEnvTemplate
          ? sharedEnvTemplate.replace('NODE_ENV=production', 'NODE_ENV=test').replace('PORT=3000', 'PORT=5000')
          : '',
    };

    for (const [envName, envTemplate] of Object.entries(envTemplates)) {
      if (!envTemplate) continue;
      fs.writeFileSync(`${folder}/.env.${envName}`, envTemplate.replaceAll('dd-default', deployId), 'utf8');
    }

    fs.writeFileSync(
      `${folder}/package.json`,
      fs.readFileSync('./package.json', 'utf8').replaceAll('dd-default', deployId),
      'utf8',
    );

    // Write default conf JSON files if they don't exist
    for (const confType of Object.keys(this.default)) {
      const confPath = `${folder}/conf.${confType}.json`;
      if (!fs.existsSync(confPath)) fs.writeFileSync(confPath, JSON.stringify(this.default[confType], null, 4), 'utf8');
    }

    if (options.subConf) {
      logger.info('Creating sub conf', {
        deployId: deployId,
        subConf: options.subConf,
      });
      fs.copySync(
        `./engine-private/conf/${deployId}/conf.server.json`,
        `./engine-private/conf/${deployId}/conf.server.dev.${options.subConf}.json`,
      );
    }

    if (options.cluster === true) {
      fs.writeFileSync(
        `./.github/workflows/${repoName}.cd.yml`,
        fs.readFileSync(`./.github/workflows/engine-test.cd.yml`, 'utf8').replaceAll('test', deployId.split('dd-')[1]),
        'utf8',
      );
      fs.writeFileSync(
        `./.github/workflows/${repoName}.ci.yml`,
        fs.readFileSync(`./.github/workflows/engine-test.ci.yml`, 'utf8').replaceAll('test', deployId.split('dd-')[1]),
        'utf8',
      );
      shellExec(`node bin new --default-conf --deploy-id ${deployId}`);

      if (!fs.existsSync(`./engine-private/deploy/dd.router`))
        fs.writeFileSync(`./engine-private/deploy/dd.router`, deployId, 'utf8');
      else
        fs.writeFileSync(
          `./engine-private/deploy/dd.router`,
          fs.readFileSync(`./engine-private/deploy/dd.router`, 'utf8').trim() + `,${deployId}`,
          'utf8',
        );
    }

    return { deployIdFolder: folder, deployId };
  },
  /**
   * @method buildProxyByDeployId
   * @description Builds the proxy by deploy ID.
   * @param {string} [deployId='dd-default'] - The deploy ID.
   * @param {string} [subConf=''] - The sub configuration.
   * @memberof ServerConfBuilder
   */
  buildProxyByDeployId: function (deployId = 'dd-default', subConf = '') {
    let confPath = fs.existsSync(`./engine-private/replica/${deployId}/conf.server.json`)
      ? `./engine-private/replica/${deployId}/conf.server.json`
      : `./engine-private/conf/${deployId}/conf.server.json`;

    if (
      process.env.NODE_ENV === 'development' &&
      subConf &&
      fs.existsSync(`./engine-private/conf/${deployId}/conf.server.dev.${subConf}.json`)
    )
      confPath = `./engine-private/conf/${deployId}/conf.server.dev.${subConf}.json`;

    const serverConf = loadConfServerJson(confPath);

    for (const host of Object.keys(loadReplicas(deployId, serverConf)))
      this.default.server[host] = {
        ...this.default.server[host],
        ...serverConf[host],
      };
  },
  /**
   * @method buildProxy
   * @description Builds the proxy.
   * @param {string} [deployList='dd-default'] - The deploy list.
   * @param {string} [subConf=''] - The sub configuration.
   * @memberof ServerConfBuilder
   */
  buildProxy: async function (deployList = 'dd-default', subConf = '') {
    if (!deployList) deployList = process.argv[3];
    if (!subConf) subConf = process.argv[4];
    this.default.server = {};
    for (const deployId of deployList.split(',')) {
      this.buildProxyByDeployId(deployId, subConf);
      if (fs.existsSync(`./engine-private/replica`)) {
        const singleReplicas = await fs.readdir(`./engine-private/replica`);
        for (let replica of singleReplicas) {
          if (replica.startsWith(deployId)) this.buildProxyByDeployId(replica, subConf);
        }
      }
    }
  },
};

/**
 * @method loadConf
 * @description Loads the configuration of the server.
 * @param {string} [deployId='dd-default'] - The deploy ID.
 * @param {string} [subConf=''] - The sub configuration.
 * @memberof ServerConfBuilder
 */
const loadConf = (deployId = DEFAULT_DEPLOY_ID, subConf) => {
  if (deployId === 'current') {
    console.log(process.env.DEPLOY_ID);
    return;
  }
  if (deployId === 'clean') {
    const path = '.';
    fs.removeSync(`${path}/.env`);
    fs.removeSync(`${path}/.env.production`);
    fs.removeSync(`${path}/.env.development`);
    fs.removeSync(`${path}/.env.test`);
    return;
  }
  const folder = getConfFolder(deployId);

  if (!fs.existsSync(folder)) Config.deployIdFactory(deployId);

  if (subConf) process.env.DEPLOY_SUB_CONF = subConf;

  for (const typeConf of Object.keys(Config.default)) {
    let srcConf = fs.readFileSync(`${folder}/conf.${typeConf}.json`, 'utf8');
    if (process.env.NODE_ENV === 'development' && typeConf === 'server' && subConf) {
      const devConfPath = `${folder}/conf.${typeConf}.dev${subConf ? `.${subConf}` : ''}.json`;
      if (fs.existsSync(devConfPath)) srcConf = fs.readFileSync(devConfPath, 'utf8');
    }
    let parsed = JSON.parse(srcConf);
    if (typeConf === 'server') parsed = loadReplicas(deployId, parsed);
    Config.default[typeConf] = parsed;
  }
  fs.writeFileSync(`./.env.production`, fs.readFileSync(`${folder}/.env.production`, 'utf8'), 'utf8');
  fs.writeFileSync(`./.env.development`, fs.readFileSync(`${folder}/.env.development`, 'utf8'), 'utf8');
  fs.writeFileSync(`./.env.test`, fs.readFileSync(`${folder}/.env.test`, 'utf8'), 'utf8');
  const NODE_ENV = process.env.NODE_ENV || 'development';
  if (NODE_ENV) {
    const subPathEnv = fs.existsSync(`${folder}/.env.${NODE_ENV}.${subConf}`)
      ? `${folder}/.env.${NODE_ENV}.${subConf}`
      : `${folder}/.env.${NODE_ENV}`;
    fs.writeFileSync(`./.env`, fs.readFileSync(subPathEnv, 'utf8'), 'utf8');
    const env = dotenv.parse(fs.readFileSync(subPathEnv, 'utf8'));
    process.env = {
      ...process.env,
      ...env,
    };
  }
  const originPackageJson = JSON.parse(fs.readFileSync(`./package.json`, 'utf8'));
  const packageJson = JSON.parse(fs.readFileSync(`${folder}/package.json`, 'utf8'));
  originPackageJson.scripts.start = packageJson.scripts.start;
  packageJson.scripts = originPackageJson.scripts;
  fs.writeFileSync(`./package.json`, JSON.stringify(packageJson, null, 4), 'utf8');
  return { folder, deployId };
};

/**
 * @method loadReplicas
 * @description Loads the replicas of the server.
 * @param {object} confServer - The server configuration.
 * @memberof ServerConfBuilder
 */
const loadReplicas = (deployId, confServer) => {
  const confServerOrigin = newInstance(confServer);
  for (const host of Object.keys(confServer)) {
    for (const path of Object.keys(confServer[host])) {
      const { replicas, singleReplica } = confServer[host][path];
      if (replicas) {
        if (!singleReplica)
          for (const replicaPath of replicas) {
            {
              confServer[host][replicaPath] = newInstance(confServer[host][path]);
              delete confServer[host][replicaPath].replicas;
            }
          }
        else {
          const orderReplica = orderAbc(confServerOrigin[host][path].replicas);
          confServerOrigin[host][path].replicas = orderReplica;
          confServer[host][path].replicas = orderReplica;
        }
      }
    }
  }
  const serverPath = `./engine-private/conf/${deployId}/conf.server${process.env.NODE_ENV === 'production' ? '' : '.dev'}.json`;
  if (fs.existsSync(serverPath)) fs.writeFileSync(serverPath, JSON.stringify(confServerOrigin, null, 4), 'utf8');

  return confServer;
};

/**
 * @method cloneConf
 * @description Clones the configuration of the server.
 * @param {object} toOptions - The options for the target configuration.
 * @param {object} fromOptions - The options for the source configuration.
 * @param {object} [fromDefaultOptions={ deployId: 'dd-default', clientId: 'default' }] - The default options for the source configuration.
 * @memberof ServerConfBuilder
 */
const cloneConf = async (
  { toOptions, fromOptions },
  fromDefaultOptions = { deployId: 'dd-default', clientId: 'default' },
) => {
  if (!fromOptions.deployId) fromOptions.deployId = fromDefaultOptions.deployId;
  if (!fromOptions.clientId) fromOptions.clientId = fromDefaultOptions.clientId;

  const confFromFolder = `./engine-private/conf/${fromOptions.deployId}`;
  const confToFolder = `./engine-private/conf/${toOptions.deployId}`;

  const toClientVariableName = getCapVariableName(toOptions.clientId);
  const fromClientVariableName = getCapVariableName(fromOptions.clientId);

  const formattedSrc = (dataConf) =>
    JSON.stringify(dataConf, null, 4)
      .replaceAll(fromClientVariableName, toClientVariableName)
      .replaceAll(fromOptions.clientId, toOptions.clientId);

  const isMergeConf = fs.existsSync(confToFolder);
  if (!isMergeConf) fs.mkdirSync(confToFolder, { recursive: true });

  fs.writeFileSync(
    `${confToFolder}/.env.production`,
    fs.readFileSync(`${confFromFolder}/.env.production`, 'utf8'),
    'utf8',
  );
  fs.writeFileSync(
    `${confToFolder}/.env.development`,
    fs.readFileSync(`${confFromFolder}/.env.development`, 'utf8'),
    'utf8',
  );
  fs.writeFileSync(`${confToFolder}/.env.test`, fs.readFileSync(`${confFromFolder}/.env.test`, 'utf8'), 'utf8');

  for (const confTypeId of ['server', 'client', 'cron', 'ssr']) {
    const confFromData = JSON.parse(fs.readFileSync(`${confFromFolder}/conf.${confTypeId}.json`, 'utf8'));
    fs.writeFileSync(`${confToFolder}/conf.${confTypeId}.json`, formattedSrc(confFromData), 'utf8');
  }

  const packageData = JSON.parse(fs.readFileSync(`${confFromFolder}/package.json`, 'utf8'));
  packageData.scripts.start = packageData.scripts.start.replaceAll(fromOptions.deployId, toOptions.deployId);
  fs.writeFileSync(`${confToFolder}/package.json`, JSON.stringify(packageData, null, 4), 'utf8');
};

/**
 * @method addClientConf
 * @description Adds the client configuration to the server.
 * @param {object} toOptions - The options for the target configuration.
 * @param {object} fromOptions - The options for the source configuration.
 * @param {object} [fromDefaultOptions={ deployId: 'dd-default', clientId: 'default' }] - The default options for the source configuration.
 * @memberof ServerConfBuilder
 */
const addClientConf = async (
  { toOptions, fromOptions },
  fromDefaultOptions = { deployId: 'dd-default', clientId: 'default' },
) => {
  if (!fromOptions.deployId) fromOptions.deployId = fromDefaultOptions.deployId;
  if (!fromOptions.clientId) fromOptions.clientId = fromDefaultOptions.clientId;

  const confFromFolder = `./engine-private/conf/${fromOptions.deployId}`;
  const confToFolder = `./engine-private/conf/${toOptions.deployId}`;

  const toClientConf = JSON.parse(fs.readFileSync(`${confToFolder}/conf.client.json`, 'utf8'));
  const fromClientConf = JSON.parse(fs.readFileSync(`${confFromFolder}/conf.client.json`, 'utf8'));

  const toClientVariableName = getCapVariableName(toOptions.clientId);
  const fromClientVariableName = getCapVariableName(fromOptions.clientId);

  const { host, path } = toOptions;

  toClientConf[fromOptions.clientId] = fromClientConf[fromOptions.clientId];

  fs.writeFileSync(`${confToFolder}/conf.client.json`, JSON.stringify(toClientConf, null, 4), 'utf8');

  const toServerConf = JSON.parse(fs.readFileSync(`${confToFolder}/conf.server.json`, 'utf8'));
  const fromServerConf = JSON.parse(fs.readFileSync(`${confToFolder}/conf.server.json`, 'utf8'));

  toServerConf[host][path].client = fromOptions.clientId;
  toServerConf[host][path].runtime = 'nodejs';
  toServerConf[host][path].apis = fromClientConf[fromOptions.clientId].services;

  fs.writeFileSync(`${confToFolder}/conf.server.json`, JSON.stringify(toServerConf, null, 4), 'utf8');

  const fromSsrConf = JSON.parse(fs.readFileSync(`${confFromFolder}/conf.ssr.json`, 'utf8'));
  const toSsrConf = JSON.parse(fs.readFileSync(`${confToFolder}/conf.ssr.json`, 'utf8'));

  toSsrConf[fromClientVariableName] = fromSsrConf[fromClientVariableName];

  fs.writeFileSync(`${confToFolder}/conf.ssr.json`, JSON.stringify(toSsrConf, null, 4), 'utf8');
};

/**
 * @method buildClientSrc
 * @description Builds the client source code.
 * @param {object} toOptions - The options for the target configuration.
 * @param {object} fromOptions - The options for the source configuration.
 * @param {object} [fromDefaultOptions={ deployId: 'dd-default', clientId: 'default' }] - The default options for the source configuration.
 * @memberof ServerConfBuilder
 */
const buildClientSrc = async (
  { toOptions, fromOptions },
  fromDefaultOptions = { deployId: 'dd-default', clientId: 'default' },
) => {
  if (!fromOptions.deployId) fromOptions.deployId = fromDefaultOptions.deployId;
  if (!fromOptions.clientId) fromOptions.clientId = fromDefaultOptions.clientId;

  const confFromFolder = `./src/client/components/${fromOptions.clientId}`;
  const confToFolder = `./src/client/components/${toOptions.clientId}`;

  const toClientVariableName = getCapVariableName(toOptions.clientId);
  const fromClientVariableName = getCapVariableName(fromOptions.clientId);

  const formattedSrc = (src) =>
    src.replaceAll(fromClientVariableName, toClientVariableName).replaceAll(fromOptions.clientId, toOptions.clientId);

  const isMergeConf = fs.existsSync(confToFolder);
  if (!isMergeConf) fs.mkdirSync(confToFolder, { recursive: true });

  const files = await fs.readdir(confFromFolder, { recursive: true });
  for (const relativePath of files) {
    const fromFilePath = dir.resolve(`${confFromFolder}/${relativePath}`);
    const toFilePath = dir.resolve(`${confToFolder}/${relativePath}`);

    fs.writeFileSync(formattedSrc(toFilePath), formattedSrc(fs.readFileSync(fromFilePath, 'utf8')), 'utf8');
  }

  fs.writeFileSync(
    `./src/client/ssr/head/${toClientVariableName}Scripts.js`,
    formattedSrc(fs.readFileSync(`./src/client/ssr/head/${fromClientVariableName}Scripts.js`, 'utf8')),
    'utf8',
  );

  fs.writeFileSync(
    `./src/client/${toClientVariableName}.index.js`,
    formattedSrc(fs.readFileSync(`./src/client/${fromClientVariableName}.index.js`, 'utf8')),
    'utf8',
  );

  fs.copySync(`./src/client/public/${fromOptions.clientId}`, `./src/client/public/${toOptions.clientId}`);
};

/**
 * @method buildApiSrc
 * @description Builds the API source code.
 * @param {object} toOptions - The options for the target configuration.
 * @param {object} fromOptions - The options for the source configuration.
 * @param {object} [fromDefaultOptions={ apiId: 'default', deployId: 'dd-default', clientId: 'default' }] - The default options for the source configuration.
 * @memberof ServerConfBuilder
 */
const buildApiSrc = async (
  { toOptions, fromOptions },
  fromDefaultOptions = { apiId: 'default', deployId: 'dd-default', clientId: 'default' },
) => {
  if (!fromOptions.apiId) fromOptions.apiId = fromDefaultOptions.apiId;
  if (!fromOptions.deployId) fromOptions.deployId = fromDefaultOptions.deployId;
  if (!fromOptions.clientId) fromOptions.clientId = fromDefaultOptions.clientId;

  const toClientVariableName = getCapVariableName(toOptions.apiId);
  const fromClientVariableName = getCapVariableName(fromOptions.apiId);

  const formattedSrc = (src) =>
    src.replaceAll(fromClientVariableName, toClientVariableName).replaceAll(fromOptions.apiId, toOptions.apiId);

  const apiToFolder = `./src/api/${toOptions.apiId}`;
  const apiFromFolder = `./src/api/${fromOptions.apiId}`;

  const isMergeConf = fs.existsSync(apiToFolder);
  if (!isMergeConf) fs.mkdirSync(apiToFolder, { recursive: true });

  for (const srcApiType of ['model', 'controller', 'service', 'router']) {
    fs.writeFileSync(
      `${apiToFolder}/${toOptions.apiId}.${srcApiType}.js`,
      formattedSrc(fs.readFileSync(`${apiFromFolder}/${fromOptions.apiId}.${srcApiType}.js`, 'utf8')),
      'utf8',
    );
  }

  fs.mkdirSync(`./src/client/services/${toOptions.apiId}`, { recursive: true });
  if (fs.existsSync(`./src/client/services/${fromOptions.apiId}/${fromOptions.apiId}.service.js`))
    fs.writeFileSync(
      `./src/client/services/${toOptions.apiId}/${toOptions.apiId}.service.js`,
      formattedSrc(
        fs.readFileSync(`./src/client/services/${fromOptions.apiId}/${fromOptions.apiId}.service.js`, 'utf8'),
      ),
      'utf8',
    );
};

/**
 * @method addApiConf
 * @description Adds the API configuration to the server.
 * @param {object} toOptions - The options for the target configuration.
 * @param {object} fromOptions - The options for the source configuration.
 * @param {object} [fromDefaultOptions={ apiId: 'default', deployId: 'dd-default', clientId: 'default' }] - The default options for the source configuration.
 * @memberof ServerConfBuilder
 */
const addApiConf = async (
  { toOptions, fromOptions },
  fromDefaultOptions = { apiId: 'default', deployId: 'dd-default', clientId: 'default' },
) => {
  if (!fromOptions.apiId) fromOptions.apiId = fromDefaultOptions.apiId;
  if (!fromOptions.deployId) fromOptions.deployId = fromDefaultOptions.deployId;
  if (!fromOptions.clientId) fromOptions.clientId = fromDefaultOptions.clientId;

  const toClientVariableName = getCapVariableName(toOptions.apiId);
  const fromClientVariableName = getCapVariableName(fromOptions.apiId);

  const confFromFolder = `./engine-private/conf/${fromOptions.deployId}`;
  const confToFolder = `./engine-private/conf/${toOptions.deployId}`;

  const confServer = JSON.parse(fs.readFileSync(`${confToFolder}/conf.server.json`, 'utf8'));
  for (const host of Object.keys(confServer))
    for (const path of Object.keys(confServer[host]))
      if (confServer[host][path].apis) confServer[host][path].apis.push(toOptions.apiId);
  fs.writeFileSync(`${confToFolder}/conf.server.json`, JSON.stringify(confServer, null, 4), 'utf8');

  const confClient = JSON.parse(fs.readFileSync(`${confToFolder}/conf.client.json`, 'utf8'));
  confClient[toOptions.clientId].services.push(toOptions.apiId);
  fs.writeFileSync(`${confToFolder}/conf.client.json`, JSON.stringify(confClient, null, 4), 'utf8');
};

/**
 * @method addWsConf
 * @description Adds the WebSocket configuration to the server.
 * @param {object} toOptions - The options for the target configuration.
 * @param {object} fromOptions - The options for the source configuration.
 * @param {object} [fromDefaultOptions={ wsId: 'default', deployId: 'dd-default', host: 'default.net', paths: '/' }] - The default options for the source configuration.
 * @memberof ServerConfBuilder
 */
const addWsConf = async (
  { toOptions, fromOptions },
  fromDefaultOptions = { wsId: 'default', deployId: 'dd-default', host: 'default.net', paths: '/' },
) => {
  if (!fromOptions.wsId) fromOptions.wsId = fromDefaultOptions.wsId;
  if (!fromOptions.deployId) fromOptions.deployId = fromDefaultOptions.deployId;
  if (!fromOptions.host) fromOptions.host = fromDefaultOptions.host;
  if (!fromOptions.paths) fromOptions.paths = fromDefaultOptions.paths;

  const toClientVariableName = getCapVariableName(toOptions.apiId);
  const fromClientVariableName = getCapVariableName(fromOptions.apiId);

  const confFromFolder = `./engine-private/conf/${fromOptions.deployId}`;
  const confToFolder = `./engine-private/conf/${toOptions.deployId}`;

  const paths = toOptions.paths.split(',');

  const confServer = JSON.parse(fs.readFileSync(`${confToFolder}/conf.server.json`, 'utf8'));
  for (const host of Object.keys(confServer))
    for (const path of Object.keys(confServer[host]))
      if (host === toOptions.host && paths.includes(path) && confServer[host][path])
        confServer[host][path].ws = toOptions.wsId;
  fs.writeFileSync(`${confToFolder}/conf.server.json`, JSON.stringify(confServer, null, 4), 'utf8');
};

/**
 * @method buildWsSrc
 * @description Builds the WebSocket source code.
 * @param {object} toOptions - The options for the target configuration.
 * @param {object} fromOptions - The options for the source configuration.
 * @param {object} [fromDefaultOptions={ wsId: 'default', deployId: 'dd-default', host: 'default.net', paths: '/' }] - The default options for the source configuration.
 * @memberof ServerConfBuilder
 */
const buildWsSrc = async (
  { toOptions, fromOptions },
  fromDefaultOptions = { wsId: 'default', deployId: 'dd-default', host: 'default.net', paths: '/' },
) => {
  if (!fromOptions.wsId) fromOptions.wsId = fromDefaultOptions.wsId;
  if (!fromOptions.deployId) fromOptions.deployId = fromDefaultOptions.deployId;
  if (!fromOptions.host) fromOptions.host = fromDefaultOptions.host;
  if (!fromOptions.paths) fromOptions.paths = fromDefaultOptions.paths;

  const toClientVariableName = getCapVariableName(toOptions.wsId);
  const fromClientVariableName = getCapVariableName(fromOptions.wsId);

  const confFromFolder = `./src/ws/${fromOptions.wsId}`;
  const confToFolder = `./src/ws/${toOptions.wsId}`;

  const paths = toOptions.paths.split(',');

  const formattedSrc = (src) =>
    src.replaceAll(fromClientVariableName, toClientVariableName).replaceAll(fromOptions.wsId, toOptions.wsId);

  const files = await fs.readdir(confFromFolder, { recursive: true });
  for (const relativePath of files) {
    const fromFilePath = dir.resolve(`${confFromFolder}/${relativePath}`);
    const toFilePath = dir.resolve(`${confToFolder}/${relativePath}`);

    if (fs.lstatSync(fromFilePath).isDirectory() && !fs.existsSync(formattedSrc(toFilePath)))
      fs.mkdirSync(formattedSrc(toFilePath), { recursive: true });

    if (fs.lstatSync(fromFilePath).isFile() && !fs.existsSync(formattedSrc(toFilePath))) {
      fs.writeFileSync(formattedSrc(toFilePath), formattedSrc(fs.readFileSync(fromFilePath, 'utf8')), 'utf8');
    }
  }
};

/**
 * @method cloneSrcComponents
 * @description Clones the source components.
 * @param {object} toOptions - The options for the target configuration.
 * @param {object} fromOptions - The options for the source configuration.
 * @memberof ServerConfBuilder
 */
const cloneSrcComponents = async ({ toOptions, fromOptions }) => {
  const toClientVariableName = getCapVariableName(toOptions.componentsFolder);
  const fromClientVariableName = getCapVariableName(fromOptions.componentsFolder);

  const formattedSrc = (src) =>
    src
      .replaceAll(fromClientVariableName, toClientVariableName)
      .replaceAll(fromOptions.componentsFolder, toOptions.componentsFolder);

  const confFromFolder = `./src/client/components/${fromOptions.componentsFolder}`;
  const confToFolder = `./src/client/components/${toOptions.componentsFolder}`;

  fs.mkdirSync(confToFolder, { recursive: true });

  const files = await fs.readdir(confFromFolder);
  for (const relativePath of files) {
    const fromFilePath = dir.resolve(`${confFromFolder}/${relativePath}`);
    const toFilePath = dir.resolve(`${confToFolder}/${relativePath}`);

    fs.writeFileSync(formattedSrc(toFilePath), formattedSrc(fs.readFileSync(fromFilePath, 'utf8')), 'utf8');
  }
};

/**
 * @method buildProxyRouter
 * @description Builds the proxy router.
 * @memberof ServerConfBuilder
 */
const buildProxyRouter = () => {
  const confServer = newInstance(Config.default.server);
  let currentPort = parseInt(process.env.PORT) + 1;
  const proxyRouter = {};
  for (const host of Object.keys(confServer)) {
    for (const path of Object.keys(confServer[host])) {
      if (confServer[host][path].singleReplica) continue;

      if (isDevProxyContext()) confServer[host][path].proxy = [isTlsDevProxy() ? 443 : 80];

      confServer[host][path].port = newInstance(currentPort);
      for (const port of confServer[host][path].proxy) {
        if (!(port in proxyRouter)) proxyRouter[port] = {};
        proxyRouter[port][`${host}${path}`] = {
          // target: `http://${host}:${confServer[host][path].port}${path}`,
          target: `http://localhost:${confServer[host][path].port}`,
          // target: `http://127.0.0.1:${confServer[host][path].port}`,
          proxy: confServer[host][path].proxy,
          redirect: confServer[host][path].redirect,
          host,
          path,
        };
      }
      currentPort++;
      if (confServer[host][path].peer) {
        const peerPath = path === '/' ? `/peer` : `${path}/peer`;
        confServer[host][peerPath] = newInstance(confServer[host][path]);
        confServer[host][peerPath].port = newInstance(currentPort);
        for (const port of confServer[host][path].proxy) {
          if (!(port in proxyRouter)) proxyRouter[port] = {};
          proxyRouter[port][`${host}${peerPath}`] = {
            // target: `http://${host}:${confServer[host][peerPath].port}${peerPath}`,
            target: `http://localhost:${confServer[host][peerPath].port}`,
            // target: `http://127.0.0.1:${confServer[host][peerPath].port}`,
            proxy: confServer[host][peerPath].proxy,
            host,
            path: peerPath,
          };
        }
        currentPort++;
      }
    }
  }

  return proxyRouter;
};

/**
 * @method pathPortAssignmentFactory
 * @description Creates the path port assignment.
 * @param {string} deployId - The deploy ID.
 * @param {object} router - The router.
 * @param {object} confServer - The server configuration.
 * @memberof ServerConfBuilder
 */
const pathPortAssignmentFactory = async (deployId, router, confServer) => {
  const pathPortAssignmentData = {};
  for (const host of Object.keys(confServer)) {
    const pathPortAssignment = [];
    for (const path of Object.keys(confServer[host])) {
      const { peer } = confServer[host][path];
      if (!router[`${host}${path === '/' ? '' : path}`]) continue;
      const port = parseInt(router[`${host}${path === '/' ? '' : path}`].split(':')[2]);
      // logger.info('', { host, port, path });
      pathPortAssignment.push({
        port,
        path,
      });

      if (peer) {
        //  logger.info('', { host, port: port + 1, path: '/peer' });
        pathPortAssignment.push({
          port: port + 1,
          path: `${path === '/' ? '' : path}/peer`,
        });
      }
    }
    pathPortAssignmentData[host] = pathPortAssignment;
  }
  if (fs.existsSync(`./engine-private/replica`)) {
    const singleReplicas = await fs.readdir(`./engine-private/replica`);
    for (let replica of singleReplicas) {
      if (replica.startsWith(deployId)) {
        const replicaServerConf = loadConfServerJson(`./engine-private/replica/${replica}/conf.server.json`);
        for (const host of Object.keys(replicaServerConf)) {
          const pathPortAssignment = [];
          for (const path of Object.keys(replicaServerConf[host])) {
            const { peer } = replicaServerConf[host][path];
            if (!router[`${host}${path === '/' ? '' : path}`]) continue;
            const port = parseInt(router[`${host}${path === '/' ? '' : path}`].split(':')[2]);
            // logger.info('', { host, port, path });
            pathPortAssignment.push({
              port,
              path,
            });

            if (peer) {
              //  logger.info('', { host, port: port + 1, path: '/peer' });
              pathPortAssignment.push({
                port: port + 1,
                path: `${path === '/' ? '' : path}/peer`,
              });
            }
          }
          pathPortAssignmentData[host] = pathPortAssignmentData[host].concat(pathPortAssignment);
        }
      }
    }
  }
  return pathPortAssignmentData;
};

/**
 * @method deployRangePortFactory
 * @description Creates the deploy range port factory.
 * @param {object} router - The router.
 * @returns {object} - The deploy range port factory.
 * @memberof ServerConfBuilder
 */
const deployRangePortFactory = (router) => {
  const ports = Object.values(router).map((p) => parseInt(p.split(':')[2]));
  const fromPort = Math.min(...ports);
  const toPort = Math.max(...ports);
  return { ports, fromPort, toPort };
};

/**
 * @method buildKindPorts
 * @description Builds the kind ports.
 * @param {number} from - The from port.
 * @param {number} to - The to port.
 * @returns {string} - The kind ports.
 * @memberof ServerConfBuilder
 */
const buildKindPorts = (from, to) =>
  range(parseInt(from), parseInt(to))
    .map(
      (port) => `    - name: 'tcp-${port}'
      protocol: TCP
      port: ${port}
      targetPort: ${port}
    - name: 'udp-${port}'
      protocol: UDP
      port: ${port}
      targetPort: ${port}
`,
    )
    .join('\n');

/**
 * @method buildPortProxyRouter
 * @description Builds the port proxy router.
 * @param {object} options - The options.
 * @param {number} [options.port=4000] - The port.
 * @param {object} options.proxyRouter - The proxy router.
 * @param {object} [options.hosts] - The hosts.
 * @param {boolean} [options.orderByPathLength=false] - Whether to order by path length.
 * @param {boolean} [options.devProxyContext=false] - Whether to use dev proxy context.
 * @returns {object} - The port proxy router.
 * @memberof ServerConfBuilder
 */
const buildPortProxyRouter = (
  options = { port: 4000, proxyRouter, hosts, orderByPathLength: false, devProxyContext: false },
) => {
  let { port, proxyRouter, hosts, orderByPathLength } = options;
  hosts = hosts || proxyRouter[port] || {};

  const router = {};
  // build router
  Object.keys(hosts).map((hostKey) => {
    let { host, path, target, proxy, peer } = hosts[hostKey];

    if (!proxy.includes(port)) {
      logger.warn('Proxy port not set on conf', { port, host, path, proxy, target });
      if (process.env.NODE_ENV === 'production') {
        logger.warn('Omitting host', { host, path, target });
        return;
      }
    }
    // ${process.env.NODE_ENV === 'development' && !isDevProxyContext() ? `:${port}` : ''}
    const absoluteHost = [80, 443].includes(port)
      ? `${host}${path === '/' ? '' : path}`
      : `${host}:${port}${path === '/' ? '' : path}`;

    if (absoluteHost in router)
      logger.warn('Overwrite: Absolute host already exists on router', { absoluteHost, target });

    if (options.devProxyContext === true) {
      const appDevPort = parseInt(target.split(':')[2]) - process.env.DEV_PROXY_PORT_OFFSET;
      router[absoluteHost] = `http://localhost:${appDevPort}`;
    } else router[absoluteHost] = target;
  }); // order router

  if (Object.keys(router).length === 0) return router;

  const devApiConfPath = `./engine-private/conf/${process.argv[3]}/conf.server.dev.${process.argv[4]}-dev-api.json`;
  if (options.devProxyContext === true && process.env.NODE_ENV === 'development' && fs.existsSync(devApiConfPath)) {
    const confDevApiServer = JSON.parse(fs.readFileSync(devApiConfPath, 'utf8'));
    let devApiHosts = [];
    let origins = [];
    for (const _host of Object.keys(confDevApiServer))
      for (const _path of Object.keys(confDevApiServer[_host])) {
        if (confDevApiServer[_host][_path].origins && confDevApiServer[_host][_path].origins.length) {
          origins.push(...confDevApiServer[_host][_path].origins);
          if (_path !== 'peer' && devApiHosts.length === 0)
            devApiHosts.push(
              `${_host}${[80, 443].includes(port) && isDevProxyContext() ? '' : `:${port}`}${_path == '/' ? '' : _path}`,
            );
        }
      }
    origins = Array.from(new Set(origins));
    console.log({
      origins,
      devApiHosts,
    });
    for (const devApiHost of devApiHosts) {
      if (devApiHost in router) {
        const target = router[devApiHost];
        delete router[devApiHost];
        router[`${devApiHost}/${process.env.BASE_API}`] = target;
        router[`${devApiHost}/socket.io`] = target;
        for (const origin of origins) router[devApiHost] = origin;
      }
    }
  }

  if (orderByPathLength === true) {
    const reOrderRouter = {};
    for (const absoluteHostKey of orderArrayFromAttrInt(Object.keys(router), 'length'))
      reOrderRouter[absoluteHostKey] = router[absoluteHostKey];
    return reOrderRouter;
  }

  return router;
};

/**
 * @method buildReplicaId
 * @description Builds the replica ID.
 * @param {object} options - The options.
 * @param {string} options.deployId - The deploy ID.
 * @param {string} options.replica - The replica.
 * @returns {string} - The replica ID.
 * @memberof ServerConfBuilder
 */
const buildReplicaId = ({ deployId, replica }) => `${deployId}-${replica.slice(1)}`;

/**
 * @method getDataDeploy
 * @description Gets the data deploy.
 * @param {object} options - The options.
 * @param {boolean} [options.buildSingleReplica=false] - The build single replica.
 * @param {string} [options.deployId] - The deploy ID.
 * @param {boolean} [options.disableSyncEnvPort=false] - The disable sync env port.
 * @returns {object} - The data deploy.
 * @memberof ServerConfBuilder
 */
const getDataDeploy = async (
  options = {
    buildSingleReplica: false,
    disableSyncEnvPort: false,
  },
) => {
  let dataDeploy = fs
    .readFileSync(`./engine-private/deploy/dd.router`, 'utf8')
    .split(',')
    .map((deployId) => deployId.trim())
    .filter((deployId) => deployId);

  dataDeploy = dataDeploy.map((deployId) => {
    return {
      deployId,
    };
  });

  if (options && options.buildSingleReplica && fs.existsSync(`./engine-private/replica`))
    fs.removeSync(`./engine-private/replica`);

  let buildDataDeploy = [];
  for (const deployObj of dataDeploy) {
    const isReplicaDeploy = fs.existsSync(`./engine-private/replica/${deployObj.deployId}`);
    const serverConf = loadReplicas(
      deployObj.deployId,
      loadConfServerJson(`./engine-private/conf/${deployObj.deployId}/conf.server.json`),
    );
    let replicaDataDeploy = [];
    for (const host of Object.keys(serverConf))
      for (const path of Object.keys(serverConf[host])) {
        if (!isReplicaDeploy && serverConf[host][path].replicas && serverConf[host][path].singleReplica) {
          if (options && options.buildSingleReplica)
            await Underpost.repo.client(deployObj.deployId, '', host, path, {
              singleReplica: true,
            });
          replicaDataDeploy = replicaDataDeploy.concat(
            serverConf[host][path].replicas.map((r) => {
              return {
                deployId: buildReplicaId({ deployId: deployObj.deployId, replica: r }),
                replicaHost: host,
              };
            }),
          );
        }
      }
    buildDataDeploy.push(deployObj);
    if (replicaDataDeploy.length > 0) buildDataDeploy = buildDataDeploy.concat(replicaDataDeploy);
  }

  if (!options.disableSyncEnvPort && options.buildSingleReplica)
    await Underpost.repo.client(undefined, '', '', '', { syncEnvPort: true });

  logger.info('Deployments configured', buildDataDeploy);

  return buildDataDeploy;
};

/**
 * @method validateTemplatePath
 * @description Validates the template path.
 * @param {string} absolutePath - The absolute path.
 * @returns {boolean} - The validation result.
 * @memberof ServerConfBuilder
 */
const validateTemplatePath = (absolutePath = '') => {
  const host = 'default.net';
  const path = '/';
  const client = 'default';
  const ssr = 'Default';
  const confServer = DefaultConf.server[host][path];
  const confClient = DefaultConf.client[client];
  const confSsr = DefaultConf.ssr[ssr];
  const clients = DefaultConf.client.default.services;

  if (
    absolutePath.match('src/api') &&
    !absolutePath.match('src/api/types.js') &&
    !confServer.apis.find((p) => absolutePath.match(`src/api/${p}/`))
  ) {
    return false;
  }
  if (absolutePath.match('conf.dd-') && absolutePath.match('.js')) return false;
  if (absolutePath.match('typedoc.dd-') && absolutePath.match('.json')) return false;
  if (
    absolutePath.match('src/client/services/') &&
    !clients.find((p) => absolutePath.match(`src/client/services/${p}/`))
  ) {
    return false;
  }
  if (absolutePath.match('src/client/public/') && !clients.find((p) => absolutePath.match(`src/client/public/${p}/`))) {
    return false;
  }
  if (
    absolutePath.match('src/client/components/') &&
    !clients.find((p) => absolutePath.match(`src/client/components/${p}/`))
  ) {
    return false;
  }
  if (absolutePath.match('src/client/sw/') && !absolutePath.match('src/client/sw/core.sw.js')) {
    return false;
  }
  if (
    absolutePath.match('src/client/ssr/body') &&
    !confSsr.body.find((p) => absolutePath.match(`src/client/ssr/body/${p}.js`))
  ) {
    return false;
  }
  if (
    absolutePath.match('src/client/ssr/head') &&
    !confSsr.head.find((p) => absolutePath.match(`src/client/ssr/head/${p}.js`))
  ) {
    return false;
  }
  if (
    absolutePath.match('src/client/ssr/mailer') &&
    !Object.keys(confSsr.mailer).find((p) => absolutePath.match(`src/client/ssr/mailer/${confSsr.mailer[p]}.js`))
  ) {
    return false;
  }
  if (
    absolutePath.match('src/client/ssr/views') &&
    !(confSsr.views || []).find((p) => absolutePath.match(`src/client/ssr/views/${p.client}.js`))
  ) {
    return false;
  }
  if (absolutePath.match('hardhat/')) return false;
  if (
    absolutePath.match('/client') &&
    absolutePath.match('.index.js') &&
    !absolutePath.match('/offline') &&
    !clients.find((p) => absolutePath.match(`src/client/${capFirst(p)}.index.js`))
  ) {
    return false;
  }
  if (absolutePath.match('src/ws/') && !clients.find((p) => absolutePath.match(`src/ws/${p}/`))) {
    return false;
  }
  return true;
};

/**
 * @method awaitDeployMonitor
 * @description Waits for the deploy monitor.
 * @param {boolean} [isFinal=false] - If true, logs when the final (non-replica) deployment completes.
 * @param {number} [deltaMs=1000] - The delta ms.
 * @param {boolean} [callback=false] - The callback.
 * @returns {Promise<boolean>} - `false` if `container-status=error` was detected, `true` on clean completion.
 * @memberof ServerConfBuilder
 */
const awaitDeployMonitor = async (isFinal = false, deltaMs = 1000, callback = false) => {
  if (!callback) Underpost.env.set('await-deploy', new Date().toISOString());
  if (isFinal) logger.info('Final deployment running (no replica)');
  await timer(deltaMs);
  if (Underpost.env.get('container-status') === 'error') return false;
  if (Underpost.env.get('await-deploy')) return await awaitDeployMonitor(false, deltaMs, true);
  return true;
};

/**
 * @method mergeFile
 * @description Merges the file.
 * @param {Array} parts - The parts.
 * @param {string} outputFilePath - The output file path.
 * @returns {Promise<void>} - The merge file.
 * @memberof ServerConfBuilder
 */
const mergeFile = async (parts = [], outputFilePath) => {
  await new Promise((resolve) => {
    splitFile
      .mergeFiles(parts, outputFilePath)
      .then(() => {
        resolve();
      })
      .catch((err) => {
        console.log('Error: ', err);
        resolve();
      });
  });
};

/**
 * @method getPathsSSR
 * @description Gets the paths SSR.
 * @param {object} conf - The conf.
 * @returns {Array} - The paths SSR.
 * @memberof ServerConfBuilder
 */
const getPathsSSR = (conf) => {
  const paths = ['src/client/ssr/RootDocument.js'];
  for (const o of conf.head) paths.push(`src/client/ssr/head/${o}.js`);
  for (const o of conf.body) paths.push(`src/client/ssr/body/${o}.js`);
  for (const o of Object.keys(conf.mailer)) paths.push(`src/client/ssr/mailer/${conf.mailer[o]}.js`);
  for (const o of conf.views || []) paths.push(`src/client/ssr/views/${o.client}.js`);
  return paths;
};

/**
 * @method splitFileFactory
 * @description Splits the file factory.
 * @param {string} name - The name.
 * @param {string} _path - The path.
 * @returns {Promise<boolean>} - The split file factory.
 * @memberof ServerConfBuilder
 */
const splitFileFactory = async (name, _path) => {
  const stats = fs.statSync(_path);
  const maxSizeInBytes = 1024 * 1024 * 50; // 50 mb
  const fileSizeInBytes = stats.size;
  if (fileSizeInBytes > maxSizeInBytes) {
    logger.info('splitFileFactory input', { name, from: _path });
    return await new Promise((resolve) => {
      splitFile
        .splitFileBySize(_path, maxSizeInBytes) // 50 mb
        .then((names) => {
          logger.info('splitFileFactory output', { parts: names });
          fs.writeFileSync(
            `${_path.split('/').slice(0, -1).join('/')}/${name}-parths.json`,
            JSON.stringify(names, null, 4),
            'utf8',
          );
          fs.removeSync(_path);
          return resolve(true);
        })
        .catch((err) => {
          console.log('Error: ', err);
          return resolve(false);
        });
    });
  }
  return false;
};

/**
 * @method resolveReplicaCount
 * @description Normalizes a CLI `--replicas` value to a positive integer, falling back to the
 * caller's default when unset or invalid. Deliberately does not clamp upward: a floor would
 * silently override an explicit, lower request, which is what made `--replicas 2` deploy three
 * MongoDB members. Single source of truth so every statefulset reads the flag the same way.
 * @param {string|number} input - Raw `--replicas` value.
 * @param {number} [fallback=1] - Count to use when input is absent or not a positive integer.
 * @returns {number} Effective replica count.
 * @memberof ServerConfBuilder
 */
const resolveReplicaCount = (input, fallback = 1) => {
  const parsed = Number.parseInt(input, 10);
  return Number.isInteger(parsed) && parsed > 0 ? parsed : fallback;
};

/**
 * @method generateSecurePassword
 * @description Generates a cryptographically secure password satisfying every validatePassword
 * constraint (lowercase, uppercase, digit, special character). Backed by `crypto.randomBytes`,
 * never `Math.random`, because these values become long-lived service credentials.
 * @param {number} [length=16] - Password length; values below 8 are raised to 8.
 * @returns {string} The generated password.
 * @memberof ServerConfBuilder
 */
const generateSecurePassword = (length = 16) => {
  const size = Math.max(8, length);
  const lower = 'abcdefghijklmnopqrstuvwxyz';
  const upper = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ';
  const digits = '0123456789';
  const special = '@#$%^&*()_+';
  const all = lower + upper + digits + special;
  const buf = crypto.randomBytes(size + 4);
  // Guarantee at least one character from each required class
  const chars = [
    lower[buf[0] % lower.length],
    upper[buf[1] % upper.length],
    digits[buf[2] % digits.length],
    special[buf[3] % special.length],
  ];
  for (let i = 4; i < size; i++) chars.push(all[buf[i] % all.length]);
  // Fisher-Yates shuffle using an independent random buffer
  const shuf = crypto.randomBytes(size);
  for (let i = chars.length - 1; i > 0; i--) {
    const j = shuf[i % shuf.length] % (i + 1);
    [chars[i], chars[j]] = [chars[j], chars[i]];
  }
  return chars.join('');
};

/**
 * @method buildCliDoc
 * @description Scrapes `node bin help` (and `node bin help <command>` for every
 * registered command) and renders a structured Markdown reference: a command
 * index with anchor links, plus a per-command section with its description,
 * usage, and Arguments/Options rendered as tables. Writes
 * `CLI-HELP.md` + the served reference doc, and refreshes the README CLI index.
 * @param {object} program - The commander program.
 * @param {string} oldVersion - The old version string to replace.
 * @param {string} newVersion - The new version string.
 * @memberof ServerConfBuilder
 */
const buildCliDoc = (program, oldVersion, newVersion) => {
  const help = (args = '') => shellExec(`node bin help${args ? ` ${args}` : ''}`, { silent: true, stdout: true });
  // Escape table-breaking pipes and collapse wrapped whitespace for a Markdown cell.
  const cell = (s) => String(s).replace(/\s+/g, ' ').replaceAll('|', '\\|').trim();
  const anchor = (name) => `underpost-${name}`.toLowerCase().replace(/[^a-z0-9-]/g, '');

  // Parse a commander help block into { usage, description, sections: { Options, Arguments, Commands } }.
  const parseHelp = (text) => {
    const lines = text.split('\n');
    const usageMatch = lines[0].match(/^Usage:\s*(.*)$/);
    const usage = usageMatch ? usageMatch[1].trim() : '';
    const sections = {};
    const descLines = [];
    let current = null;
    let buf = [];
    const flush = () => {
      if (current) sections[current] = buf.join('\n');
      buf = [];
    };
    for (let i = 1; i < lines.length; i++) {
      const line = lines[i];
      const head = line.match(/^([A-Za-z][\w ]*):\s*$/); // top-level "Options:", "Arguments:", "Commands:"
      if (head) {
        flush();
        current = head[1].trim();
      } else if (current !== null) {
        buf.push(line);
      } else {
        descLines.push(line);
      }
    }
    flush();
    return { usage, description: descLines.join('\n').trim(), sections };
  };

  // Parse a columnar "  <term>   <description>" section (descriptions may wrap onto
  // indented continuation lines) into [{ term, desc }].
  const parseEntries = (text = '') => {
    const entries = [];
    for (const line of text.split('\n')) {
      if (!line.trim()) continue;
      const leading = line.length - line.trimStart().length;
      if (leading <= 2) {
        const rest = line.trim();
        const gap = rest.search(/\s{2,}/);
        entries.push(gap === -1 ? { term: rest, desc: '' } : { term: rest.slice(0, gap), desc: rest.slice(gap) });
      } else if (entries.length) {
        entries[entries.length - 1].desc += ` ${line.trim()}`;
      }
    }
    return entries;
  };

  const table = (head, entries) =>
    !entries.length
      ? ''
      : `| ${head[0]} | ${head[1]} |\n| --- | --- |\n` +
        entries.map(({ term, desc }) => `| \`${cell(term)}\` | ${cell(desc)} |`).join('\n') +
        '\n';

  const detailSection = (sections, name, head) => {
    const t = table(head, parseEntries(sections[name]));
    return t ? `\n#### ${name}\n\n${t}` : '';
  };

  // ── Top-level index ──
  const root = parseHelp(help());
  const commandEntries = parseEntries(root.sections['Commands']).filter((e) => e.term.split(' ')[0] !== 'help');

  const index =
    `## Underpost CLI\n\n` +
    (root.description ? `> ${root.description.replace(/\s+/g, ' ')}\n\n` : '') +
    `**Usage:** \`${root.usage}\`\n\n` +
    `### Global options\n\n${table(['Option', 'Description'], parseEntries(root.sections['Options']))}\n` +
    `### Commands\n\n| Command | Description |\n| --- | --- |\n` +
    commandEntries
      .map((e) => {
        const name = e.term.split(' ')[0];
        return `| [\`${name}\`](#${anchor(name)}) | ${cell(e.desc)} |`;
      })
      .join('\n') +
    '\n';

  // ── Per-command detail ──
  let details = `\n## Command reference\n`;
  for (const cmd of program.commands) {
    const name = cmd._name;
    if (name === 'help') continue;
    const cmdHelp = parseHelp(help(name));
    details +=
      `\n### underpost ${name}\n\n` +
      (cmdHelp.description ? `${cmdHelp.description.replace(/\s+/g, ' ')}\n\n` : '') +
      `**Usage:** \`${cmdHelp.usage}\`\n` +
      detailSection(cmdHelp.sections, 'Arguments', ['Argument', 'Description']) +
      detailSection(cmdHelp.sections, 'Options', ['Option', 'Description']) +
      `\n---\n`;
  }

  const md = `${index}${details}`.replaceAll(oldVersion, newVersion);
  fs.writeFileSync(`./src/client/public/nexodev/docs/references/Command Line Interface.md`, md, 'utf8');
  fs.writeFileSync(`./CLI-HELP.md`, md, 'utf8');

  // Update README.md: bump version and refresh the CLI index between the comment tags.
  let readme = fs.readFileSync(`./README.md`, 'utf8').replaceAll(oldVersion, newVersion);
  const cliStartTag = '<!-- cli-index-start -->';
  const cliEndTag = '<!-- cli-index-end -->';
  const startIdx = readme.indexOf(cliStartTag);
  const endIdx = readme.indexOf(cliEndTag);
  if (startIdx !== -1 && endIdx !== -1) {
    const readmeIndex = index.replace(/\(#(underpost-[a-z0-9-]+)\)/g, '(CLI-HELP.md#$1)');
    readme =
      readme.substring(0, startIdx) +
      cliStartTag +
      '\n' +
      readmeIndex.replaceAll(oldVersion, newVersion) +
      '\n' +
      cliEndTag +
      readme.substring(endIdx + cliEndTag.length);
  }
  fs.writeFileSync('./README.md', readme, 'utf8');
};

/**
 * @method getInstanceContext
 * @description Gets the instance context.
 * @param {object} options - The options.
 * @param {string} options.deployId - The deploy ID.
 * @param {boolean} options.singleReplica - The single replica.
 * @param {Array} options.replicas - The replicas.
 * @param {string} options.redirect - The redirect.
 * @param {boolean} [options.peer=false] - Whether peer is enabled on the parent singleReplica path (used for port offset estimation when replica conf is not yet built).
 * @returns {object} - The instance context.
 * @memberof ServerConfBuilder
 */
const getInstanceContext = async (options = { deployId, singleReplica, replicas, redirect: '', peer: false }) => {
  const { deployId, singleReplica, replicas, redirect, peer } = options;
  let singleReplicaOffsetPortSum = 0;

  if (singleReplica && replicas && replicas.length > 0) {
    for (const replica of replicas) {
      const replicaDeployId = buildReplicaId({ deployId, replica });
      const replicaConfPath = `./engine-private/replica/${replicaDeployId}/conf.server.json`;
      if (!fs.existsSync(replicaConfPath)) {
        // Replica folder not built yet (e.g. dev mode without prior build);
        // estimate port offset: 1 per replica path + 1 extra if peer is enabled on the parent singleReplica config
        singleReplicaOffsetPortSum++;
        if (peer) singleReplicaOffsetPortSum++;
        continue;
      }
      const confReplicaServer = loadConfServerJson(replicaConfPath);
      for (const host of Object.keys(confReplicaServer)) {
        for (const path of Object.keys(confReplicaServer[host])) {
          singleReplicaOffsetPortSum++;
          if (confReplicaServer[host][path].peer) singleReplicaOffsetPortSum++;
        }
      }
    }
  }

  const redirectTarget = redirect
    ? redirect[redirect.length - 1] === '/'
      ? redirect.slice(0, -1)
      : redirect
    : undefined;

  return { redirectTarget, singleReplicaOffsetPortSum };
};

/**
 * @method buildApiConf
 * @description Builds the API configuration.
 * @param {object} options - The options.
 * @param {string} options.deployId - The deploy ID.
 * @param {string} options.subConf - The sub configuration.
 * @param {string} options.host - The host.
 * @param {string} options.path - The path.
 * @param {string} options.origin - The origin.
 * @returns {object} - The API configuration.
 * @memberof ServerConfBuilder
 */
const buildApiConf = async (options = { deployId: '', subConf: '', host: '', path: '', origin: '' }) => {
  let { deployId, subConf, host, path, origin } = options;
  if (!deployId) deployId = process.argv[2].trim();
  if (!subConf) subConf = process.argv[3].trim();
  if (process.argv[4]) host = process.argv[4].trim();
  if (process.argv[5]) path = process.argv[5].trim();
  if (process.argv[6])
    origin = `${process.env.NODE_ENV === 'production' ? 'https' : 'http'}://${process.argv[6].trim()}`;

  if (!origin) return;
  const confServer = JSON.parse(
    fs.readFileSync(`./engine-private/conf/${deployId}/conf.server.dev.${subConf}.json`, 'utf8'),
  );
  const envObj = dotenv.parse(
    fs.readFileSync(`./engine-private/conf/${deployId}/.env.${process.env.NODE_ENV}`, 'utf8'),
  );
  if (host && path) {
    confServer[host][path].origins = [origin];
    logger.info('Build api conf', { host, path, origin });
  } else return;
  writeEnv(`./engine-private/conf/${deployId}/.env.${process.env.NODE_ENV}.${subConf}-dev-api`, envObj);
  fs.writeFileSync(
    `./engine-private/conf/${deployId}/conf.server.dev.${subConf}-dev-api.json`,
    JSON.stringify(confServer, null, 4),
    'utf8',
  );
};

/**
 * @method buildClientStaticConf
 * @description Builds the client static configuration.
 * @param {object} options - The options.
 * @param {string} options.deployId - The deploy ID.
 * @param {string} options.subConf - The sub configuration.
 * @param {string} options.apiBaseHost - The API base host.
 * @param {string} options.host - The host.
 * @param {string} options.path - The path.
 * @param {boolean} options.devProxy - The dev proxy flag.
 * @returns {void}
 * @memberof ServerConfBuilder
 */
const buildClientStaticConf = async (
  options = { deployId: '', subConf: '', apiBaseHost: '', host: '', path: '', devProxy: false },
) => {
  let { deployId, subConf, host, path, devProxy } = options;
  if (!deployId) deployId = process.argv[2].trim();
  if (!subConf) subConf = process.argv[3].trim();
  if (!host) host = process.argv[4].trim();
  if (!path) path = process.argv[5].trim();
  const confServer = JSON.parse(
    fs.readFileSync(`./engine-private/conf/${deployId}/conf.server.dev.${subConf}-dev-api.json`, 'utf8'),
  );
  const envObj = dotenv.parse(
    fs.readFileSync(`./engine-private/conf/${deployId}/.env.${process.env.NODE_ENV}.${subConf}-dev-api`, 'utf8'),
  );
  envObj.PORT = parseInt(envObj.PORT);
  const apiBaseHost = devProxy
    ? devProxyHostFactory({ host, tls: isTlsDevProxy() })
    : options?.apiBaseHost
      ? options.apiBaseHost
      : `localhost:${envObj.PORT + 1}`;
  confServer[host][path].apiBaseHost = apiBaseHost;
  confServer[host][path].apiBaseProxyPath = path;
  logger.warn('Build client static conf', { host, path, apiBaseHost });
  envObj.PORT = parseInt(confServer[host][path].origins[0].split(':')[2]) - 1;
  writeEnv(`./engine-private/conf/${deployId}/.env.${process.env.NODE_ENV}.${subConf}-dev-client`, envObj);
  fs.writeFileSync(
    `./engine-private/conf/${deployId}/conf.server.dev.${subConf}-dev-client.json`,
    JSON.stringify(confServer, null, 4),
    'utf8',
  );
};

/**
 * @method isDeployRunnerContext
 * @description Checks if the deploy runner context is valid.
 * @param {string} path - The path.
 * @param {object} options - The options.
 * @returns {boolean} - The deploy runner context.
 * @memberof ServerConfBuilder
 */
const isDeployRunnerContext = (path, options) => !options.build && path && path !== 'template-deploy';

/**
 * @method isDevProxyContext
 * @description Checks if the dev proxy context is valid.
 * @returns {boolean} - The dev proxy context.
 * @memberof ServerConfBuilder
 */
const isDevProxyContext = () => (process.argv.find((arg) => arg === 'proxy') ? true : false);

/**
 * @method devProxyHostFactory
 * @description Creates the dev proxy host.
 * @param {object} options - The options.
 * @param {string} [options.host='default.net'] - The host.
 * @param {boolean} [options.includeHttp=false] - Whether to include HTTP.
 * @param {number} [options.port=443] - The port.
 * @param {boolean} [options.tls=false] - Whether to use TLS.
 * @returns {string} - The dev proxy host.
 * @memberof ServerConfBuilder
 */
const devProxyHostFactory = (options = { host: 'default.net', includeHttp: false, port: 80, tls: false }) => {
  const resolvedPort =
    (options.port ? options.port : options.tls ? 443 : 80) + parseInt(process.env.DEV_PROXY_PORT_OFFSET);
  const isDefaultPort = (options.tls && resolvedPort === 443) || (!options.tls && resolvedPort === 80);
  const protocol = options.includeHttp ? (options.tls ? 'https://' : 'http://') : '';
  const hostname = options.host ? options.host : 'localhost';
  return `${protocol}${hostname}${isDefaultPort ? '' : `:${resolvedPort}`}`;
};

/**
 * @method isTlsDevProxy
 * @description Checks if TLS is used in the dev proxy.
 * @returns {boolean} - The TLS dev proxy status.
 * @memberof ServerConfBuilder
 */
const isTlsDevProxy = () => process.env.NODE_ENV !== 'production' && !!process.argv.find((arg) => arg === 'tls');

/**
 * @method getTlsHosts
 * @description Gets the TLS hosts.
 * @param {object} confServer - The server configuration.
 * @returns {Array} - The TLS hosts.
 * @memberof ServerConfBuilder
 */
const getTlsHosts = (confServer) =>
  Array.from(new Set(Object.keys(confServer).map((h) => new URL('https://' + h).hostname)));

/**
 * Reads a `conf.server.json` file from disk, parses it, and resolves all `env:` secret
 * references using {@link resolveConfSecrets}.
 *
 * Reads and parses a `conf.server.json` file from disk. The `env:` secret
 * references are **preserved** by default so that build/deploy tooling never
 * accidentally strips them.  Callers that need the actual secret values
 * (e.g. database or mailer modules) should explicitly wrap the result with
 * {@link resolveConfSecrets}.
 *
 * @method loadConfServerJson
 * @param {string} jsonPath - Absolute or relative path to the `conf.server.json` file.
 * @param {object} [options] - Optional settings.
 * @param {boolean} [options.resolve=false] - When `true`, resolves `env:` references
 *   via {@link resolveConfSecrets} before returning.
 * @returns {object} The parsed server configuration object (secrets unresolved unless
 *   `options.resolve` is `true`).
 * @throws {Error} If the file does not exist or cannot be parsed.
 * @memberof ServerConfBuilder
 *
 * @example
 * // Structure-only read (env: strings preserved)
 * const confServer = loadConfServerJson(`./engine-private/conf/${deployId}/conf.server.json`);
 *
 * @example
 * // Resolved read (env: strings replaced with process.env values)
 * const confServer = loadConfServerJson(`./engine-private/conf/${deployId}/conf.server.json`, { resolve: true });
 */
const loadConfServerJson = (jsonPath, options) => {
  if (!fs.existsSync(jsonPath)) {
    throw new Error(`loadConfServerJson: configuration file not found: ${jsonPath}`);
  }
  const raw = JSON.parse(fs.readFileSync(jsonPath, 'utf8'));
  return options && options.resolve === true ? resolveConfSecrets(raw) : raw;
};

/**
 * @method deepReplaceToken
 * @description Recursively replaces every occurrence of `from` with `to` in all
 * string leaves of a JSON-shaped value, returning a new structure.
 * @param {*} value - Any JSON-serialisable value.
 * @param {string} from - Token to replace.
 * @param {string} to - Replacement token.
 * @returns {*} A structurally identical value with the token replaced.
 * @memberof ServerConfBuilder
 */
const deepReplaceToken = (value, from, to) => {
  if (typeof value === 'string') return value.split(from).join(to);
  if (Array.isArray(value)) return value.map((entry) => deepReplaceToken(entry, from, to));
  if (value && typeof value === 'object')
    return Object.fromEntries(Object.entries(value).map(([k, v]) => [k, deepReplaceToken(v, from, to)]));
  return value;
};

/**
 * @method readConfInstances
 * @description Reads `conf.instances.json` and returns the bare array of instance
 * entries. The canonical shape is a plain array; the historical `{ instances }`
 * object wrapper is still unwrapped for backward compatibility. Multi-instance is
 * declared per entry under `entry.multiInstance` (not deploy-wide).
 * @param {string} deployId - Deployment identifier (e.g. `dd-cyberia`).
 * @returns {Array<object>} Instance entries.
 * @memberof ServerConfBuilder
 */
const readConfInstances = (deployId) => {
  const raw = JSON.parse(fs.readFileSync(`./engine-private/conf/${deployId}/conf.instances.json`, 'utf8'));
  return Array.isArray(raw) ? raw : raw.instances || [];
};

/**
 * @method loadInstanceTopology
 * @description Returns normalized variants describing the deploy's instance
 * topology, or `null` when the deploy is single-instance. The root path is the
 * default variant; no separate default code is declared.
 * @param {string} deployId - Deployment identifier (e.g. `dd-cyberia`).
 * @returns {?{variants: Array<object>}} The topology, or `null`.
 * @memberof ServerConfBuilder
 */
const loadInstanceTopology = (deployId) => {
  for (const entry of readConfInstances(deployId)) {
    const mi = entry.multiInstance;
    if (mi && Array.isArray(mi.variants) && mi.variants.length > 0)
      return normalizeInstanceTopology(mi, `${deployId}/${entry.id}`);
  }
  return null;
};

/**
 * @method normalizeInstanceTopology
 * @description Expands the compact `multiInstance.variants` path list into the
 * descriptors used by deploy tooling. `/` is always the default and keeps the
 * template workload id. `/FOREST` produces code `FOREST`, slug `/forest`, and
 * path `/FOREST`. A missing root entry is prepended automatically.
 * @param {{variants?: Array<string>}} spec - Multi-instance specification.
 * @param {string} [context] - Configuration location used in validation errors.
 * @returns {{variants: Array<{code:string,slug:string,path:string,isDefault:boolean}>}}
 * @memberof ServerConfBuilder
 */
const normalizeInstanceTopology = (spec, context = 'multiInstance') => {
  const declaredPaths = spec?.variants;
  if (!Array.isArray(declaredPaths) || declaredPaths.length === 0) return { variants: [] };
  if (declaredPaths.some((path) => typeof path !== 'string'))
    throw new Error(`${context}: multiInstance.variants must contain only path strings`);
  if (new Set(declaredPaths).size !== declaredPaths.length)
    throw new Error(`${context}: multiInstance.variants contains a duplicate path`);
  const paths = ['/', ...declaredPaths.filter((path) => path !== '/')];

  const variants = paths.map((path) => {
    if (path !== '/' && !/^\/[A-Za-z0-9][A-Za-z0-9._-]*$/.test(path))
      throw new Error(`${context}: invalid instance variant path "${path}"`);
    const code = path.slice(1);
    return { code, slug: path === '/' ? '' : path.toLowerCase(), path, isDefault: path === '/' };
  });

  const seen = new Set();
  for (const variant of variants) {
    if (seen.has(variant.slug)) throw new Error(`${context}: duplicate instance variant "${variant.path}"`);
    seen.add(variant.slug);
  }
  return { variants };
};

/**
 * @method dispatchBuildInstanceEnv
 * @description Applies an optional deploy-specific env builder to a canonical
 * env object. Generic topology code never knows project env key names; callers
 * register builders by deploy id. The runner-owned container id is applied last.
 * @param {object} options - Dispatch context.
 * @param {string} options.deployId - Deployment id used to select a builder.
 * @param {object} options.instance - Expanded instance descriptor.
 * @param {string} options.environment - development or production.
 * @param {Object<string,string>} options.baseEnv - Parsed canonical env file.
 * @param {string} options.containerDeployId - Runner-derived deployment id.
 * @param {Object<string,Function>} [options.builders] - Deploy id to env builder registry.
 * @returns {Object<string,string>} Complete materialized env object.
 * @memberof ServerConfBuilder
 */
const dispatchBuildInstanceEnv = ({
  deployId,
  instance,
  environment,
  baseEnv = {},
  containerDeployId,
  builders = {},
}) => {
  const builder = builders[deployId];
  const env = builder ? builder({ deployId, instance, environment, env: { ...baseEnv } }) : { ...baseEnv };
  if (!env || typeof env !== 'object' || Array.isArray(env))
    throw new TypeError(`dispatchBuildInstanceEnv: builder for "${deployId}" must return an env object`);
  return { ...env, CONTAINER_DEPLOY_ID: containerDeployId };
};

/**
 * Loads a deploy project's optional instance env builder by convention.
 * `dd-cyberia` resolves to `src/projects/cyberia/instance-data.js`, whose
 * public integration export is `buildInstanceEnv`. Missing modules mean the
 * canonical env is copied unchanged; malformed exports fail explicitly.
 * @param {string} deployId - Deployment id in `dd-<project>` form.
 * @returns {Promise<Function|null>} Project env builder, when provided.
 * @memberof ServerConfBuilder
 */
const loadProjectInstanceEnvBuilder = async (deployId) => {
  const match = /^dd-([a-z0-9][a-z0-9-]*)$/.exec(`${deployId || ''}`);
  if (!match) return null;
  const moduleUrl = new URL(`../projects/${match[1]}/instance-data.js`, import.meta.url);
  if (!fs.existsSync(moduleUrl)) return null;
  const projectModule = await import(moduleUrl.href);
  if (projectModule.buildInstanceEnv === undefined) return null;
  if (typeof projectModule.buildInstanceEnv !== 'function')
    throw new TypeError(`${moduleUrl.pathname}: buildInstanceEnv must be a function`);
  return projectModule.buildInstanceEnv;
};

/**
 * @method loadConfInstances
 * @description Loads `conf.instances.json` and expands every entry carrying a
 * `multiInstance` block into one concrete instance per declared variant.
 *
 * A template entry is never deployed as-is once variants exist: each variant
 * produces its own entry whose id, env file path, volume mount and
 * container-status strings are derived by replacing the template id token
 * throughout. The `/` variant keeps the template id verbatim, so pre-existing
 * deployments, PVCs and env directories survive multi-instance expansion.
 *
 * Nothing here is application-specific: variants preserve their public path
 * through the ingress and the runtime owns that base-path contract.
 * Project-specific env behavior is delegated through
 * {@link dispatchBuildInstanceEnv} rather than encoded in topology configuration.
 *
 * Every expanded entry carries normalized metadata consumed by deploy runners:
 * `instanceCode`, `instanceSlug`, `isDefaultInstance`, and `templateId`.
 *
 * @param {string} deployId - Deployment identifier (e.g. `dd-cyberia`).
 * @returns {Array<object>} Expanded instance entries.
 * @memberof ServerConfBuilder
 */
const loadConfInstances = (deployId) => {
  const expanded = [];
  for (const entry of readConfInstances(deployId)) {
    const spec = entry.multiInstance;
    if (!Array.isArray(spec?.variants) || spec.variants.length === 0) {
      expanded.push(entry.path ? entry : { ...entry, path: '/' });
      continue;
    }
    if (Object.hasOwn(spec, 'env'))
      throw new Error(
        `loadConfInstances: ${deployId}/${entry.id} uses removed multiInstance.env; ` +
          'move project-specific env logic to a dispatch env builder',
      );
    const topology = normalizeInstanceTopology(spec, `${deployId}/${entry.id}`);
    const variants = topology.variants;
    for (const variant of variants) {
      const id = variant.isDefault ? entry.id : `${entry.id}-${variant.slug.slice(1)}`;
      const instance = variant.isDefault ? JSON.parse(JSON.stringify(entry)) : deepReplaceToken(entry, entry.id, id);
      delete instance.multiInstance;
      instance.id = id;
      instance.path = variant.path;
      instance.instanceCode = variant.code;
      instance.instanceSlug = variant.slug;
      instance.isDefaultInstance = variant.isDefault;
      instance.templateId = entry.id;
      expanded.push(instance);
    }
  }
  return expanded;
};

/**
 * @method selectConfInstances
 * @description Filters expanded instances for a deploy target. Targeting a
 * template id (`mmo-server`) selects the whole instance family; targeting a
 * concrete id (`mmo-server-forest`) selects just that one.
 * @param {Array<object>} instances - Expanded entries from {@link loadConfInstances}.
 * @param {string} id - Target instance id or template id.
 * @returns {Array<object>} Matching entries.
 * @memberof ServerConfBuilder
 */
const selectConfInstances = (instances, id) =>
  instances.filter((instance) => instance.id === id || instance.templateId === id);

/**
 * @method resolveEnvScoped
 * @description Resolves a conf value that may be declared either shared or
 * env-scoped. An instance block is written as `{ ...spec }` when both
 * environments share it and as `{ development: {...}, production: {...} }` when
 * they do not; both shapes reach the manifest factories, which expect the
 * resolved one.
 * @param {object|undefined} value - Shared or env-scoped block.
 * @param {string} env - `development` | `production`.
 * @returns {object|undefined} The block for `env`, or the value unchanged when it is shared.
 * @memberof ServerConfBuilder
 */
const resolveEnvScoped = (value, env) => (value && (value.development || value.production) ? value[env] : value);

/**
 * @method instancePortFactory
 * @description The port an instance is reached on for an environment.
 * Development prefers the instance's debug port when it declares one, so a
 * local runtime can expose a debugger without the production port moving.
 * @param {object} instance - Expanded instance entry.
 * @param {string} env - `development` | `production`.
 * @param {boolean} [container] - Resolve the container-side port (`toPort`) instead of the proxied one (`fromPort`).
 * @returns {number|undefined} The effective port.
 * @memberof ServerConfBuilder
 */
const instancePortFactory = ({ instance, env, container = false }) => {
  const [port, debugPort] = container
    ? [instance.toPort, instance.toDebugPort]
    : [instance.fromPort, instance.fromDebugPort];
  return env === 'development' && debugPort ? debugPort : port;
};

/**
 * @method sortInstancesByPath
 * @description Orders instances longest sub-path first, so a specific instance
 * path (`/FOREST`) is never shadowed by the default instance's catch-all (`/`).
 * @param {Array<object>} instances - Expanded instance entries.
 * @returns {Array<object>} A new ordered array.
 * @memberof ServerConfBuilder
 */
const sortInstancesByPath = (instances) =>
  [...instances].sort((a, b) => (b.path || '/').length - (a.path || '/').length);

/**
 * @method deployHostsFactory
 * @description Every hostname a deploy terminates: the ones its `conf.server.json`
 * serves directly, plus the ones its `conf.instances.json` instances serve.
 *
 * Both sets reach the browser through the one Gateway the deploy owns, so both
 * have to appear in its listener's certificate list and in the `/etc/hosts` pass
 * — an instance host missing from either is unreachable even though its workload
 * and route are correct.
 *
 * Every declared hostname is returned, not just the ones being deployed right
 * now: the Gateway is per deploy, not per run, and a certificate reference it
 * carries for a hostname the environment never provisioned is unresolvable —
 * which costs the listener, and with it every other hostname on it.
 * @param {string} deployId - Deployment identifier.
 * @returns {Array<string>} Unique hostnames, deploy hosts first.
 * @memberof ServerConfBuilder
 */
const deployHostsFactory = (deployId) => {
  const confServerPath = `./engine-private/conf/${deployId}/conf.server.json`;
  const confInstancesPath = `./engine-private/conf/${deployId}/conf.instances.json`;
  const serverHosts = fs.existsSync(confServerPath) ? Object.keys(loadConfServerJson(confServerPath)) : [];
  const instanceHosts = fs.existsSync(confInstancesPath)
    ? loadConfInstances(deployId).map((instance) => instance.host)
    : [];
  return [...new Set([...serverHosts, ...instanceHosts].filter(Boolean))];
};

/**
 * @method instanceStatusPageDeployIdFactory
 * @description Status page documents are identical across a family's variants,
 * so the resources holding them are named after the template instance and
 * shared by every variant route.
 * @param {string} deployId - Parent deployment identifier.
 * @param {object} instance - Expanded instance entry.
 * @returns {string} Family-scoped deploy id owning the status page resources.
 * @memberof ServerConfBuilder
 */
const instanceStatusPageDeployIdFactory = (deployId, instance) => `${deployId}-${instance.templateId || instance.id}`;

/**
 * @method instanceProjectPathFactory
 * @description Where an instance's own project sits in this checkout. A
 * `customStatusPages` entry declares `hostPath` relative to that project
 * (`./public/404/index.html`), not to the engine root, because the document is
 * built and versioned by the project the instance runs.
 *
 * The directory is the repository's own name — the same name `underpost clone`
 * checks it out under — falling back to the runtime and then the instance id.
 * @param {object} instance - Expanded instance entry.
 * @returns {string} Project root, relative to the engine root.
 * @memberof ServerConfBuilder
 */
const instanceProjectPathFactory = (instance) =>
  `./${`${instance?.metadata?.repository || instance?.runtime || instance?.id || ''}`.split('/').pop()}`;

/**
 * @method instanceStatusPageEntriesFactory
 * @description Resolves every `customStatusPages` entry an instance declares
 * into the document to copy and the place under the static root to copy it to.
 *
 * Single source of truth for the two sides that must agree exactly: the
 * destination comes from the same {@link UnderpostGateway.statusPageAssetPathFactory}
 * the HTTPRoute rewrites to, so a variant's page lands where that variant's rule
 * points — `/FOREST/404` at `<host>/FOREST/status-pages/404/index.html`, and the
 * default variant at `<host>/root/status-pages/404/index.html`.
 * @param {Array<object>} instances - Expanded instance entries.
 * @param {string} [projectPath] - Project root override; omit to derive one per instance.
 * @returns {Array<{host: string, path: string, status: string, assetPath: string, sourcePath: string}>}
 *   One entry per declared page, skipping entries missing a status or a source.
 * @memberof ServerConfBuilder
 */
const instanceStatusPageEntriesFactory = ({ instances = [], projectPath }) =>
  instances.flatMap((instance) =>
    (instance.customStatusPages || [])
      .filter((page) => page?.status && page?.hostPath)
      .map((page) => ({
        host: instance.host,
        path: instance.path,
        status: `${page.status}`,
        assetPath: statusPageAssetPathFactory({ host: instance.host, path: instance.path, status: page.status })
          .assetPath,
        sourcePath: dir.normalize(`${projectPath || instanceProjectPathFactory(instance)}/${page.hostPath}`),
      })),
  );

/**
 * @method nextTrafficFactory
 * @description The colour a promote routes to next.
 *
 * The single definition of the blue/green flip. An explicit request wins; with no
 * colour live yet the canonical first colour is `blue`.
 * @param {string} [liveTraffic] - Colour currently routed, or empty when none is.
 * @param {string} [requestedTraffic] - Explicitly requested colour, if any.
 * @returns {string} `blue` or `green`.
 * @memberof ServerConfBuilder
 */
const nextTrafficFactory = (liveTraffic = '', requestedTraffic = '') =>
  requestedTraffic === 'blue' || requestedTraffic === 'green'
    ? requestedTraffic
    : liveTraffic === 'blue'
      ? 'green'
      : 'blue';

/**
 * @method schedulableNodeFactory
 * @description Narrows a chosen node name to one the cluster actually has.
 *
 * Node defaults are guessed from the environment when no cluster flag is given —
 * `development` implies a kind cluster and therefore `kind-worker`. That guess is
 * wrong on a `--dev` kubeadm cluster, and for a `hostNetwork` listener pinned by
 * `nodeSelector` it is fatal rather than merely suboptimal: nothing schedules,
 * and the name persists in the live object so every later run inherits it.
 *
 * A control-plane node is the last resort, not the first: on a multi-node cluster
 * the public listener belongs on a worker. With no node list to check against the
 * caller's choice is returned untouched — an unreadable cluster is not evidence
 * the name is wrong.
 * @param {Array<object>} [nodes] - Rows from `kubectl get nodes` (NAME, STATUS, ROLES).
 * @param {string} [node] - The chosen node name.
 * @returns {{node: string, corrected: boolean}} The name to use, and whether it had to change.
 * @memberof ServerConfBuilder
 */
const schedulableNodeFactory = ({ nodes = [], node = '' }) => {
  const named = nodes.filter((entry) => entry?.NAME);
  if (named.length === 0) return { node, corrected: false };
  if (node && named.some((entry) => entry.NAME === node)) return { node, corrected: false };
  // STATUS can be a comma-joined list (e.g. "Ready,SchedulingDisabled").
  const ready = named.filter((entry) => `${entry.STATUS || ''}`.split(',').includes('Ready'));
  const pool = ready.length > 0 ? ready : named;
  const worker = pool.find((entry) => !`${entry.ROLES || ''}`.includes('control-plane'));
  return { node: (worker || pool[0]).NAME, corrected: true };
};

/**
 * @method stopPlanFactory
 * @description Resolves which colour-suffixed Deployments a stop should remove.
 *
 * Four ways to name them, in precedence order:
 *
 * 1. A literal comma path names the Deployments outright and every flag is
 *    ignored — the caller already knows the exact object, so nothing is derived
 *    and nothing else can be caught by accident.
 * 2. `deployId` alone selects that deploy's PWA workload.
 * 3. `deployId` with `instanceId` adds every custom instance of each id; a
 *    template id expands to its whole variant family, since each variant is its
 *    own Deployment.
 * 4. `instanceId` without `deployId` is refused: an instance id is only unique
 *    inside a deploy, so acting on it alone would be a guess.
 *
 * Colour selection is separate: `traffic` names the colours explicitly (a comma
 * list, so `blue,green` stops both), and without it each target resolves to the
 * blue/green partner of whatever it is currently serving — the colour that is by
 * definition not carrying traffic.
 * @param {string} [path] - Literal comma-separated Deployment names.
 * @param {string} [deployId] - Deploy id whose workload and instances are targeted.
 * @param {string} [instanceId] - Comma-separated instance or template ids.
 * @param {string} [traffic] - Comma-separated colours; empty means the inactive one.
 * @param {string} [env] - `development` | `production`.
 * @param {Function} [instancesFor] - `(instanceId) => Array<object>` expanded instances.
 * @param {Function} [liveTrafficOf] - `(target) => 'blue' | 'green' | '' | null`.
 * @returns {{deployments: Array<object>, error: string|null}} The plan, or why there isn't one.
 * @memberof ServerConfBuilder
 */
const stopPlanFactory = ({
  path = '',
  deployId = '',
  instanceId = '',
  traffic = '',
  env = '',
  instancesFor = () => [],
  liveTrafficOf = () => '',
}) => {
  const list = (value) =>
    `${value || ''}`
      .split(',')
      .map((entry) => entry.trim())
      .filter(Boolean);

  const literal = list(path);
  if (literal.length > 0)
    return {
      deployments: literal.map((deployment) => ({ deployment, kind: 'literal', id: deployment, host: '', colour: '' })),
      error: null,
    };

  const instanceIds = list(instanceId);
  if (!deployId)
    return {
      deployments: [],
      error:
        instanceIds.length > 0
          ? '--instance-id requires --deploy-id: an instance id is only unique inside a deploy'
          : 'nothing to stop: pass a literal deployment path, or --deploy-id',
    };

  const requestedRaw = list(traffic);
  const requested = requestedRaw.filter((colour) => colour === 'blue' || colour === 'green');
  if (requestedRaw.length > 0 && requested.length === 0)
    return { deployments: [], error: `--traffic accepts blue and/or green, got: ${requestedRaw.join(',')}` };

  const targets = [{ id: deployId, host: '', kind: 'pwa' }];
  for (const id of instanceIds)
    for (const instance of instancesFor(id))
      targets.push({ id: `${deployId}-${instance.id}`, host: instance.host || '', kind: 'instance' });

  const deployments = [];
  const seen = new Set();
  for (const target of targets)
    for (const colour of requested.length > 0 ? requested : [nextTrafficFactory(liveTrafficOf(target))]) {
      const deployment = `${target.id}-${env}-${colour}`;
      if (seen.has(deployment)) continue;
      seen.add(deployment);
      deployments.push({ ...target, colour, deployment });
    }
  return { deployments, error: null };
};

/**
 * @method trafficFromRoutingInfoFactory
 * @description Reads a deployment's live colour out of the routing text that
 * carries it.
 *
 * Legacy stacks name the backend Service `<deployId>-<env>-<colour>-service`;
 * the stable traffic Service names the same value in `spec.selector.app` without
 * the `-service` suffix. The colour therefore reads the same way during and
 * after migration. Kept pure and separate
 * from the read so one host's routing text can be matched against several
 * deployments and environments without fetching it again.
 *
 * With `env` given the match is anchored on the full `<deployId>-<env>-` prefix:
 * essential on a shared multi-instance host, where one object holds several
 * variants' routes on possibly different colours, and where an unanchored match
 * would return a sibling's answer. `dd-cyberia-mmo-server` never matches
 * `dd-cyberia-mmo-server-forest` this way.
 * @param {string} [info] - Routing text (route object YAML and/or Nginx block).
 * @param {string} deployId - Deployment identifier the colour is wanted for.
 * @param {string} [env] - `development` | `production`; omitted falls back to a whole-text match.
 * @returns {string|null} `blue`, `green`, or null when the text names neither.
 * @memberof ServerConfBuilder
 */
const trafficFromRoutingInfoFactory = ({ info = '', deployId = '', env = '' }) => {
  if (!`${info}`.trim()) return null;
  if (env) {
    const escaped = `${deployId}`.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
    const match = `${info}`.match(new RegExp(`${escaped}-${env}-(blue|green)(?:-service)?(?:\\s|$)`));
    return match ? match[1] : null;
  }
  return `${info}`.match('blue') ? 'blue' : `${info}`.match('green') ? 'green' : null;
};

/**
 * @method trafficProbePathsFactory
 * @description The literal paths a conf.server.json route should be probed on,
 * matching the `replicas`/`singleReplica` convention `loadReplicas` expands for
 * a real build (see push-bundle/pull-bundle): a `singleReplica` route is never
 * itself served, so probing its canonical path always reads as unrouted; a
 * plain `replicas` route serves the canonical path in addition to each replica.
 * Read-only by design — a traffic report must not mutate conf.server.json as a
 * side effect of being read, unlike `loadReplicas`.
 * @param {object} [routeConf] - `confServer[host][path]` entry.
 * @param {string} routePath - The canonical path key.
 * @returns {Array<string>} Paths to probe for this route.
 * @memberof ServerConfBuilder
 */
const trafficProbePathsFactory = (routeConf = {}, routePath) => {
  const replicas = Array.isArray(routeConf.replicas) ? routeConf.replicas : [];
  if (replicas.length === 0) return [routePath];
  return routeConf.singleReplica ? replicas : [routePath, ...replicas];
};

/**
 * @method deployTrafficEntriesFactory
 * @description Every routable deployment a deploy id owns, of both kinds.
 *
 * The two kinds are named differently in the cluster and resolved from different
 * conf files, which is exactly why a colour report has to build them together:
 * the PWA workload is one Deployment per deploy id serving whatever hosts its
 * `conf.server.json` declares, while each expanded custom instance is its own
 * Deployment behind one host sub-path.
 * @param {string} deployId - Deployment identifier.
 * @param {string} env - `development` | `production`.
 * @returns {Array<{kind: string, deployId: string, id: string, host: string, path: string, deployment: string}>} One entry per routable deployment.
 * @memberof ServerConfBuilder
 */
const deployTrafficEntriesFactory = ({ deployId, env }) => {
  const entries = [];
  const confServerPath = `./engine-private/conf/${deployId}/conf.server.json`;
  if (fs.existsSync(confServerPath)) {
    const confServer = loadConfServerJson(confServerPath);
    for (const host of Object.keys(confServer))
      entries.push({
        kind: 'pwa',
        deployId,
        id: deployId,
        host,
        path:
          Object.keys(confServer[host])
            .flatMap((routePath) => trafficProbePathsFactory(confServer[host][routePath], routePath))
            .join(' ') || '/',
        deployment: `${deployId}-${env}`,
      });
  }
  if (fs.existsSync(`./engine-private/conf/${deployId}/conf.instances.json`))
    for (const instance of loadConfInstances(deployId))
      entries.push({
        kind: 'instance',
        deployId,
        id: `${deployId}-${instance.id}`,
        host: instance.host,
        path: instance.path || '/',
        deployment: `${deployId}-${instance.id}-${env}`,
      });
  return entries;
};

/**
 * @method hostIngressFactsFactory
 * @description What the cluster's routing objects say about each hostname: which
 * kind describes it, whether it is served over TLS, and whether HTTP/3 is on.
 *
 * None of the three can be read from the route object alone. The kind is which
 * object exists; TLS lives on the Gateway's listener (an HTTPRoute never carries
 * it) or on an HTTPProxy's `virtualhost.tls`; and HTTP/3 is a `ClientTrafficPolicy`
 * targeting that Gateway. So the answer is a correlation across four kinds, done
 * once for the whole cluster rather than per row.
 *
 * A hostname described by both kinds is a leftover from switching stacks; the
 * HTTPRoute wins, matching the precedence the shared ingress routes it with.
 * @param {Array<object>} [httpRoutes] - HTTPRoute items.
 * @param {Array<object>} [httpProxies] - HTTPProxy items.
 * @param {Array<object>} [gateways] - Gateway items.
 * @param {Array<object>} [clientTrafficPolicies] - ClientTrafficPolicy items.
 * @returns {Object<string,{route: string, tls: boolean, http3: boolean}>} Facts by hostname.
 * @memberof ServerConfBuilder
 */
const hostIngressFactsFactory = ({
  httpRoutes = [],
  httpProxies = [],
  gateways = [],
  clientTrafficPolicies = [],
} = {}) => {
  const tlsGateways = new Set(
    gateways
      .filter((gateway) =>
        (gateway?.spec?.listeners || []).some((listener) => `${listener?.protocol}`.toUpperCase() === 'HTTPS'),
      )
      .map((gateway) => gateway?.metadata?.name)
      .filter(Boolean),
  );
  // QUIC only exists where TLS does, so a policy naming a Gateway with no HTTPS
  // listener describes nothing — the same reason the policy is emitted scoped to
  // the HTTPS section in the first place.
  const http3Gateways = new Set(
    clientTrafficPolicies
      .filter((policy) => policy?.spec?.http3 !== undefined)
      .flatMap((policy) => [...(policy?.spec?.targetRefs || []), policy?.spec?.targetRef].filter(Boolean))
      .map((ref) => ref?.name)
      .filter((name) => name && tlsGateways.has(name)),
  );

  const facts = {};
  for (const proxy of httpProxies) {
    const host = proxy?.spec?.virtualhost?.fqdn;
    if (!host) continue;
    facts[host] = { route: 'HTTPProxy', tls: !!proxy?.spec?.virtualhost?.tls, http3: false };
  }
  for (const route of httpRoutes) {
    const parents = (route?.spec?.parentRefs || []).map((ref) => ref?.name).filter(Boolean);
    const tls = parents.some((name) => tlsGateways.has(name));
    const http3 = parents.some((name) => http3Gateways.has(name));
    for (const host of route?.spec?.hostnames || []) if (host) facts[host] = { route: 'HTTPRoute', tls, http3 };
  }
  return facts;
};

/**
 * @method curlStatusChainFactory
 * @description Extracts the response chain emitted by `curl -L -v -i -s`.
 * Verbose response lines are authoritative because `-i` can duplicate the same
 * headers on stdout. A write-out marker supplies the final code when curl did
 * not emit a verbose response (and `000` when no HTTP response was received).
 * CONNECT tunnel acknowledgements are transport setup, not host responses, and
 * are deliberately excluded from the displayed chain.
 * @param {string} [raw] - Combined curl stdout/stderr.
 * @returns {Array<string>} Ordered three-digit response codes.
 * @memberof ServerConfBuilder
 */
const curlStatusChainFactory = (raw = '') => {
  const text = `${raw || ''}`;
  const verbose = [...text.matchAll(/^< HTTP\/\S+\s+([0-9]{3})(?![^\n]*Connection established)/gim)].map(
    (match) => match[1],
  );
  const headers = [...text.matchAll(/^HTTP\/\S+\s+([0-9]{3})(?![^\n]*Connection established)/gim)].map(
    (match) => match[1],
  );
  const chain = verbose.length > 0 ? verbose : headers;
  const finalCode = /UNDERPOST_CURL_FINAL=([0-9]{3})/.exec(text)?.[1] || '';
  if (finalCode && finalCode !== '000' && chain[chain.length - 1] !== finalCode) chain.push(finalCode);
  if (chain.length === 0) chain.push(finalCode || '000');
  return chain;
};

/**
 * @method trafficTableRowsFactory
 * @description Resolves the live colour of each routable deployment, optionally
 * narrowed to a set of hosts.
 *
 * Cluster lookups are injected so the shaping stays a pure resolution over the
 * conf. An entry whose colour cannot be read is reported with an empty colour
 * rather than dropped — "no route published" is the answer, not an absence.
 * @param {Array<object>} [entries] - Entries from {@link ServerConfBuilder.deployTrafficEntriesFactory}.
 * @param {Array<string>} [hosts] - Hosts to report on; empty reports every host.
 * @param {Function} liveTrafficOf - `(entry) => 'blue' | 'green' | '' | null`.
 * @param {Function} servesTraffic - `(entry, colour) => boolean`.
 * @returns {Array<object>} Entries with `traffic` and `serving` resolved.
 * @memberof ServerConfBuilder
 */
const trafficTableRowsFactory = ({
  entries = [],
  hosts = [],
  liveTrafficOf = () => '',
  servesTraffic = () => false,
}) => {
  const wanted = new Set(hosts.filter(Boolean));
  return entries
    .filter((entry) => wanted.size === 0 || wanted.has(entry.host))
    .map((entry) => {
      const traffic = liveTrafficOf(entry) || '';
      return {
        ...entry,
        traffic,
        serving: isTrafficServingFactory({
          liveTraffic: traffic,
          hasReadyEndpoints: (colour) => servesTraffic(entry, colour),
        }),
      };
    });
};

/**
 * @method hostRenderInstancesFactory
 * @description The instances a shared host's routing must be rendered from.
 *
 * A host's Nginx server block and its HTTPRoute are single objects shared by
 * every variant on that host, so both are rewritten whole on every promote.
 * Rendering them from the declared set alone therefore deletes the routes of any
 * variant that is still deployed but no longer declared — silently taking down a
 * workload this deploy was never asked to touch.
 *
 * Each variant sub-path is its own deployment, so a variant loses its route only
 * once its workload is actually gone. `preserved` carries the descriptors last
 * published for this host; a declared entry always wins, since it is the current
 * truth for that id.
 * @param {Array<object>} [declared] - Instances the conf declares for this host.
 * @param {Array<object>} [preserved] - Instances last published for this host.
 * @param {Function} [isDeployed] - `(instance) => boolean`, true while its workload exists.
 * @returns {Array<object>} Declared entries, plus still-deployed preserved ones.
 * @memberof ServerConfBuilder
 */
const hostRenderInstancesFactory = ({ declared = [], preserved = [], isDeployed = () => false }) => {
  const declaredIds = new Set(declared.map((instance) => instance.id));
  return [
    ...declared,
    ...preserved.filter((instance) => instance?.id && !declaredIds.has(instance.id) && isDeployed(instance)),
  ];
};

/**
 * @method isTrafficServingFactory
 * @description Whether a routed colour is actually carrying traffic.
 *
 * The one gate every no-backend fallback checkpoint is conditional on, shared by
 * `run sync` and `run instance`. A colour is serving only when it is both routed
 * and still has a ready endpoint: a route can name a colour whose workload is
 * long gone, and taking a host offline to prove a fallback is only acceptable
 * when nothing is serving it.
 * @param {string} [liveTraffic] - Colour currently routed, or empty when none is.
 * @param {Function} hasReadyEndpoints - `(colour) => boolean`.
 * @returns {boolean} True when that colour is routed and reachable.
 * @memberof ServerConfBuilder
 */
const isTrafficServingFactory = ({ liveTraffic = '', hasReadyEndpoints = () => false }) =>
  !!liveTraffic && hasReadyEndpoints(liveTraffic);

/**
 * @method instanceTrafficPlanFactory
 * @description Resolves, for each instance, the colour routed now and the colour
 * to route next, and which instances are actually serving on the live one.
 *
 * `serving` is the precondition for the no-backend fallback checkpoint, which
 * routes the edge at a colour that has no Deployment yet. That is correct on a
 * first bring-up and an outage on a re-deploy, and a routed colour is only real
 * traffic when it still has a ready endpoint — a route alone can name a colour
 * whose workload is long gone.
 *
 * Cluster lookups are injected so this stays a pure resolution over the conf.
 * @param {Array<object>} [instances] - Expanded instance entries.
 * @param {string} [requestedTraffic] - Explicitly requested colour, if any.
 * @param {Function} liveTrafficOf - `(instance) => 'blue' | 'green' | '' | null`.
 * @param {Function} servesTraffic - `(instance, colour) => boolean`, true when that colour has a ready endpoint.
 * @returns {{liveTrafficById: Object<string,string>, targetTrafficById: Object<string,string>, serving: Array<object>}} The plan.
 * @memberof ServerConfBuilder
 */
const instanceTrafficPlanFactory = ({
  instances = [],
  requestedTraffic = '',
  liveTrafficOf = () => '',
  servesTraffic = () => false,
}) => {
  const liveTrafficById = {};
  const targetTrafficById = {};
  const serving = [];
  for (const instance of instances) {
    const liveTraffic = liveTrafficOf(instance) || '';
    liveTrafficById[instance.id] = liveTraffic;
    targetTrafficById[instance.id] = nextTrafficFactory(liveTraffic, requestedTraffic);
    if (isTrafficServingFactory({ liveTraffic, hasReadyEndpoints: (colour) => servesTraffic(instance, colour) }))
      serving.push(instance);
  }
  return { liveTrafficById, targetTrafficById, serving };
};

/**
 * @method instanceInterceptStatusesFactory
 * @description The statuses the gateway intercepts for one instance, and the
 * context directory each is answered from.
 *
 * Driven entirely by what the instance declares: every `customStatusPages` entry
 * answers its own status, and those declarations are also what upstream-failure
 * codes fall back to — a variant whose workload is gone should show the same page
 * as one that has no such route, because to the client they are the same thing.
 * @param {object} instance - Expanded instance entry.
 * @returns {Object<string,string>} Status code → context directory under the instance sub-path.
 * @memberof ServerConfBuilder
 */
const instanceInterceptStatusesFactory = (instance) => {
  const statuses = {};
  for (const page of instance?.customStatusPages || []) {
    if (!page?.status || !page?.hostPath) continue;
    const context = `status-pages/${page.status}`;
    statuses[page.status] = context;
    // Custom instances do not have the PWA's `maintenanceDefault` SSR view.
    // Their declared status document is therefore also the only useful answer
    // while the binary is absent or still becoming Ready. Nginx preserves the
    // original 502/503/504 code while substituting this document, so clients
    // can distinguish an unavailable runtime from the instance's own 404.
    for (const failureStatus of [502, 503, 504]) if (!statuses[failureStatus]) statuses[failureStatus] = context;
  }
  return statuses;
};

/**
 * @method instanceProxyRoutesFactory
 * @description Renders the Contour HTTPProxy route block for every instance
 * sharing a host.
 * @param {string} deployId - Parent deployment identifier.
 * @param {Array<object>} instances - Expanded instance entries bound to one host.
 * @param {string} env - `development` | `production`.
 * @param {Object<string,string>} trafficById - Instance id → traffic colour.
 * @returns {string} Concatenated route YAML.
 * @memberof ServerConfBuilder
 */
const instanceProxyRoutesFactory = ({ deployId, instances, env, trafficById }) =>
  sortInstancesByPath(instances)
    .map((instance) =>
      Underpost.deploy.deploymentYamlServiceFactory({
        path: instance.path,
        port: instancePortFactory({ instance, env }),
        serviceId: Underpost.deploy.trafficServiceNameFactory({ deployId: `${deployId}-${instance.id}`, env }),
        pathRewritePolicy: instance.pathRewritePolicy,
      }),
    )
    .join('');

/**
 * @method instanceHttpRouteRulesFactory
 * @description Renders the Gateway API rules for every instance sharing a host:
 * the workload rule for each instance sub-path, plus the edge-served status page
 * rules declared by that instance's `customStatusPages`.
 *
 * Variant paths are preserved by default, so the selected runtime receives the
 * same URL that the client requested. An explicit generic `pathRewritePolicy`
 * is still passed through for unrelated workloads that define one directly.
 * @param {string} deployId - Parent deployment identifier.
 * @param {Array<object>} instances - Expanded instance entries bound to one host.
 * @param {string} env - `development` | `production`.
 * @param {Object<string,string>} trafficById - Instance id → traffic colour.
 * @param {object} [options] - Runner options (namespace, gateway/QUIC settings).
 * @param {Array<string>} [servedStatuses] - Statuses whose document reached the static tree; undefined means all declared.
 * @returns {string} Concatenated rule YAML.
 * @memberof ServerConfBuilder
 */
const instanceHttpRouteRulesFactory = ({ deployId, instances, env, trafficById, options, servedStatuses }) => {
  const { http3, altSvc } = Underpost.deploy.gatewayApiConfigFactory(options);
  const sorted = sortInstancesByPath(instances);
  // The `/` status fallback is only emitted when no workload claims the root
  // path: two rules matching `/` would leave gateway precedence ambiguous.
  const rootClaimed = sorted.some((instance) => (instance.path || '/') === '/');
  let rules = '';
  for (const [i, instance] of sorted.entries()) {
    rules += Underpost.deploy.statusPageRouteRulesFactory({
      deployId: instanceStatusPageDeployIdFactory(deployId, instance),
      host: instance.host,
      basePath: instance.path,
      statusPages: instance.customStatusPages,
      altSvc: http3 ? altSvc : undefined,
      catchAll: !rootClaimed && i === sorted.length - 1,
      servedStatuses,
    });
    // An instance that declares a status page is reached through the shared
    // gateway, which proxies to its workload and intercepts the errors. One that
    // declares none is routed straight there, so the extra hop only exists where
    // it buys something. Any explicit generic `pathRewritePolicy` moves to the
    // gateway with the workload route.
    const intercepted = Object.keys(instanceInterceptStatusesFactory(instance)).length > 0;
    rules += Underpost.deploy.httpRouteRuleFactory({
      path: instance.path,
      ...(intercepted
        ? { serviceId: UNDERPOST_GATEWAY.serviceName, port: UNDERPOST_GATEWAY.port }
        : {
            port: instancePortFactory({ instance, env }),
            serviceId: Underpost.deploy.trafficServiceNameFactory({ deployId: `${deployId}-${instance.id}`, env }),
            pathRewritePolicy: instance.pathRewritePolicy,
          }),
      altSvc: http3 ? altSvc : undefined,
    });
  }
  return rules;
};

/**
 * @method clusterTypeFactory
 * @description The cluster runtime a set of options selects, as the string every
 * command line and volume context spells it.
 * @param {object} [options] - Options carrying the cluster flags.
 * @param {string} [defaultType] - Type when no flag is set; `kind` everywhere except workflows that never provision one.
 * @returns {string} `k3s` | `kubeadm` | `kind`.
 * @memberof ServerConfBuilder
 */
const clusterTypeFactory = (options = {}, defaultType = 'kind') =>
  options.k3s ? 'k3s' : options.kubeadm ? 'kubeadm' : defaultType;

/**
 * A row returned by {@link UnderpostKubectl.get}. Column names come from
 * `kubectl get -o wide`; Services expose their port column as `PORT(S)`.
 *
 * @typedef {Object<string, string|undefined>} ExposeKubernetesResource
 * @property {string} NAME - Kubernetes resource name.
 */

/**
 * @method exposeTcpPortsFactory
 * @description Extracts TCP Service ports from a parsed
 * `kubectl get svc -o wide` row. NodePort suffixes are ignored, so
 * `8080:32080/TCP` resolves to Service port `8080`.
 * @param {ExposeKubernetesResource} resource - Parsed Kubernetes resource row.
 * @returns {number[]} Positive TCP Service ports in the order reported by kubectl.
 * @memberof ServerConfBuilder
 */
const exposeTcpPortsFactory = (resource) =>
  `${resource?.['PORT(S)'] || ''}`
    .split(',')
    .filter((port) => port.includes('/TCP'))
    .map((port) => parseInt(port.split(':')[0]))
    .filter((port) => Number.isInteger(port) && port > 0);

/**
 * @method exposePathPartsFactory
 * @description Parses a comma-separated expose runner path into safe literal
 * Kubernetes name fragments. These are literal fragments, not regular
 * expressions or shell input.
 * @param {string} [path=''] - Comma-separated Service or Pod name fragments.
 * @returns {string[]} Trimmed, non-empty literal resource-name fragments.
 * @throws {Error} When no fragment is supplied or a fragment contains
 * characters outside `[a-zA-Z0-9._-]`.
 * @memberof ServerConfBuilder
 */
const exposePathPartsFactory = (path = '') => {
  const parts = `${path}`
    .split(',')
    .map((part) => part.trim())
    .filter(Boolean);
  if (parts.length === 0) throw new Error('Expose requires a Service or Pod name in path');
  if (parts.some((part) => !/^[a-zA-Z0-9._-]+$/.test(part)))
    throw new Error(`Invalid Kubernetes resource name match: ${path}`);
  return parts;
};

/**
 * @method exposePartialMatchesFactory
 * @description Selects every resource whose `NAME` contains any requested
 * literal path fragment. Results follow path-fragment order, with an exact name
 * before partial names in each group, then lexical name order. The input array
 * is not mutated.
 * @param {ExposeKubernetesResource[]} resources - Parsed Kubernetes resource rows.
 * @param {string[]} pathParts - Literal name fragments from {@link exposePathPartsFactory}.
 * @returns {ExposeKubernetesResource[]} Matching resource rows in deterministic order.
 * @memberof ServerConfBuilder
 */
const exposePartialMatchesFactory = (resources, pathParts) =>
  resources
    .filter(({ NAME }) => pathParts.some((part) => `${NAME || ''}`.includes(part)))
    .sort((a, b) => {
      const pathIndexA = pathParts.findIndex((part) => `${a.NAME || ''}`.includes(part));
      const pathIndexB = pathParts.findIndex((part) => `${b.NAME || ''}`.includes(part));
      const exactA = a.NAME === pathParts[pathIndexA] ? 0 : 1;
      const exactB = b.NAME === pathParts[pathIndexB] ? 0 : 1;
      return pathIndexA - pathIndexB || exactA - exactB || `${a.NAME}`.localeCompare(`${b.NAME}`);
    });

/**
 * @method exposePortListFactory
 * @description Parses and validates a comma-separated CLI port list.
 * @param {string|number} [value=''] - Comma-separated port values.
 * @param {string} [optionName='ports'] - Option name used in validation errors.
 * @returns {number[]} Ordered TCP ports, preserving their CLI indices.
 * @throws {Error} When an item is empty, non-integer, or outside `1..65535`.
 * @memberof ServerConfBuilder
 */
const exposePortListFactory = (value = '', optionName = 'ports') => {
  if (value === '' || value === undefined || value === null) return [];
  const values = `${value}`.split(',').map((port) => port.trim());
  if (values.some((port) => port === '')) throw new Error(`Invalid ${optionName}: ${value}`);
  const ports = values.map(Number);
  if (ports.some((port) => !Number.isInteger(port) || port < 1 || port > 65535))
    throw new Error(`Invalid ${optionName}: ${value}`);
  return ports;
};

/**
 * A validated Kubernetes port-forward mapping.
 *
 * @typedef {Object} ExposePortMapping
 * @property {string} kindType - Kubernetes resource kind (`svc` or `pod`).
 * @property {string} name - Kubernetes resource name.
 * @property {number} localPort - Host-side listening port.
 * @property {number} remotePort - Service or container-side destination port.
 */

/**
 * @method exposePortPlanFactory
 * @description Builds a complete, collision-free port-forward plan. With more
 * than one matched resource, container and host port lists map by resource
 * index. With one resource, list items map pairwise to multiple ports.
 * @param {object} options - Port planning options.
 * @param {ExposeKubernetesResource[]} options.resources - Ordered matched resources.
 * @param {string} options.kindType - Kubernetes resource kind (`svc` or `pod`).
 * @param {number[]} [options.containerPorts=[]] - Explicit destination ports.
 * @param {number[]} [options.hostPorts=[]] - Explicit host listening ports.
 * @param {function(ExposeKubernetesResource): number[]} [options.portsOf=exposeTcpPortsFactory] - Declared-port resolver.
 * @returns {ExposePortMapping[]} Complete port-forward mappings.
 * @throws {Error} When list cardinality cannot map by resource/port index, no
 * destination port exists, an explicit host port repeats, or an automatic port
 * cannot fit inside `1..65535`.
 * @memberof ServerConfBuilder
 */
const exposePortPlanFactory = ({
  resources,
  kindType,
  containerPorts = [],
  hostPorts = [],
  portsOf = exposeTcpPortsFactory,
}) => {
  const resourceCount = resources.length;
  const multipleResources = resourceCount > 1;
  if (multipleResources && containerPorts.length > 0 && containerPorts.length !== resourceCount)
    throw new Error(`--expose-container-ports requires ${resourceCount} ports for ${resourceCount} resources`);
  if (multipleResources && hostPorts.length > 0 && hostPorts.length !== resourceCount)
    throw new Error(`--expose-host-ports requires ${resourceCount} ports for ${resourceCount} resources`);

  const portGroups = resources.map((resource, resourceIndex) => {
    const declaredPorts = [...new Set(portsOf(resource))];
    let remotePorts = [];
    if (containerPorts.length > 0) {
      remotePorts = multipleResources ? [containerPorts[resourceIndex]] : [...containerPorts];
    } else if (hostPorts.length > 0 && !multipleResources) {
      remotePorts = hostPorts.map((hp) => (declaredPorts.includes(hp) ? hp : null)).filter(Boolean);
      if (remotePorts.length !== hostPorts.length) {
        remotePorts = declaredPorts.slice(0, hostPorts.length);
      }
    } else {
      remotePorts = declaredPorts;
    }
    if (remotePorts.length === 0)
      throw new Error(`No declared TCP port for ${kindType}/${resource.NAME}; pass --expose-container-ports <ports>`);
    const localPorts = hostPorts.length ? (multipleResources ? [hostPorts[resourceIndex]] : [...hostPorts]) : [];
    if (localPorts.length > 0 && localPorts.length !== remotePorts.length)
      throw new Error(
        `Host/container port counts differ for ${kindType}/${resource.NAME}: ${localPorts.length}/${remotePorts.length}`,
      );
    return { resource, remotePorts, localPorts };
  });

  const plan = [];
  const usedLocalPorts = new Set();
  for (const { resource, remotePorts, localPorts } of portGroups)
    for (const [portIndex, remotePort] of remotePorts.entries()) {
      const explicitLocalPort = localPorts[portIndex];
      let localPort = explicitLocalPort || remotePort;
      if (explicitLocalPort && usedLocalPorts.has(localPort))
        throw new Error(`Duplicate --expose-host-ports value: ${localPort}`);
      while (!explicitLocalPort && usedLocalPorts.has(localPort)) localPort++;
      if (localPort > 65535) throw new Error(`No valid host port remains for ${kindType}/${resource.NAME}`);
      usedLocalPorts.add(localPort);
      plan.push({ kindType, name: resource.NAME, localPort, remotePort });
    }
  return plan;
};
/**
 * @method gatewayApiEnabledFactory
 * @description Whether a workflow routes through the Gateway API stack.
 *
 * On unless explicitly disabled, in every runner: the Gateway API with QUIC/HTTP3
 * is the platform's routing stack, and the Contour HTTPProxy set is the fallback
 * a caller opts into with `--disable-gateway-api`. `--gateway-api` stays
 * meaningful as an explicit request, so a caller that passes it is never
 * second-guessed. Reading it from one place is what keeps a runner from
 * defaulting to a different stack than the one that deployed the routes.
 * @param {object} [options] - Runner/deploy options.
 * @returns {boolean} True when the Gateway API stack is in effect.
 * @memberof ServerConfBuilder
 */
const gatewayApiEnabledFactory = (options = {}) => options.gatewayApi === true || options.disableGatewayApi !== true;

/**
 * @method clusterContextFactory
 * @description The inverse of {@link ServerConfBuilder.clusterTypeFactory}: a
 * cluster type as the option flags a runner reads.
 *
 * A workflow resolves its cluster type once and passes it to spawned commands as
 * `--${clusterType}`. A runner invoked in-process gets no such string — it sees
 * the raw options, where every consumer independently defaults to kind: the
 * image pull (`docker exec kind-worker`), the node resolution behind hostPath
 * `nodeAffinity`, and the volume cluster context. This carries the choice across
 * that boundary.
 * @param {string} clusterType - `kubeadm` | `k3s` | `kind`.
 * @returns {{kind: boolean, kubeadm: boolean, k3s: boolean}} Mutually exclusive context flags.
 * @memberof ServerConfBuilder
 */
const clusterContextFactory = (clusterType) => ({
  kind: clusterType === 'kind',
  kubeadm: clusterType === 'kubeadm',
  k3s: clusterType === 'k3s',
});

/**
 * @method waitForPort
 * @description Polls a TCP port until it reaches the wanted state.
 *
 * Single source of truth for every "is it listening yet" wait: a port-forward
 * coming up locally, a freshly provisioned node's sshd, and the closed edge that
 * proves a reboot actually started. The probe is a native connect rather than a
 * shelled-out one, so it needs neither a shell nor `timeout` on the host and
 * reports the same result on every platform.
 * @param {number} port - Port to probe.
 * @param {string} [host] - Host to probe.
 * @param {boolean} [open] - Wanted state: true waits for the port to accept, false waits for it to refuse.
 * @param {number} [timeoutMs] - Maximum wait window.
 * @param {number} [intervalMs] - Delay between attempts.
 * @param {number} [connectTimeoutMs] - Per-attempt connect timeout.
 * @returns {Promise<boolean>} True once the wanted state is observed, false on timeout.
 * @memberof ServerConfBuilder
 */
const waitForPort = async ({
  port,
  host = '127.0.0.1',
  open = true,
  timeoutMs = 60 * 1000,
  intervalMs = 2000,
  connectTimeoutMs = 5000,
}) => {
  const probe = () =>
    new Promise((resolve) => {
      const socket = new net.Socket();
      const done = (reachable) => {
        socket.destroy();
        resolve(reachable);
      };
      socket.setTimeout(connectTimeoutMs);
      socket.once('connect', () => done(true));
      socket.once('timeout', () => done(false));
      socket.once('error', () => done(false));
      socket.connect(port, host);
    });
  const deadline = Date.now() + timeoutMs;
  while (Date.now() < deadline) {
    if ((await probe()) === open) return true;
    await timer(intervalMs);
  }
  logger.warn(`Port ${host}:${port} was not ${open ? 'reachable' : 'closed'} within timeout`, { timeoutMs });
  return false;
};

/**
 * Creates and writes the /etc/hosts file for a deployment.
 * @method etcHostFactory
 * @param {Array<string>} hosts - List of hosts to be added to the hosts file.
 * @param {object} options - Options for the hosts file creation.
 * @param {boolean} options.append - Whether to append to the existing hosts file.
 * @param {string} [options.blockId] - Replace an idempotent owned block while preserving unrelated entries.
 * @param {string} [options.path=/etc/hosts] - Hosts file path; injectable for tests.
 * @returns {{renderHosts: string, changed: boolean}} Rendered content and whether the file changed.
 * @memberof ServerConfBuilder
 */
const etcHostFactory = (hosts = [], options = { append: false }) => {
  hosts = hosts.map((host) => {
    try {
      if (!host.startsWith('http')) host = `http://${host}`;
      const hostname = new URL(host).hostname;
      logger.info('Hostname extract valid', { host, hostname });
      return hostname;
    } catch (e) {
      logger.warn('No hostname extract valid', host);
      return host;
    }
  });
  const renderHosts = `127.0.0.1         ${hosts.join(
    ' ',
  )} localhost localhost.localdomain localhost4 localhost4.localdomain4
::1         localhost localhost.localdomain localhost6 localhost6.localdomain6`;

  const hostsPath = options?.path || '/etc/hosts';
  if (options?.blockId) {
    if (!/^[A-Za-z0-9._-]+$/.test(options.blockId)) throw new Error(`Invalid /etc/hosts block id: ${options.blockId}`);
    const beginMarker = `# underpost hosts ${options.blockId}:begin`;
    const endMarker = `# underpost hosts ${options.blockId}:end`;
    const existing = fs.existsSync(hostsPath) ? fs.readFileSync(hostsPath, 'utf8') : '';
    const begin = existing.indexOf(beginMarker);
    const end = begin === -1 ? -1 : existing.indexOf(endMarker, begin);
    let outsideBlock = existing;
    if (begin !== -1)
      outsideBlock = `${existing.slice(0, begin)}${end === -1 ? '' : existing.slice(end + endMarker.length)}`;
    outsideBlock = outsideBlock.trimEnd();
    const updated = `${outsideBlock}${outsideBlock ? '\n' : ''}${beginMarker}\n${renderHosts}\n${endMarker}\n`;
    const changed = updated !== existing;
    if (changed) fs.writeFileSync(hostsPath, updated, 'utf8');
    return { renderHosts, changed };
  }

  if (options && options.append && fs.existsSync(hostsPath)) {
    fs.writeFileSync(
      hostsPath,
      fs.readFileSync(hostsPath, 'utf8') +
        `
${renderHosts}`,
      'utf8',
    );
  } else fs.writeFileSync(hostsPath, renderHosts, 'utf8');
  return { renderHosts, changed: true };
};

/**
 * Resolves the concrete deploy ids a build or conf-sync run should iterate over.
 *
 * The meta deploy id `dd` fans out to the comma separated ids declared in
 * `engine-private/deploy/dd.router`; when that file is absent (e.g. the private
 * repository is not checked out) it falls back to {@link ServerConfBuilder.DEFAULT_DEPLOY_ID}.
 * Any other value is parsed as a comma separated list.
 * Entries are trimmed and empties dropped.
 *
 * @method resolveDeployList
 * @param {string} deployId - A deploy id, a comma separated list, or the `dd` meta id.
 * @returns {string[]} Ordered list of concrete deploy ids.
 * @memberof ServerConfBuilder
 */
const resolveDeployList = (deployId) =>
  (deployId === 'dd'
    ? fs.existsSync('./engine-private/deploy/dd.router')
      ? fs.readFileSync('./engine-private/deploy/dd.router', 'utf8')
      : DEFAULT_DEPLOY_ID
    : deployId
  )
    .split(',')
    .map((id) => id.trim())
    .filter(Boolean);

/**
 * Syncs a single deploy id's private configuration into its dedicated
 * `engine-<suffix>-private` repository and pushes the result.
 *
 * Idempotent and safe to rerun: the private repo is cloned when missing or reset to a clean
 * checkout when present, then the deploy id's `conf` folder, matching `replica` and
 * `itc-scripts` entries, and any caller-supplied `extraPaths` payloads are mirrored. The
 * commit/push step is a no-op when nothing changed (`silentOnError`).
 *
 * @method syncPrivateConf
 * @param {string} deployId - A concrete deploy id (e.g. `dd-cyberia`), not the `dd` meta id.
 * @param {string[]} [extraPaths=[]] - Extra `./engine-private` payload paths to mirror (from the
 *   deploy's product catalog), kept out of this module so it stays product-agnostic.
 * @returns {void}
 * @memberof ServerConfBuilder
 */
const syncPrivateConf = (deployId, extraPaths = []) => {
  const suffix = deployId.split('dd-')[1];
  const privateRepoName = `engine-${suffix}-private`;
  const privateGitUri = `${process.env.GITHUB_USERNAME}/${privateRepoName}`;
  const privateRepoPath = `../${privateRepoName}`;

  if (!fs.existsSync(privateRepoPath)) {
    shellExec(`cd .. && underpost clone ${privateGitUri}`, { silent: true });
  } else {
    shellExec(`git config --global --add safe.directory '${dir.resolve(privateRepoPath)}'`);
    shellExec(`cd ${privateRepoPath} && git checkout . && git clean -f -d && underpost pull . ${privateGitUri}`, {
      silent: true,
    });
  }

  const confDest = `${privateRepoPath}/conf/${deployId}`;
  fs.removeSync(confDest);
  fs.mkdirSync(confDest, { recursive: true });
  fs.copySync(`./engine-private/conf/${deployId}`, confDest);

  fs.removeSync(`${privateRepoPath}/replica`);
  for (const payloadDir of ['replica', 'itc-scripts']) {
    const srcDir = `./engine-private/${payloadDir}`;
    if (!fs.existsSync(srcDir)) continue;
    for (const entry of fs.readdirSync(srcDir))
      if (entry.match(deployId)) fs.copySync(`${srcDir}/${entry}`, `${privateRepoPath}/${payloadDir}/${entry}`);
  }

  for (const extraPath of extraPaths) fs.copySync(`./engine-private/${extraPath}`, `${privateRepoPath}/${extraPath}`);

  shellExec(
    `cd ${privateRepoPath}` +
      ` && git add .` +
      ` && underpost cmt . ci engine-core-conf 'Update ${deployId} conf'` +
      ` && underpost push . ${privateGitUri}`,
    { silent: true, silentOnError: true },
  );
};

/**
 * Moves a deploy's public template sources into the engine working tree ahead of
 * the build copy step. Idempotent and safe to rerun: each move is guarded by
 * `existsSync`, so already-moved or absent sources are skipped rather than throwing.
 * The `[src, dest]` pairs come from the deploy's product catalog (passed in), so
 * this module stays product-agnostic.
 *
 * @method syncDeployIdSources
 * @param {Array<[string, string]>} [sourceMoves=[]] - Public `[src, dest]` move pairs.
 * @returns {boolean} `true` when any sources were declared, else `false`.
 * @memberof ServerConfBuilder
 */
const syncDeployIdSources = (sourceMoves = []) => {
  if (!sourceMoves.length) return false;
  for (const dir of ['src/api', 'src/client/components', 'src/client/public', 'src/client/services'])
    fs.mkdirSync(dir, { recursive: true });
  for (const [src, dest] of sourceMoves) if (fs.existsSync(src)) fs.moveSync(src, dest, { overwrite: true });
  return true;
};

/**
 * Rebuilds the standalone `pwa-microservices-template` from scratch out of the current
 * engine source tree.
 *
 * Clones the template repo next to the engine when missing, otherwise resets it to a clean
 * pristine checkout, then syncs every engine-tracked file the template is allowed to carry
 * ({@link validateTemplatePath}), strips engine-only + product modules, restores the template's
 * own CI workflows + guest services, and rewrites `package.json` / `package-lock.json` / `README`
 * so the result is a standalone, installable project. Throws on failure; callers own exit codes.
 *
 * Product catalogs are read dynamically ({@link module:src/server/catalog} `loadProductCatalogs`),
 * so this stays decoupled from — and survives removal of — any product module.
 *
 * @method buildTemplate
 * @param {object} [options]
 * @param {string} [options.srcPath='./'] - Engine source root to sync from.
 * @param {string} [options.toPath='../pwa-microservices-template'] - Template output path.
 * @returns {Promise<void>}
 * @memberof ServerConfBuilder
 */
const buildTemplate = async ({ srcPath = './', toPath = '../pwa-microservices-template' } = {}) => {
  const walk = (await import('ignore-walk')).default;
  const { TEMPLATE_RESTORE_PATHS, TEMPLATE_KEYWORDS, TEMPLATE_DESCRIPTION } =
    await import('../projects/underpost/catalog-underpost.js');
  const { loadProductCatalogs } = await import('./catalog.js');
  const githubUsername = process.env.GITHUB_USERNAME;

  logger.info('Build template', { srcPath, toPath });

  const sourceFiles = (
    await new Promise((resolve) =>
      walk({ path: srcPath, ignoreFiles: [`.gitignore`], includeEmpty: false, follow: false }, (...args) =>
        resolve(args[1]),
      ),
    )
  ).filter((p) => !p.startsWith('.git'));

  fs.removeSync(`${githubUsername}/pwa-microservices-template`);
  shellExec(`cd .. && node engine/bin clone ${githubUsername}/pwa-microservices-template`);

  shellExec(`cd ${toPath} && git config core.filemode false`);

  for (const copyPath of sourceFiles) {
    if (copyPath === 'NaN') continue;
    const absolutePath = `${srcPath}/${copyPath}`;
    if (!validateTemplatePath(absolutePath)) continue;

    const folder = getDirname(`${toPath}/${copyPath}`);
    if (!fs.existsSync(folder)) fs.mkdirSync(folder, { recursive: true });

    logger.info('build', `${toPath}/${copyPath}`);
    fs.copyFileSync(absolutePath, `${toPath}/${copyPath}`);
  }

  fs.copySync(`./.vscode`, `${toPath}/.vscode`);
  fs.copySync(`./src/client/public/default`, `${toPath}/src/client/public/default`);

  // Preserve the template's own README + package.json identity before merging engine metadata.
  for (const checkoutPath of ['README.md', 'package.json']) shellExec(`cd ${toPath} && git checkout ${checkoutPath}`);

  // Strip each product catalog's `stripPaths` (aggregated dynamically) plus the engine-only
  // workflows, deploy manifests, and product catalog modules.
  const productStripPaths = (await loadProductCatalogs()).flatMap((c) => c.stripPaths);
  for (const deletePath of productStripPaths) {
    const target = `${toPath}/${deletePath}`;
    if (fs.existsSync(target)) fs.removeSync(target);
  }
  shellExec(`rm -rf ${toPath}/.github`);
  shellExec(`rm -rf ${toPath}/manifests/deployment/dd-*`);
  shellExec(`rm -rf ${toPath}/deploy`);

  fs.mkdirSync(`${toPath}/.github/workflows`, { recursive: true });
  for (const restorePath of TEMPLATE_RESTORE_PATHS) {
    const dest = `${toPath}/${restorePath}`;
    if (fs.statSync(restorePath).isDirectory()) fs.copySync(restorePath, dest, { overwrite: true });
    else fs.copyFileSync(restorePath, dest);
  }

  // ── package.json: take engine deps/scripts/version, keep template identity. ──
  const originPackageJson = JSON.parse(fs.readFileSync('./package.json', 'utf8'));
  const templatePackageJson = JSON.parse(fs.readFileSync(`${toPath}/package.json`, 'utf8'));
  const templateName = templatePackageJson.name;

  templatePackageJson.dependencies = originPackageJson.dependencies;
  templatePackageJson.devDependencies = originPackageJson.devDependencies;
  templatePackageJson.version = originPackageJson.version;
  templatePackageJson.scripts = originPackageJson.scripts;
  templatePackageJson.overrides = originPackageJson.overrides;
  templatePackageJson.name = templateName;
  templatePackageJson.description = TEMPLATE_DESCRIPTION;
  templatePackageJson.keywords = TEMPLATE_KEYWORDS;
  delete templatePackageJson.scripts['build:template'];
  fs.writeFileSync(`${toPath}/package.json`, JSON.stringify(templatePackageJson, null, 4), 'utf8');

  // ── package-lock.json: mirror engine packages, keep template name/version on the root entry. ──
  const originPackageLockJson = JSON.parse(fs.readFileSync('./package-lock.json', 'utf8'));
  const templatePackageLockJson = JSON.parse(fs.readFileSync(`${toPath}/package-lock.json`, 'utf8'));
  const originBasePackageLock = newInstance(templatePackageLockJson.packages['']);
  templatePackageLockJson.name = templateName;
  templatePackageLockJson.version = originPackageLockJson.version;
  templatePackageLockJson.packages = originPackageLockJson.packages;
  templatePackageLockJson.packages[''].name = templateName;
  templatePackageLockJson.packages[''].version = originPackageLockJson.version;
  templatePackageLockJson.packages[''].hasInstallScript = originBasePackageLock.hasInstallScript;
  templatePackageLockJson.packages[''].license = originBasePackageLock.license;
  fs.writeFileSync(`${toPath}/package-lock.json`, JSON.stringify(templatePackageLockJson, null, 4), 'utf8');

  fs.writeFileSync(
    `${toPath}/README.md`,
    fs
      .readFileSync('./README.md', 'utf8')
      .replace('<!-- template-title -->', '#### Base template for pwa/api-rest projects.'),
    'utf8',
  );
};

const updatePrivateTemplateRepo = async () => {
  const templatePath = '/home/dd/pwa-microservices-template';
  shellExec(`sudo rm -rf ${templatePath}
cd /home/dd/engine && npm run build:template
cd /home/dd
underpost clone --bare underpostnet/pwa-microservices-template-private
sudo rm -rf ${templatePath}/.git
mv ./pwa-microservices-template-private.git ${templatePath}/.git
cd ${templatePath}
npm install --omit=dev --ignore-scripts
git init
git config user.name 'underpostnet'
git config user.email 'development@underpost.net'
git add .`);
  const hasChanges = shellExec(`node bin cmt ${templatePath} --has-changes`, {
    stdout: true,
    silent: true,
    disableLog: true,
  }).trim();
  if (hasChanges === '1') {
    shellExec(
      `cd ${templatePath} && git commit -m 'Update template' && underpost push . underpostnet/pwa-microservices-template-private`,
    );
  }
};

/**
 * @method updatePrivateEngineTestRepo
 * @description Publishes a deploy id's freshly assembled template to its private
 * **test** source repo `engine-test-<idPart>` (separate from the production
 * `engine-<idPart>`). A pod started with `underpost start --build --private-test-repo`
 * clones this repo, so work-in-progress engine source can be tested end to end
 * without touching the production source. Mirrors {@link updatePrivateTemplateRepo}
 * but per-deploy-id and against the test repo.
 *
 * Assumes the deploy id template has already been assembled at the template path
 * (run `node bin/build <deployId>` first, or use `node bin/build <deployId> --update-private`).
 * @param {string} deployId - Concrete deploy id (e.g. `dd-core`).
 * @returns {Promise<void>}
 * @memberof ServerConfBuilder
 */
const updatePrivateEngineTestRepo = async (deployId) => {
  const username = process.env.GITHUB_USERNAME || 'underpostnet';
  const repoName = `engine-test-${deployId.split('-')[1]}`;
  const templatePath = '/home/dd/pwa-microservices-template';
  if (!fs.existsSync(templatePath))
    throw new Error(`updatePrivateEngineTestRepo: assemble the template first (node bin/build ${deployId})`);

  // Detach the assembled working tree from any engine-build git history.
  shellExec(`sudo rm -rf ${templatePath}/.git`);

  // Adopt the test repo's existing history when present (so the push is a delta);
  // otherwise publish a fresh history on first push.
  shellExec(`cd /home/dd && sudo rm -rf ./${repoName}.git && underpost clone --bare ${username}/${repoName}`, {
    silent: true,
    disableLog: true,
    silentOnError: true,
  });
  if (fs.existsSync(`/home/dd/${repoName}.git`)) shellExec(`mv /home/dd/${repoName}.git ${templatePath}/.git`);

  // `git init` converts the moved bare repo into a normal work-tree repo (bare
  // clones have no work tree, so `git add` would fail), and bootstraps a fresh
  // repo on first publish. Idempotent — mirrors updatePrivateTemplateRepo.
  shellExec(`cd ${templatePath}
git init
git config user.name '${username}'
git config user.email 'development@underpost.net'
git add .`);

  const hasChanges = shellExec(`node bin cmt ${templatePath} --has-changes`, {
    stdout: true,
    silent: true,
    disableLog: true,
  }).trim();
  if (hasChanges === '1')
    shellExec(`cd ${templatePath} && git commit -m 'Update ${repoName}' && underpost push . ${username}/${repoName}`);
  else logger.info('No changes to publish', { repoName });
};

/**
 * @function clusterInstancesFactory
 * @description Binds the instance ids requested by the `cluster` runner to the
 * deploys that actually declare them.
 *
 * An instance belongs to a deploy through that deploy's own
 * `conf.instances.json` — nothing else relates the two — so the same id under a
 * different deploy is a different workload, and an id no deploy declares is a
 * typo rather than a silent no-op. A deploy without the file simply has no
 * instances.
 *
 * Ids are returned as requested, not expanded: `run instance` owns variant
 * expansion, so a template id (`mmo-server`) is handed over whole and deploys
 * its whole family. Hosts *are* expanded, because they are needed before any
 * instance runs — `/etc/hosts` is written in one pass for every host the
 * gateway will serve.
 * @param {Array<string>} deployList - Deploy ids being brought up.
 * @param {string} [instanceList] - `+`-separated instance/template ids.
 * @returns {{ byDeployId: Object<string,{ids: Array<string>, hosts: Array<string>}>, unmatched: Array<string> }}
 *   Per-deploy selection, and the requested ids no deploy declares.
 */
const clusterInstancesFactory = (deployList = [], instanceList = '') => {
  const requested = `${instanceList || ''}`.split('+').filter((id) => id.trim());
  const byDeployId = Object.fromEntries(
    deployList.map((deployId) => {
      const confPath = `./engine-private/conf/${deployId}/conf.instances.json`;
      if (requested.length === 0 || !fs.existsSync(confPath)) return [deployId, { ids: [], hosts: [] }];
      const confInstances = loadConfInstances(deployId);
      const matched = requested
        .map((id) => ({ id, instances: selectConfInstances(confInstances, id) }))
        .filter((entry) => entry.instances.length > 0);
      return [
        deployId,
        {
          ids: matched.map((entry) => entry.id),
          hosts: [...new Set(matched.flatMap((entry) => entry.instances.map((instance) => instance.host)))],
        },
      ];
    }),
  );
  return {
    byDeployId,
    unmatched: requested.filter((id) => !deployList.some((deployId) => byDeployId[deployId].ids.includes(id))),
  };
};

export {
  Config,
  loadConf,
  loadConfInstances,
  normalizeInstanceTopology,
  dispatchBuildInstanceEnv,
  loadProjectInstanceEnvBuilder,
  loadInstanceTopology,
  readConfInstances,
  selectConfInstances,
  loadReplicas,
  cloneConf,
  getCapVariableName,
  addClientConf,
  buildClientSrc,
  buildApiSrc,
  addApiConf,
  addWsConf,
  buildWsSrc,
  cloneSrcComponents,
  buildProxyRouter,
  getDataDeploy,
  validateTemplatePath,
  buildReplicaId,
  mergeFile,
  getPathsSSR,
  buildKindPorts,
  buildPortProxyRouter,
  splitFileFactory,
  generateSecurePassword,
  resolveReplicaCount,
  pathPortAssignmentFactory,
  deployRangePortFactory,
  awaitDeployMonitor,
  buildCliDoc,
  getInstanceContext,
  buildApiConf,
  buildClientStaticConf,
  isDeployRunnerContext,
  isDevProxyContext,
  devProxyHostFactory,
  isTlsDevProxy,
  getTlsHosts,
  resolveHostKeyContext,
  resolveConfSecrets,
  loadConfServerJson,
  getConfFolder,
  getConfFilePath,
  readConfJson,
  DEFAULT_DEPLOY_ID,
  clusterContextFactory,
  clusterTypeFactory,
  exposeTcpPortsFactory,
  exposePathPartsFactory,
  exposePartialMatchesFactory,
  exposePortListFactory,
  exposePortPlanFactory,
  deployHostsFactory,
  clusterInstancesFactory,
  etcHostFactory,
  gatewayApiEnabledFactory,
  instanceHttpRouteRulesFactory,
  instanceInterceptStatusesFactory,
  instancePortFactory,
  instanceProjectPathFactory,
  instanceProxyRoutesFactory,
  instanceStatusPageDeployIdFactory,
  instanceStatusPageEntriesFactory,
  deployTrafficEntriesFactory,
  trafficProbePathsFactory,
  hostIngressFactsFactory,
  curlStatusChainFactory,
  hostRenderInstancesFactory,
  instanceTrafficPlanFactory,
  isTrafficServingFactory,
  nextTrafficFactory,
  schedulableNodeFactory,
  stopPlanFactory,
  trafficFromRoutingInfoFactory,
  trafficTableRowsFactory,
  resolveEnvScoped,
  sortInstancesByPath,
  waitForPort,
  resolveDeployList,
  syncPrivateConf,
  syncDeployIdSources,
  buildTemplate,
  updatePrivateTemplateRepo,
  updatePrivateEngineTestRepo,
};