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

1,972 lines 87.6 kB
/**
 * Repository module for managing Git operations and configurations.
 * @module src/cli/repository.js
 * @namespace UnderpostRepository
 */

import dotenv from 'dotenv';
import { commitData } from '../client/components/core/CommonJs.js';
import { pbcopy, shellCd, shellExec } from '../server/process.js';
import { actionInitLog, loggerFactory } from '../server/logger.js';
import path from 'path';
import fs from 'fs-extra';
import {
  Config,
  loadConf,
  readConfJson,
  getConfFilePath,
  loadReplicas,
  loadConfServerJson,
  getDataDeploy,
  buildReplicaId,
  readConfInstances,
} from '../server/conf.js';
import { getNpmRootPath, writeEnv } from '../server/environment.js';
import { buildClient, unzipClientBuild, mergeClientBuildZip } from '../client-builder/client-build.js';
import { DefaultConf } from '../../conf.js';
import Underpost from '../index.js';

const logger = loggerFactory(import.meta);

const diffCmd = `--no-pager show -U0 -w --word-diff=color --word-diff-regex='[^[:space:]]' --color=always`;

/**
 * @class UnderpostRepository
 * @description Manages Git operations and configurations.
 * This class provides a set of static methods to automate various
 * Git operations, including cloning, pulling, and committing changes.
 * @memberof UnderpostRepository
 */
class UnderpostRepository {
  static API = {
    /**
     * Clones a Git repository from GitHub.
     * @param {string} [gitUri=`${process.env.GITHUB_USERNAME}/pwa-microservices-template`] - The URI of the GitHub repository (e.g., "username/repository").
     * @param {object} [options={ bare: false, G8: false }] - Cloning options.
     * @param {boolean} [options.bare=false] - If true, performs a bare clone.
     * @param {boolean} [options.g8=false] - If true, uses the .g8 extension.
     * @memberof UnderpostRepository
     */
    clone(gitUri = `${process.env.GITHUB_USERNAME}/pwa-microservices-template`, options = { bare: false, g8: false }) {
      const gExtension = options.g8 === true ? '.g8' : '.git';
      const repoName = gitUri.split('/').pop();
      if (fs.existsSync(`./${repoName}`)) fs.removeSync(`./${repoName}`);
      shellExec(
        `git clone ${options?.bare === true ? ` --bare ` : ''}https://${
          process.env.GITHUB_TOKEN ? `${process.env.GITHUB_TOKEN}@` : ''
        }github.com/${gitUri}${gExtension}`,
        {
          disableLog: true,
        },
      );
    },
    /**
     * Pulls updates from a GitHub repository.
     * @param {string} [repoPath='./'] - The local path to the repository.
     * @param {string} [gitUri=`${process.env.GITHUB_USERNAME}/pwa-microservices-template`] - The URI of the GitHub repository.
     * @param {object} [options={ g8: false }] - Pulling options.
     * @param {boolean} [options.g8=false] - If true, uses the .g8 extension.
     * @memberof UnderpostRepository
     */
    pull(
      repoPath = './',
      gitUri = `${process.env.GITHUB_USERNAME}/pwa-microservices-template`,
      options = { g8: false },
    ) {
      const gExtension = options.g8 === true ? '.g8' : '.git';
      shellExec(
        `cd ${repoPath} && git pull https://${
          process.env.GITHUB_TOKEN ? `${process.env.GITHUB_TOKEN}@` : ''
        }github.com/${gitUri}${gExtension}`,
        {
          disableLog: true,
        },
      );
    },
    /**
     * Creates a Git commit with a conventional commit message.
     * @param {string} [repoPath='./'] - The local path to the repository.
     * @param {string} [commitType='feat'] - The type of commit (e.g., 'feat', 'fix', 'docs', 'reset').
     * @param {string} [subModule=''] - The submodule or scope of the commit.
     * @param {string} [message=''] - The commit message.
     * @param {object} [options={ copy: false, info: false, empty: false }] - Commit options.
     * @param {boolean} [options.copy=false] - If true, copies the commit message to the clipboard.
     * @param {boolean} [options.info=false] - If true, displays information about commit types.
     * @param {boolean} [options.empty=false] - If true, allows an empty commit.
     * @param {boolean} [options.diff=false] - If true, shows the diff of the last commit.
     * @param {boolean} [options.edit=false] - If true, amends the last commit without changing the message.
     * @param {boolean} [options.cached=false] - If true, commits only staged changes.
     * @param {number} [options.log=0] - If greater than 0, shows the last N commits with diffs.
     * @param {boolean} [options.lastMsg=0] - If greater than 0, copies or show the last last single n commit message to clipboard.
     * @param {boolean} [options.unpush=false] - If true with --log, automatically detects unpushed commits ahead of remote and uses that count.
     * @param {string} [options.deployId=''] - An optional deploy ID to include in the commit message.
     * @param {string} [options.hashes=''] - If provided with diff option, shows the diff between two hashes.
     * @param {string} [options.extension=''] - If provided with diff option, filters the diff by this file extension.
     * @param {boolean|string} [options.changelog=undefined] - If true, prints the changelog since the last CI integration commit (starting with 'ci(package-pwa-microservices-'). If a number string, prints the changelog of the last N commits split by version sections. Only considers commits starting with '[<tag>]'.
     * @param {boolean} [options.changelogBuild=false] - If true, scrapes all git history and builds a full CHANGELOG.md. Commits containing 'New release v:' are used as version section titles. Only commits starting with '[<tag>]' are included as entries.
     * @param {string} [options.changelogMinVersion=''] - If set, overrides the default minimum version limit (2.85.0) for --changelog-build.
     * @param {boolean} [options.changelogNoHash=false] - If true, omits commit hashes from the changelog entries.
     * @param {boolean} [options.remoteUrl=false] - If true, prints the current git remote URL (origin) in plain text and returns.
     * @param {string} [options.switchRepo=''] - If set, switches the remote `origin` to this URL and force-pulls the target branch, overwriting the current working tree.
     * @param {string} [options.targetBranch=''] - Target branch for `switchRepo` (defaults to the remote's default branch).
     * @memberof UnderpostRepository
     */
    commit(
      repoPath = undefined,
      commitType = 'feat',
      subModule = '',
      message = '',
      options = {
        copy: false,
        info: false,
        empty: false,
        diff: false,
        edit: false,
        cached: false,
        lastMsg: 0,
        log: 0,
        deployId: '',
        hashes: '',
        extension: '',
        changelog: undefined,
        changelogBuild: false,
        changelogMinVersion: '',
        changelogNoHash: false,
        unpush: false,
        b: false,
        p: undefined,
        bc: '',
        isRemoteRepo: '',
        hasChanges: false,
        remoteUrl: false,
        switchRepo: '',
        targetBranch: '',
      },
    ) {
      if (!repoPath) repoPath = '.';

      if (options.initRepo) {
        Underpost.repo.initLocalRepo({
          path: repoPath,
          origin: typeof options.initRepo === 'string' ? options.initRepo : undefined,
        });
        return;
      }

      if (options.hasChanges) {
        const status = shellExec(`cd ${repoPath} && git status --porcelain`, {
          stdout: true,
          silent: true,
          disableLog: true,
        }).trim();
        process.stdout.write(status ? '1' : '');
        return;
      }

      if (options.isRemoteRepo) {
        const accessible = Underpost.repo.isRemoteRepo(options.isRemoteRepo);
        console.log(accessible);
        return;
      }

      if (options.remoteUrl) {
        const url = Underpost.repo.getRemoteUrl({ path: repoPath });
        if (options.copy) pbcopy(url);
        else console.log(url);
        return;
      }

      if (options.switchRepo) {
        Underpost.repo.switchRemote({
          path: repoPath,
          url: options.switchRepo,
          branch: options.targetBranch,
        });
        return;
      }

      if (options.bc) {
        console.log(
          shellExec(`cd ${repoPath} && git for-each-ref --contains ${options.bc} --format='%(refname:short)'`, {
            stdout: true,
            silent: true,
            disableLog: true,
          }).trim(),
        );
        return;
      }

      if (options.p !== undefined) {
        const branch =
          options.p === true
            ? shellExec(`cd ${repoPath} && git branch --show-current`, {
                stdout: true,
                silent: true,
                disableLog: true,
              }).trim()
            : options.p;
        console.log(
          shellExec(`cd ${repoPath} && git --no-pager reflog show refs/heads/${branch}`, {
            stdout: true,
            silent: true,
            disableLog: true,
          }).trim(),
        );
        return;
      }

      if (options.b) {
        const currentBranch = shellExec(`cd ${repoPath} && git branch --show-current`, {
          stdout: true,
          silent: true,
          disableLog: true,
        }).trim();
        if (options.copy) pbcopy(currentBranch);
        else console.log(currentBranch);
        return;
      }

      if (options.changelog !== undefined || options.changelogBuild || options.changelogMsg !== undefined) {
        const releaseMatch = 'New release v:';
        // Helper: parse [<tag>] commits into grouped sections
        const buildSectionChangelog = (commits) => {
          const groups = {};
          const tagOrder = [];
          for (const commit of commits) {
            if (!commit.message.startsWith('[')) continue;
            const match = commit.message.match(/^\[([^\]]+)\]\s*(.*)/);
            if (match) {
              const tag = match[1].trim();
              const context = match[2].trim().replaceAll('"', '');
              if (!groups[tag]) {
                groups[tag] = [];
                tagOrder.push(tag);
              }
              groups[tag].push({ ...commit, context });
            }
          }
          let out = '';
          for (const tag of tagOrder) {
            out += `### ${tag}\n\n`;
            for (const entry of groups[tag]) {
              out += `- ${entry.context}${options.changelogNoHash ? '' : ` (${commitUrl(entry.hash, entry.fullHash)})`}\n`;
            }
            out += '\n';
          }
          return out;
        };

        // Helper: fetch git log as structured array
        const fetchHistory = (limit) => {
          const limitArg = limit ? ` -n ${limit}` : '';
          const rawLog = shellExec(`git log --pretty=format:"%h||%H||%s||%ci"${limitArg}`, {
            stdout: true,
            silent: true,
            disableLog: true,
            silentOnError: true,
          }).toString();
          return rawLog
            .split('\n')
            .map((line) => {
              const parts = line.split('||');
              return {
                hash: (parts[0] || '').trim(),
                fullHash: (parts[1] || '').trim(),
                message: parts[2] || '',
                date: parts[3] || '',
              };
            })
            .filter((c) => c.hash);
        };

        const githubUser = process.env.GITHUB_USERNAME || 'underpostnet';
        const commitUrl = (shortHash, fullHash) =>
          `[${shortHash}](https://github.com/${githubUser}/engine/commit/${fullHash})`;

        // Helper: extract version from commit message containing 'New release v:'
        const extractVersion = (message) => {
          const idx = message.indexOf(releaseMatch);
          if (idx === -1) return null;
          return message.substring(idx + releaseMatch.length).trim();
        };

        // Helper: split commits array into version sections by 'New release v:' boundary
        const buildVersionSections = (commits) => {
          const sections = [];
          let currentSection = { title: null, date: new Date().toISOString().split('T')[0], commits: [] };

          for (const commit of commits) {
            const version = extractVersion(commit.message);
            if (version) {
              // Push accumulated commits as a section
              sections.push(currentSection);
              // Start new version section; commits below this one belong to it
              const commitDate = commit.date ? commit.date.split(' ')[0] : '';
              currentSection = { title: `${releaseMatch}${version}`, date: commitDate, hash: commit.hash, commits: [] };
            } else {
              currentSection.commits.push(commit);
            }
          }
          // Push the last (oldest) section
          if (currentSection.commits.length > 0) sections.push(currentSection);
          return sections;
        };

        // Helper: render sections array into changelog markdown string
        const renderSections = (sections) => {
          let changelog = '';
          for (const section of sections) {
            const sectionBody = buildSectionChangelog(section.commits);
            if (!sectionBody) continue;
            if (section.title) {
              changelog += `## ${section.title}${options.changelogNoHash ? '' : ` (${section.date})`}\n\n`;
            } else {
              changelog += `## ${section.date}\n\n`;
            }
            changelog += sectionBody;
          }
          return changelog;
        };

        const changelogMinVersion = options.changelogMinVersion || '2.97.1';

        if (options.changelogBuild) {
          // --changelog-build: scrape ALL history, split by 'New release v:' commits as version sections
          const allCommits = fetchHistory();
          const sections = buildVersionSections(allCommits);

          // Filter sections: stop at changelogMinVersion boundary
          const limitedSections = [];
          for (const section of sections) {
            limitedSections.push(section);
            if (section.title) {
              const versionStr = section.title.replace(releaseMatch, '').trim();
              if (versionStr === changelogMinVersion) break;
            }
          }

          let changelog = renderSections(limitedSections);

          if (!changelog) {
            changelog = `No changelog entries found.\n`;
          }

          const changelogPath = `${repoPath === '.' ? '.' : repoPath}/CHANGELOG.md`;
          fs.writeFileSync(changelogPath, `# Changelog\n\n${changelog}`);
          logger.info('CHANGELOG.md built at', changelogPath);
        } else {
          // --changelog / --changelog-msg: message from the last N commits, where N is --from-n-commit
          // (default 1, last commit only). No auto-detection.
          const n = parseInt(options.fromNCommit) > 0 ? parseInt(options.fromNCommit) : 1;
          const sections = buildVersionSections(fetchHistory(n));
          const changelog = renderSections(sections);
          if (options.changelogMsg !== undefined) {
            // Sanitized, commit-ready message; empty string when there are no tagged entries so
            // callers fall back to their own generic default instead of a placeholder.
            console.log(Underpost.repo.sanitizeChangelogMessage(changelog));
          } else {
            console.log(changelog || `No changelog entries found.\n`);
          }
        }

        return;
      }
      if (options.diff && options.hashes) {
        const hashes = options.hashes.split(',');
        const cmd = `git --no-pager diff ${hashes[0]} ${hashes[1] ? hashes[1] : 'HEAD'}${options.extension ? ` -- '*.${options.extension}'` : ''}`;
        if (options.copy) {
          pbcopy(cmd);
        } else console.log(cmd);
        return;
      }
      if (options.lastMsg) {
        if (options.copy) {
          pbcopy(Underpost.repo.getLastCommitMsg(options.lastMsg - 1));
        } else console.log(Underpost.repo.getLastCommitMsg(options.lastMsg - 1));
        return;
      }
      if (options.diff) {
        const _diffCmd = `git ${diffCmd.replace('show', `diff${options.cached ? ` --cached` : ''}`)}`;
        if (options.copy) pbcopy(_diffCmd);
        else console.log('Diff command:', _diffCmd);
        return;
      }
      if (options.log || options.unpush) {
        if (options.unpush) {
          const { count, hasUnpushed } = Underpost.repo.getUnpushedCount(repoPath);
          if (!hasUnpushed) {
            logger.warn('No unpushed commits found');
            return;
          }
          options.log = count;
        }
        const history = Underpost.repo.getHistory(options.log, repoPath);
        const chainCmd = history
          .reverse()
          .map((commitData, i) => `${i === 0 ? '' : ' && '}git -C ${repoPath} ${diffCmd} ${commitData.hash}`)
          .join('');
        if (history[0]) {
          let index = history.length;
          for (const commit of history) {
            console.log(
              shellExec(`cd ${repoPath} && git show -s --format=%ci ${commit.hash}`, {
                stdout: true,
                silent: true,
                disableLog: true,
              }).trim().green,
            );
            console.log(`${index}`.magenta, commit.hash.yellow, commit.message);
            index--;
            console.log(
              shellExec(`cd ${repoPath} && git show --name-status --pretty="" ${commit.hash}`, {
                stdout: true,
                silent: true,
                disableLog: true,
              }).trim().red,
            );
          }
          if (options.copy) pbcopy(chainCmd);
          else console.log('Show all:', chainCmd);
        } else logger.warn('No commits found');
        return;
      }
      if (commitType === 'reset') {
        if (options.copy) pbcopy(Underpost.repo.getLastCommitMsg());
        shellExec(`cd ${repoPath} && git reset --soft HEAD~${isNaN(parseInt(subModule)) ? 1 : parseInt(subModule)}`);
        return;
      }
      if (options.info) return logger.info('', commitData);
      const _message = `${commitType}${subModule ? `(${subModule})` : ''}: ${
        commitData[commitType].emoji
      } ${message ? message : commitData[commitType].description}`;
      if (options.copy) return pbcopy(_message);
      shellExec(
        `cd ${repoPath} && git commit ${options?.empty ? `--allow-empty ` : ''}${options.edit ? `--amend  --no-edit ` : `-m "${_message}"`}`,
      );
    },

    /**
     * Retrieves the message of the last Git commit.
     * @param {number} [skip=0] - Number of commits to skip from HEAD (0 = most recent).
     * @returns {string} The last commit message.
     * @memberof UnderpostRepository
     */
    getLastCommitMsg(skip = 0) {
      return shellExec(`git --no-pager log -1 --skip=${skip} --pretty=%B`, {
        stdout: true,
        silent: true,
        disableLog: true,
      });
    },

    /**
     * Pushes commits to a remote GitHub repository.
     * @param {string} [repoPath='./'] - The local path to the repository.
     * @param {string} [gitUri=`${process.env.GITHUB_USERNAME}/pwa-microservices-template`] - The URI of the GitHub repository.
     * @param {object} [options={ f: false, g8: false }] - Push options.
     * @param {boolean} [options.f=false] - If true, forces the push.
     * @param {boolean} [options.g8=false] - If true, uses the .g8 extension.
     * @memberof UnderpostRepository
     */
    push(
      repoPath = './',
      gitUri = `${process.env.GITHUB_USERNAME}/pwa-microservices-template`,
      options = { f: false, g8: false },
    ) {
      const gExtension = options.g8 === true ? '.g8' : '.git';
      shellExec(
        `cd ${repoPath} && git push https://${process.env.GITHUB_TOKEN}@github.com/${gitUri}${gExtension}${
          options?.f === true ? ' --force' : ''
        }`,
        {
          disableLog: true,
        },
      );
      logger.info(
        'commit url',
        `http://github.com/${gitUri}${gExtension === '.g8' ? '.g8' : ''}/commit/${shellExec(
          `cd ${repoPath} && git rev-parse --verify HEAD`,
          {
            stdout: true,
          },
        ).trim()}`,
      );
    },

    /**
     * Initializes a new Underpost repository, optionally setting up a deploy ID or sub-configuration.
     * @param {string} [projectName=''] - The name of the project to create.
     * @param {object} [options] - Initialization options.
     * @param {string} [options.deployId=''] - The deployment ID to set up.
     * @param {string} [options.subConf=''] - The sub-configuration to create.
     * @param {boolean} [options.cluster=false] - If true, sets up a clustered configuration.
     * @param {boolean} [options.dev=false] - If true, uses development settings.
     * @param {boolean} [options.buildRepos=false] - If true, creates the deployment repositories (engine-*, engine-*-private, engine-*-cron-backups).
     * @param {boolean} [options.purge=false] - If true, removes the deploy ID conf and all related repositories (requires deployId).
     * @param {boolean} [options.cleanTemplate=false] - If true, cleans the pwa-microservices-template build directory.
     * @param {boolean} [options.build=false] - If true, builds the deployment to pwa-microservices-template (requires deployId).
     * @param {boolean} [options.syncConf=false] - If true, syncs configuration to private repositories (requires deployId).
     * @param {boolean} [options.syncStart=false] - If true, syncs start scripts in deploy ID package.json with root package.json.
     * @param {boolean} [options.defaultConf=false] - If true, updates the default configuration file (requires deployId).
     * @param {string} [options.confWorkflowId=''] - If provided, uses this configuration workflow ID.
     * @returns {Promise<boolean>} A promise that resolves when the initialization is complete.
     * @memberof UnderpostRepository
     */
    new(
      projectName,
      options = {
        deployId: '',
        subConf: '',
        cluster: false,
        dev: false,
        buildRepos: false,
        purge: false,
        cleanTemplate: false,
        build: false,
        syncConf: false,
        syncStart: false,
        defaultConf: false,
        confWorkflowId: '',
      },
    ) {
      return new Promise(async (resolve, reject) => {
        try {
          await logger.setUpInfo();
          actionInitLog();

          // Handle cleanTemplate operation
          if (options.cleanTemplate) {
            logger.info('Cleaning build directory');
            const basePath = '../pwa-microservices-template';
            shellExec(`cd ${basePath} && git reset`);
            shellExec(`cd ${basePath} && git checkout .`);
            shellExec(`cd ${basePath} && git clean -f -d`);
            logger.info('Build directory cleaned successfully');
            return resolve(true);
          }

          // Handle defaultConf operation
          if (options.defaultConf) {
            Underpost.repo.updateDefaultConf(options);
            return resolve(true);
          }

          if (options.deployId) {
            let deployId = options.deployId;

            // Handle sync-start operation (before dd- prefix normalization to support 'dd' special case)
            if (options.syncStart) {
              shellExec(`node bin/deploy sync-start ${deployId}`);
              return resolve(true);
            }

            if (!deployId.startsWith('dd-')) deployId = `dd-${deployId}`;
            // Handle purge operation
            if (options.purge) {
              logger.info(`Purging deploy ID: ${deployId}`);

              const suffix = deployId.split('dd-')[1];
              const repoName = `engine-${suffix}`;
              const privateRepoName = `engine-${suffix}-private`;
              const cronRepoName = `engine-${suffix}-cron-backups`;
              const confFolder = `./engine-private/conf/${deployId}`;

              // Remove conf folder
              if (fs.existsSync(confFolder)) {
                fs.removeSync(confFolder);
                logger.info(`Removed conf folder: ${confFolder}`);
              } else {
                logger.warn(`Conf folder not found: ${confFolder}`);
              }

              // Remove repositories
              const repos = [
                { path: `../${repoName}`, name: repoName },
                { path: `../${privateRepoName}`, name: privateRepoName },
                { path: `../${cronRepoName}`, name: cronRepoName },
              ];

              for (const repo of repos) {
                if (fs.existsSync(repo.path)) {
                  fs.removeSync(repo.path);
                  logger.info(`Removed repository: ${repo.path}`);
                } else {
                  logger.warn(`Repository not found: ${repo.path}`);
                }
              }

              logger.info(`Successfully purged deploy ID: ${deployId}`);
              return resolve(true);
            }

            // Handle sync-conf operation
            if (options.syncConf) {
              logger.info(`Syncing configuration for deploy ID: ${deployId}`);
              shellExec(`node bin/build ${deployId} --conf`);
              logger.info('Configuration synced successfully');
              return resolve(true);
            }

            // Handle build operation
            if (options.build) {
              logger.info(`Building deployment for deploy ID: ${deployId}`);
              shellExec(`node bin/build ${deployId}`);
              logger.info('Build completed successfully');
              return resolve(true);
            }

            // Normal deploy ID factory operation
            const { deployId: normalizedDeployId } = Config.deployIdFactory(deployId, options);

            if (options.buildRepos) {
              const suffix = normalizedDeployId.split('dd-')[1];
              const repoName = `engine-${suffix}`;
              const privateRepoName = `engine-${suffix}-private`;
              const cronRepoName = `engine-${suffix}-cron-backups`;
              const repos = [
                { path: `../${repoName}`, name: repoName },
                { path: `../${privateRepoName}`, name: privateRepoName },
                { path: `../${cronRepoName}`, name: cronRepoName },
              ];

              const username = process.env.GITHUB_USERNAME;
              const token = process.env.GITHUB_TOKEN;

              if (!username) {
                logger.error('GITHUB_USERNAME environment variable not set');
                return reject(false);
              }

              for (const repo of repos) {
                if (!fs.existsSync(repo.path)) {
                  fs.mkdirSync(repo.path, { recursive: true });
                  logger.info(`Created repository directory: ${repo.path}`);
                }

                const remoteUrl = `https://${token ? `${token}@` : ''}github.com/${username}/${repo.name}.git`;
                UnderpostRepository.API.initLocalRepo({ path: repo.path, origin: remoteUrl });
                logger.info(`Initialized git repository with remote: ${repo.name}`);
              }
            }
            return resolve(true);
          }
          if (projectName) {
            const npmRoot = getNpmRootPath();
            const underpostRoot = options?.dev === true ? '.' : `${npmRoot}/underpost`;
            const destFolder = `./${projectName}`;
            const deployId = projectName.startsWith('dd-') ? projectName : `dd-${projectName}`;
            logger.info('build app', { destFolder, deployId });
            if (fs.existsSync(destFolder)) fs.removeSync(destFolder);
            fs.mkdirSync(destFolder, { recursive: true });
            if (!options.dev) {
              fs.copySync(underpostRoot, destFolder);
              fs.writeFileSync(
                `${destFolder}/.gitignore`,
                fs.readFileSync(`${underpostRoot}/.dockerignore`, 'utf8'),
                'utf8',
              );
              UnderpostRepository.API.initLocalRepo({ path: destFolder });
              shellExec(`cd ${destFolder} && git add . && git commit -m "Base template implementation"`);
            }
            shellExec(`cd ${destFolder} && node bin new --deploy-id ${deployId} --default-conf`);
            shellExec(`cd ${destFolder} && node bin client ${deployId}`);
            shellExec(`cd ${destFolder} && DEPLOY_ID=${deployId} npm run dev`);
          }
          return resolve(true);
        } catch (error) {
          console.log(error);
          logger.error(error, error.stack);
          return reject(false);
        }
      });
    },

    /**
     * Builds client assets, single replicas, and/or syncs environment ports.
     * @param {string} [deployId='dd-default'] - The deployment ID.
     * @param {string} [subConf=''] - The sub-configuration for the build.
     * @param {string} [host=''] - Comma-separated hosts to filter the build.
     * @param {string} [path=''] - Comma-separated paths to filter the build.
     * @param {object} [options] - Build options.
     * @param {boolean} [options.syncEnvPort=false] - If true, syncs environment port assignments across all deploy IDs.
     * @param {boolean} [options.singleReplica=false] - If true, builds single replica folders instead of full client.
     * @param {boolean} [options.buildZip=false] - If true, creates zip files of the builds.
     * @param {string|number} [options.split=''] - Optional ZIP part size in MB. When set with buildZip, writes split parts.
     * @param {string} [options.unzip=''] - Optional build ZIP prefix to extract from ./build.
     * @param {string} [options.mergeZip=''] - Optional build prefix to merge split ZIP parts into a single ZIP.
     * @param {boolean} [options.liteBuild=false] - If true, skips full build (default is full build).
     * @param {boolean} [options.iconsBuild=false] - If true, builds icons.
     * @returns {Promise<boolean>} A promise that resolves when the build is complete.
     * @memberof UnderpostRepository
     */
    client(
      deployId = 'dd-default',
      subConf = '',
      host = '',
      path = '',
      options = {
        syncEnvPort: false,
        singleReplica: false,
        buildZip: false,
        split: '',
        unzip: '',
        mergeZip: '',
        liteBuild: false,
        iconsBuild: false,
      },
    ) {
      return new Promise(async (resolve, reject) => {
        try {
          if (options.mergeZip) {
            mergeClientBuildZip({
              buildPrefix: options.mergeZip,
              logger,
            });
            return resolve(true);
          }

          if (options.unzip) {
            unzipClientBuild({
              buildPrefix: options.unzip,
              logger,
            });
            return resolve(true);
          }

          // Handle singleReplica operation (must run before syncEnvPort to ensure replica dirs exist)
          if (options.singleReplica) {
            const replicaPath = path;
            if (!deployId || !host || !replicaPath) {
              logger.error('client --single-replica requires deploy-id, host, and path arguments');
              return reject(false);
            }
            const serverConf = loadReplicas(
              deployId,
              loadConfServerJson(`./engine-private/conf/${deployId}/conf.server.json`),
            );

            if (serverConf[host][replicaPath].replicas) {
              {
                let replicaIndex = -1;
                for (const replica of serverConf[host][replicaPath].replicas) {
                  replicaIndex++;
                  const replicaDeployId = `${deployId}-${serverConf[host][replicaPath].replicas[replicaIndex].slice(1)}`;
                  await fs.copy(`./engine-private/conf/${deployId}`, `./engine-private/replica/${replicaDeployId}`);
                  fs.writeFileSync(
                    `./engine-private/replica/${replicaDeployId}/package.json`,
                    fs
                      .readFileSync(`./engine-private/replica/${replicaDeployId}/package.json`, 'utf8')
                      .replaceAll(`${deployId}`, `${replicaDeployId}`),
                    'utf8',
                  );
                  const replicaFolder = `./engine-private/replica/${replicaDeployId}`;
                  for (const envFile of ['.env.production', '.env.development', '.env.test']) {
                    const envFilePath = `${replicaFolder}/${envFile}`;
                    if (fs.existsSync(envFilePath)) {
                      fs.writeFileSync(
                        envFilePath,
                        fs
                          .readFileSync(envFilePath, 'utf8')
                          .replaceAll(`DEPLOY_ID=${deployId}`, `DEPLOY_ID=${replicaDeployId}`),
                        'utf8',
                      );
                    }
                  }
                }
              }
              {
                let replicaIndex = -1;
                for (const replica of serverConf[host][replicaPath].replicas) {
                  replicaIndex++;
                  const replicaDeployId = `${deployId}-${serverConf[host][replicaPath].replicas[replicaIndex].slice(1)}`;
                  let replicaServerConf = JSON.parse(
                    fs.readFileSync(`./engine-private/replica/${replicaDeployId}/conf.server.json`, 'utf8'),
                  );

                  const singleReplicaConf = replicaServerConf[host][replicaPath];
                  singleReplicaConf.replicas = undefined;
                  singleReplicaConf.singleReplica = undefined;

                  replicaServerConf = {};
                  replicaServerConf[host] = {};
                  replicaServerConf[host][replica] = singleReplicaConf;

                  fs.writeFileSync(
                    `./engine-private/replica/${replicaDeployId}/conf.server.json`,
                    JSON.stringify(replicaServerConf, null, 4),
                    'utf8',
                  );
                }
              }
            }
            if (!options.syncEnvPort) return resolve(true);
          }

          // Handle syncEnvPort operation
          if (options.syncEnvPort) {
            const dataDeploy = await getDataDeploy({ disableSyncEnvPort: true });
            const dataEnv = [
              { env: 'production', port: 3000 },
              { env: 'development', port: 4000 },
              { env: 'test', port: 5000 },
            ];
            let portOffset = 0;
            const singleReplicaPortOffsets = {};
            for (const deployIdObj of dataDeploy) {
              const { deployId } = deployIdObj;
              const baseConfPath = fs.existsSync(`./engine-private/replica/${deployId}`)
                ? `./engine-private/replica`
                : `./engine-private/conf`;

              const effectivePortOffset =
                singleReplicaPortOffsets[deployId] !== undefined ? singleReplicaPortOffsets[deployId] : portOffset;

              let skipDeploy = false;
              for (const envInstanceObj of dataEnv) {
                const envPath = `${baseConfPath}/${deployId}/.env.${envInstanceObj.env}`;
                if (!fs.existsSync(envPath)) {
                  logger.warn(`Skipping ${deployId}: ${envPath} not found`);
                  skipDeploy = true;
                  break;
                }
                const envObj = dotenv.parse(fs.readFileSync(envPath, 'utf8'));
                envObj.PORT = `${envInstanceObj.port + effectivePortOffset}`;
                writeEnv(envPath, envObj);
              }

              if (skipDeploy) continue;
              if (singleReplicaPortOffsets[deployId] !== undefined) continue;

              const serverConf = loadReplicas(
                deployId,
                loadConfServerJson(`${baseConfPath}/${deployId}/conf.server.json`),
              );
              for (const host of Object.keys(serverConf)) {
                let deferredSingleReplicaSlots = [];
                for (const path of Object.keys(serverConf[host])) {
                  if (serverConf[host][path].singleReplica && serverConf[host][path].replicas) {
                    deferredSingleReplicaSlots.push({
                      replicas: serverConf[host][path].replicas,
                      peer: !!serverConf[host][path].peer,
                    });
                    continue;
                  }
                  portOffset++;
                  if (serverConf[host][path].peer) portOffset++;
                }
                for (const slot of deferredSingleReplicaSlots) {
                  for (const replica of slot.replicas) {
                    const replicaDeployId = buildReplicaId({ deployId, replica });
                    singleReplicaPortOffsets[replicaDeployId] = portOffset;
                    portOffset++;
                    if (slot.peer) portOffset++;
                  }
                }
              }
            }
            return resolve(true);
          }

          // Handle buildFullClient operation (default)
          {
            const { deployId: resolvedDeployId } = loadConf(deployId, subConf ?? '');

            let argHost = host ? host.split(',') : [];
            let argPath = path ? path.split(',') : [];
            let deployIdSingleReplicas = [];
            let singleReplicaHosts = [];
            const isReplicaContext = resolvedDeployId
              ? fs.existsSync(`./engine-private/replica/${resolvedDeployId}`)
              : false;
            const serverConf = resolvedDeployId
              ? readConfJson(resolvedDeployId, 'server', { loadReplicas: true })
              : Config.default.server;
            const confFilePath = resolvedDeployId ? getConfFilePath(resolvedDeployId, 'server') : null;
            const originalConfBackup = confFilePath ? fs.readFileSync(confFilePath, 'utf8') : null;
            for (const host of Object.keys(serverConf)) {
              for (const path of Object.keys(serverConf[host])) {
                if (argHost.length && argPath.length && (!argHost.includes(host) || !argPath.includes(path))) {
                  delete serverConf[host][path];
                } else {
                  if (!isReplicaContext && serverConf[host][path].singleReplica && serverConf[host][path].replicas) {
                    singleReplicaHosts.push({ host, path });
                    deployIdSingleReplicas = deployIdSingleReplicas.concat(
                      serverConf[host][path].replicas.map((replica) =>
                        buildReplicaId({ deployId: resolvedDeployId, replica }),
                      ),
                    );
                  }
                }
              }
            }
            await buildClient({
              deployId: resolvedDeployId,
              buildZip: options.buildZip || false,
              split: options.split || '',
              fullBuild: options.liteBuild ? false : true,
              iconsBuild: options.iconsBuild || false,
              ssrOnly: options.ssr || false,
            });
            for (const replicaDeployId of deployIdSingleReplicas) {
              if (!fs.existsSync(`./engine-private/replica/${replicaDeployId}`)) {
                logger.warn('Skip replica client build: replica folder not found', { replicaDeployId });
                continue;
              }
              await Underpost.repo.client(replicaDeployId, '', '', '', {
                buildZip: options.buildZip || false,
                split: options.split || '',
                liteBuild: options.liteBuild || false,
                iconsBuild: options.iconsBuild || false,
                ssr: options.ssr || false,
              });
            }

            return resolve(true);
          }
        } catch (error) {
          console.log(error);
          logger.error(error, error.stack);
          return reject(false);
        }
      });
    },

    /**
     * Gets a list of deleted files from a Git repository.
     * @param {string} [path='.'] - The path to the repository.
     * @returns {string[]} An array of deleted file paths.
     * @memberof UnderpostRepository
     */
    getDeleteFiles(path = '.') {
      const commandUntrack = `cd ${path} && git ls-files --deleted`;
      const diffUntrackOutput = shellExec(commandUntrack, { stdout: true, silent: true });
      return diffUntrackOutput.toString().split('\n').filter(Boolean);
    },

    /**
     * Gets a list of changed (modified and untracked) files in a Git repository.
     * @param {string} [path='.'] - The path to the repository.
     * @param {string} [extension=''] - An optional file extension to filter by.
     * @param {boolean} [head=false] - If true, diffs against HEAD^.
     * @returns {string[]} An array of changed file paths.
     * @memberof UnderpostRepository
     */
    getChangedFiles(path = '.', extension = '', head = false) {
      const extensionFilter = extension ? `-- '***.${extension}'` : '';
      const command = `cd ${path} && git diff ${head ? 'HEAD^ HEAD ' : ''}--name-only ${extensionFilter}`;
      const commandUntrack = `cd ${path} && git ls-files --others --exclude-standard`;
      const diffOutput = shellExec(command, { stdout: true, silent: true });
      const diffUntrackOutput = shellExec(commandUntrack, { stdout: true, silent: true });
      const deleteFiles = Underpost.repo.getDeleteFiles(path);
      return diffOutput
        .toString()
        .split('\n')
        .filter(Boolean)
        .concat(diffUntrackOutput.toString().split('\n').filter(Boolean))
        .filter((f) => !deleteFiles.includes(f));
    },
    /**
     * Updates the private configuration repository for a given deployId.
     * @param {string} deployId - The deployment ID.
     * @returns {{validVersion: boolean, engineVersion: string, deployVersion: string}} An object indicating if the versions are valid.
     * @memberof UnderpostRepository
     */
    privateConfUpdate(deployId) {
      shellCd(`/home/dd/engine`);
      const privateRepoName = `engine-${deployId.split('dd-')[1]}-private`;
      const privateRepoPath = `../${privateRepoName}`;
      if (fs.existsSync(privateRepoPath)) fs.removeSync(privateRepoPath);
      shellExec(`cd .. && underpost clone ${process.env.GITHUB_USERNAME}/${privateRepoName}`);
      shellExec(`cd ${privateRepoPath} && underpost pull . ${process.env.GITHUB_USERNAME}/${privateRepoName}`, {
        silent: true,
      });
      const packageJsonDeploy = JSON.parse(fs.readFileSync(`./engine-private/conf/${deployId}/package.json`, 'utf8'));
      const packageJsonEngine = JSON.parse(fs.readFileSync(`./package.json`, 'utf8'));
      if (packageJsonDeploy.version !== packageJsonEngine.version) {
        logger.warn(
          `Version mismatch: deploy-version:${packageJsonDeploy.version} !== engine-version:${packageJsonEngine.version},
Prevent build private config repo.`,
        );
        return {
          validVersion: false,
          engineVersion: packageJsonEngine.version,
          deployVersion: packageJsonDeploy.version,
        };
      }
      shellExec(`node bin/build ${deployId} --conf`);
      return {
        validVersion: true,
        engineVersion: packageJsonEngine.version,
        deployVersion: packageJsonDeploy.version,
      };
    },

    /**
     * Retrieves the Git commit history.
     * @param {number} [sinceCommit=1] - The number of recent commits to retrieve.
     * @param {string} [repoPath='.'] - The path to the repository.
     * @returns {Array<{hash: string, message: string, files: string}>} An array of commit objects with hash, message, and files.
     * @memberof UnderpostRepository
     */
    getHistory(sinceCommit = 1, repoPath = '.') {
      return shellExec(`cd ${repoPath} && git log -1 --pretty=format:"%h %s" -n ${sinceCommit}`, {
        stdout: true,
        silent: true,
        disableLog: true,
      })
        .split(`\n`)
        .map((line) => {
          const hash = line.split(' ')[0];
          return {
            hash,
            message: line.split(`${hash} `)[1],
          };
        })
        .filter((line) => line.hash)
        .map((line) => {
          line.files = shellExec(`cd ${repoPath} && git show --name-status --pretty="" ${line.hash}`, {
            stdout: true,
            silent: true,
            disableLog: true,
          });
          return line;
        });
    },
    /**
     * Updates the default configuration file based on the provided options.
     * @param {object} [options={ deployId: '' }] - The options for updating the configuration.
     * @param {string} [options.deployId=''] - The deployment ID to use for configuration.
     * @param {string} [options.confWorkflowId=''] - The configuration workflow ID to use.
     * @memberof UnderpostRepository
     */
    updateDefaultConf(options = { deployId: '', confWorkflowId: '' }) {
      const defaultServer = DefaultConf.server['default.net']['/'];
      let { deployId, confWorkflowId } = options;
      let defaultConf = false;

      // Custom workflow configurations
      if (confWorkflowId)
        switch (confWorkflowId) {
          case 'dd-github-pages': {
            const host = `${process.env.GITHUB_USERNAME ? process.env.GITHUB_USERNAME : 'underpostnet'}.github.io`;
            const path = '/pwa-microservices-template-ghpkg';
            DefaultConf.server = {
              [host]: { [path]: defaultServer },
            };
            DefaultConf.server[host][path].apiBaseProxyPath = '/';
            DefaultConf.server[host][path].apiBaseHost = 'www.nexodev.org';
            defaultConf = true;
            break;
          }
          case 'template': {
            const host = 'default.net';
            const path = '/';
            DefaultConf.server[host][path].valkey = {
              port: 'env:VALKEY_PORT:int:6379',
              host: 'env:VALKEY_HOST:127.0.0.1',
            };
            DefaultConf.server[host][path].db.host = 'env:DB_HOST:mongodb://127.0.0.1:27017';
            DefaultConf.server[host][path].db.replicaSet = 'env:DB_REPLICA_SET:rs0';
            DefaultConf.server[host][path].db.authSource = 'env:DB_AUTH_SOURCE:admin';
            DefaultConf.server[host][path].db.user = 'env:DB_USER:';
            DefaultConf.server[host][path].db.password = 'env:DB_PASSWORD:';
            defaultConf = true;
            break;
          }
          default:
            logger.error(`Unknown confWorkflowId: ${confWorkflowId}.`);
            return;
        }
      else if (deployId && fs.existsSync(`./engine-private/conf/${deployId}`)) {
        DefaultConf.client = JSON.parse(fs.readFileSync(`./engine-private/conf/${deployId}/conf.client.json`, 'utf8'));
        DefaultConf.server = JSON.parse(fs.readFileSync(`./engine-private/conf/${deployId}/conf.server.json`, 'utf8'));
        DefaultConf.ssr = JSON.parse(fs.readFileSync(`./engine-private/conf/${deployId}/conf.ssr.json`, 'utf8'));
      } else
        logger.warn(
          `Deploy ID configuration not found: ./engine-private/conf/${deployId}, using default configuration.`,
        );

      // Serialize the configuration into the conf.*.js manifest file.
      // env: references from JSON configs are preserved as 'env:KEY' strings.
      // At runtime, resolveConfSecrets() in conf.js resolves them via process.env.
      const sepRender = '/**/';
      const confRawPaths = fs.readFileSync('./conf.js', 'utf8').split(sepRender);
      confRawPaths[1] = `${JSON.stringify(DefaultConf)};`;
      const targetConfPath = `./conf${defaultConf ? '' : `.${deployId}`}.js`;
      fs.writeFileSync(targetConfPath, confRawPaths.join(sepRender), 'utf8');
      shellExec(`prettier --write ${targetConfPath}`);
    },

    /**
     * Cleans the specified paths in the repository by resetting, checking out, and cleaning untracked files.
     * @param {object} [options={ paths: [''] }] - The options for cleaning.
     * @param {string[]} [options.paths=['']] - The paths to clean.
     * @memberof UnderpostRepository
     */
    clean(options = { paths: [''] }) {
      for (const path of options.paths) {
        shellExec(`cd ${path} && git reset`, { silentOnError: true, silent: true, disableLog: true });
        shellExec(`cd ${path} && git checkout .`, { silentOnError: true, silent: true, disableLog: true });
        shellExec(`cd ${path} && git clean -f -d`, { silentOnError: true, silent: true, disableLog: true });
      }
    },

    /**
     * Copies files recursively from a Git repository URL directory path.
     * @param {object} options - Configuration options for copying files.
     * @param {string} options.gitUrl - The GitHub repository URL (e.g., 'https://github.com/canonical/packer-maas').
     * @param {string} options.directoryPath - The directory path within the repository to copy (e.g., 'rocky-9').
     * @param {string} options.targetPath - The local target path where files should be copied.
     * @param {string} [options.branch='main'] - The git branch to use (default: 'main').
     * @param {boolean} [options.overwrite=false] - Whether to overwrite existing target directory.
     * @returns {Promise<object>} A promise that resolves with copied files information.
     * @memberof UnderpostRepository
     */
    async copyGitUrlDirectoryRecursive(options) {
      const { gitUrl, directoryPath, targetPath, branch = 'main', overwrite = false } = options;

      // Validate inputs
      if (!gitUrl) {
        throw new Error('gitUrl is required');
      }
      if (!directoryPath) {
        throw new Error('directoryPath is required');
      }
      if (!targetPath) {
        throw new Error('targetPath is required');
      }

      // Parse GitHub URL to extract owner and repo
      const urlMatch = gitUrl.match(/github\.com\/([^\/]+)\/([^\/\.]+)/);
      if (!urlMatch) {
        throw new Error(`Invalid GitHub URL: ${gitUrl}`);
      }
      const [, owner, repo] = urlMatch;

      logger.info(`Copying from ${owner}/${repo}/${directoryPath} to ${targetPath}`);

      // Check if target directory exists
      if (fs.existsSync(targetPath) && !overwrite) {
        throw new Error(`Target directory already exists: ${targetPath}. Use overwrite option to replace.`);
      }

      // Create target directory
      fs.mkdirSync(targetPath, { recursive: true });

      // GitHub API base URL
      const githubApiBase = 'https://api.github.com/repos';
      const apiUrl = `${githubApiBase}/${owner}/${repo}/contents/${directoryPath}`;

      logger.info(`Fetching directory contents from: ${apiUrl}`);

      try {
        // Fetch directory contents recursively
        const copiedFiles = await this.fetchAndCopyGitHubDirectory({
          apiUrl,
          targetPath,
          basePath: directoryPath,
          branch,
        });

        logger.info(`Successfully copied ${copiedFiles.length} files to ${targetPath}`);

        return {
          success: true,
          filesCount: copiedFiles.length,
          files: copiedFiles,
          targetPath,
        };
      } catch (error) {
        // Clean up on error
        if (fs.existsSync(targetPath)) {
          fs.removeSync(targetPath);
          logger.warn(`Cleaned up target directory after error: ${targetPath}`);
        }
        throw new Error(`Failed to copy directory: ${error.message}`);
      }
    },

    /**
     * Internal method to recursively fetch and copy files from GitHub API.
     * @method
     * @param {object} options - Fetch options.
     * @param {string} options.apiUrl - The GitHub API URL.
     * @param {string} options.targetPath - The local target path.
     * @param {string} options.basePath - The base path in the repository.
     * @param {string} options.branch - The git branch.
     * @returns {Promise<array>} Array of copied file paths.
     * @memberof UnderpostRepository
     */
    async fetchAndCopyGitHubDirectory(options) {
      const { apiUrl, targetPath, basePath, branch } = options;
      const copiedFiles = [];

      const response = await fetch(apiUrl, {
        headers: {
          Accept: 'application/vnd.github.v3+json',
          'User-Agent': 'underpost-cli',
        },
      });

      if (!response.ok) {
        const errorBody = await response.text();
        logger.error(`GitHub API request failed for: ${apiUrl}`);
        logger.error(`Status: ${response.status} ${response.statusText}`);
        logger.error(`Response: ${errorBody}`);
        throw new Error(`GitHub API request failed: ${response.status} ${response.statusText} - ${errorBody}`);
      }

      const contents = await response.json();

      if (!Array.isArray(contents)) {
        logger.error(`Expected directory but got: ${typeof contents}`);
        logger.error(`API URL: ${apiUrl}`);
        logger.error(`Response keys: ${Object.keys(contents).join(', ')}`);
        if (contents.message) {
          logger.error(`GitHub message: ${contents.message}`);
        }
        throw new Error(
          `Path is not a directory: ${basePath}. Response: ${JSON.stringify(contents).substring(0, 200)}`,
        );
      }

      logger.info(`Found ${contents.length} items in directory: ${basePath}`);

      for (const item of contents) {
        const itemTargetPath = `${targetPath}/${item.name}`;

        if (item.type === 'file') {
          logger.info(`Downloading file: ${item.path}`);

          const fileResponse = await fetch(item.download_url);
          if (!fileResponse.ok) {
            logger.error(`Failed to download: ${item.download_url}`);
            throw new Error(`Failed to download file: ${item.path} (${fileResponse.status})`);
          }

          const fileContent = await fileResponse.text();
          fs.writeFileSync(itemTargetPath, fileContent);

          logger.info(`Saved: ${itemTargetPath}`);
          copiedFiles.push(itemTargetPath);
        } else if (item.type === 'dir') {
          logger.info(`Processing directory: ${item.path}`);

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

          const subFiles = await this.fetchAndCopyGitHubDirectory({
            apiUrl: item.url,
            targetPath: itemTargetPath,
            basePath: item.path,
            branch,
          });

          copiedFiles.push(...subFiles);
          logger.info(`Completed directory: ${item.path} (${subFiles.length} files)`);
        } else {
          logger.warn(`Skipping unknown item type '${item.type}': ${item.path}`);
        }
      }

      return copiedFiles;
    },

    /**
     * Resolves the default branch for a remote GitHub repository by querying
     * `git ls-remote --symref` and extracting the HEAD ref target.
     * Falls back to `main` when detection fails.
     * @param {string} repo - The GitHub repository (e.g., "owner/repo").
     * @returns {string} The default branch name (e.g. "main" or "master").
     * @memberof UnderpostRepository
     */
    getDefaultBranch(repo) {
      if (!repo) return 'main';
      const authUrl = Underpost.repo.resolveAuthUrl(repo);
      const raw = shellExec(
        `GIT_TERMINAL_PROMPT=0 git -c credential.helper= ls-remote --symref "${authUrl}" HEAD 2>&1`,
        { stdout: true, silent: true, disableLog: true, silentOnError: true },
      );
      // --symref emits a line like: ref: refs/heads/main	HEAD
      const match = typeof raw === 'string' ? raw.match(/^ref:\s*refs\/heads\/(\S+)\tHEAD$/m) : null;
      const branch = match ? match[1] : 'main';
      logger.info('getDefaultBranch', { repo, branch });
      return branch;
    },

    /**
     * Dispatches a GitHub Actions workflow using gh CLI or curl fallback.
     * @param {object} options - Dispatch options.
     * @param {string} options.repo - The GitHub repository (e.g., "owner/repo").
     * @param {string} options.workflowFile - The workflow file name (e.g., "engine-core.cd.yml").
     * @param {string} [options.ref] - The git ref to dispatch against. Auto-detects the remote's default branch when omitted.
     * @param {object} [options.inputs={}] - Key-value inputs for the workflow_dispatch event.
     * @memberof UnderpostRepository
     */
    dispatchWorkflow(options = { repo: '', workflowFile: '', ref: '', inputs: {} }) {
      const { repo, workflowFile, inputs } = options;
      const ref = options.ref || Underpost.repo.getDefaultBranch(repo);
      const ghAvailable = shellExec('command -v gh 2>/dev/null', {
        stdout: true,
        silent: true,
        disableLog: true,
      }).trim();

      if (ghAvailable) {
        let cmd = `gh workflow run ${workflowFile} --repo ${repo} --ref ${ref}`;
        for (const [key, value] of Object.entries(inputs)) {
          if (value !== undefined && value !== '') {
            const escaped = String(value).replace(/'/g, "'\\''");
            cmd += ` -f ${key}='${escaped}'`;
          }
        }
        shellExec(cmd);
      } else {
        let token = process.env.GITHUB_TOKEN;
        if (!token) {
          const envPath = `${getNpmRootPath()}/underpost/.env`;
          if (fs.existsSync(envPath) && fs.statSync(envPath).isFile()) {
            const envVars = dotenv.parse(fs.readFileSync(envPath, 'utf8'));
            token = envVars.GITHUB_TOKEN;
          }
        }
        if (!token) {
          logger.error('GITHUB_TOKEN is required for workflow dispatch (gh CLI not available)');
          return;
        }
        const payload = { ref };
        if (Object.keys(inputs).length > 0) payload.inputs = inputs;
        const payloadJson = JSON.stringify(payload).replace(/'/g, "'\\''");
        shellExec(
          `curl -s -f -X POST ` +
            `-H "Accept: application/vnd.github.v3+json" ` +
            `-H "Authorization: token ${token}" ` +
            `"https://api.github.com/repos/${repo}/actions/workflows/${workflowFile}/dispatches" ` +
            `-d '${payloadJson}'`,
        );
      }
      logger.info('Dispatched workflow', `${repo} -> ${workflowFile}`, inputs.job ? `(job: ${inputs.job})` : '');
    },

    /**
     * Resolves a Git remote URL, normalizing short-form owner/repo references to full
     * GitHub HTTPS URLs and injecting GITHUB_TOKEN when available.
     * @param {string} url - The repository URL or short-form (e.g. "owner/repo" or full HTTPS URL).
     * @returns {string} The resolved (and optionally authenticated) HTTPS URL.
     * @memberof UnderpostRepository
     */
    resolveAuthUrl(url) {
      if (!url) return url;
      // Normalize short form "owner/repo" → full GitHub HTTPS URL
      let normalized = url;
      if (!url.startsWith('http://') && !url.startsWith('https://') && !url.startsWith('git@')) {
        normalized = `https://github.com/${url}`;
      }
      if (process.env.GITHUB_TOKEN && normalized.startsWith('https://github.com/')) {
        return normalized.replace(
          'https://github.com/',
          `https://x-access-token:${process.env.GITHUB_TOKEN}@github.com/`,
        );
      }
      return normalized;
    },
    /**
     * Checks whether a remote Git repository URL is reachable.
     * Uses `silentOnError` so a non-reachable remote returns false instead of throwing.
     * Injects `GITHUB_TOKEN` into GitHub HTTPS URLs when available.
     * @param {string} url - Full HTTPS clone URL to test (e.g. "https://github.com/org/repo.git").
     * @returns {boolean} `true` when the remote responded with at least one ref hash.
     * @memberof UnderpostRepository
     */
    isRemoteRepo(url) {
      if (!url) return false;
      const authUrl = Underpost.repo.resolveAuthUrl(url);
      // GIT_TERMINAL_PROMPT=0 prevents git from hanging on credential prompts inside containers.
      // `-c credential.helper=` disables any host-configured credential helper so its stderr
      // warnings don't pollute the ls-remote output; the token is already embedded in authUrl.
      const raw = shellExec(`GIT_TERMINAL_PROMPT=0 git -c credential.helper= ls-remote "${authUrl}" HEAD 2>&1`, {
        stdout: true,
        silent: true,
        disableLog: true,
        silentOnError: true,
      });
      const refLine = typeof raw === 'string' ? (raw.match(/^[0-9a-f]{40}\t.*$/m) || [])[0] : undefined;
      const accessible = !!refLine;
      logger.info('isRemoteRepo', { url, accessible, ref: refLine || (raw || '').trim().split('\n').pop() });
      return accessible;
    },

    /**
     * Returns the current URL of a git remote in plain text.
     * @param {object} [opts]
     * @param {string} [opts.path='.'] - Path to the git repository.
     * @param {string} [opts.remote='origin'] - Remote name to query.
     * @returns {string} The remote URL, or '' when the remote is not configured.
     * @memberof UnderpostRepository
     */
    getRemoteUrl({ path: repoPath = '.', remote = 'origin' } = {}) {
      return shellExec(`cd "${repoPath}" && git remote get-url ${remote}`, {
        stdout: true,
        silent: true,
        disableLog: true,
        silentOnError: true,
      }).trim();
    },

    /**
     * Switches a local repository onto a different remote and force-syncs its
     * working tree to a target branch, discarding local commits and tracked
     * changes — effectively "switch repo to <url>#<branch>".
     *
     * Sequence (idempotent, re-runnable):
     *   1. Normalize the URL (`owner/repo` → full GitHub HTTPS) and set/add the
     *      remote, storing the token-free URL so no secret leaks into `.git/config`.
     *   2. Force-fetch the target branch (auth injected inline for private repos).
     *   3. Reset the working tree to the fetched tip and check out the target
     *      branch, overwriting any current tracked content.
     *
     * Untracked files are intentionally left in place (no `git clean`).
     *
     * @param {object} opts
     * @param {string} opts.url - New remote URL (full URL or "owner/repo" short form).
     * @param {string} [opts.path='.'] - Path to the git repository.
     * @param {string} [opts.branch] - Target branch to overwrite the current tree with. Defaults to the remote's default branch.
     * @param {string} [opts.remote='origin'] - Remote name to set and fetch from.
     * @returns {void}
     * @memberof UnderpostRepository
     */
    switchRemote({ url, path: repoPath = '.', branch = '', remote = 'origin' }) {
      if (!url) throw new Error('switchRemote requires a target remote url');
      if (!fs.existsSync(`${repoPath}/.git`)) throw new Error(`switchRemote: not a git repository: ${repoPath}`);
      const targetBranch = branch || Underpost.repo.getDefaultBranch(url);
      // Token-free URL for the stored remote; auth-injected URL only for the fetch.
      let normalized = url;
      if (!url.startsWith('http://') && !url.startsWith('https://') && !url.startsWith('git@')) {
        normalized = `https://github.com/${url}`;
      }
      const authUrl = Underpost.repo.resolveAuthUrl(url);
      const current = Underpost.repo.getRemoteUrl({ path: repoPath, remote });
      if (!current) shellExec(`cd "${repoPath}" && git remote add ${remote} "${normalized}"`);
      else shellExec(`cd "${repoPath}" && git remote set-url ${remote} "${normalized}"`);
      logger.info('switchRemote', { path: repoPath, remote, branch: targetBranch, url: normalized });
      shellExec(`cd "${repoPath}" && GIT_TERMINAL_PROMPT=0 git fetch --force "${authUrl}" ${targetBranch}`);
      // reset --hard first clears the worktree so the checkout cannot be blocked
      // by conflicting local changes; -B points the target branch at the fetched tip.
      shellExec(`cd "${repoPath}" && git reset --hard FETCH_HEAD`);
      shellExec(`cd "${repoPath}" && git checkout -B ${targetBranch} FETCH_HEAD`);
    },

    /**
     * Returns metadata about unpushed commits in a git repository.
     * Fetches from origin, then counts commits ahead of the remote branch.
     * @param {string} [repoPath='.'] - Path to the git repository.
     * @param {number} [fallback=1] - Value to return as `count` when no unpushed commits are detected.
     * @returns {{ count: number, branch: string, hasUnpushed: boolean }} Unpush metadata.
     * @memberof UnderpostRepository
     */
    getUnpushedCount(repoPath = '.', fallback = 1) {
      // Every git call is silentOnError: a detached HEAD (CI checkout) or a missing upstream must
      // degrade to the fallback, never throw — otherwise the thrown error is logged to stdout and
      // can be captured as a commit message by callers that read this command's output.
      const branch = shellExec(`cd ${repoPath} && git branch --show-current`, {
        stdout: true,
        silent: true,
        disableLog: true,
        silentOnError: true,
      })
        .toString()
        .trim();
      if (!branch) return { count: fallback, branch: '', hasUnpushed: false };
      shellExec(`cd ${repoPath} && git fetch origin 2>/dev/null`, {
        silent: true,
        disableLog: true,
        silentOnError: true,
      });
      const raw = shellExec(`cd ${repoPath} && git rev-list --count origin/${branch}..HEAD 2>/dev/null`, {
        stdout: true,
        silent: true,
        disableLog: true,
        silentOnError: true,
      })
        .toString()
        .trim();
      const count = parseInt(raw);
      const hasUnpushed = !isNaN(count) && count > 0;
      return { count: hasUnpushed ? count : fallback, branch, hasUnpushed };
    },

    /**
     * Sanitizes a markdown changelog string into a compact message format.
     * Strips date headers, converts section tags to `[tag]` prefixes, removes bullet markers and special characters.
     * @param {string} message - The raw markdown changelog output.
     * @returns {string} The sanitized single-line or multi-line compact message.
     * @memberof UnderpostRepository
     */
    sanitizeChangelogMessage(message) {
      if (!message) return '';
      const sanitized = message
        .replace(/^##\s+\d{4}-\d{2}-\d{2}\s*/gm, '')
        .replace(/^###\s+(\S+)\s*/gm, '[$1] ')
        .replace(/^- /gm, '')
        .replaceAll('"', '')
        .replaceAll('`', '')
        .split('\n')
        .map((l) => l.trim())
        .filter(Boolean)
        .join('\n')
        .trim()
        .replaceAll('] - ', '] ');
      // The empty-changelog placeholder must never become a commit message; return empty so
      // callers fall back to their own generic default.
      return sanitized === 'No changelog entries found.' ? '' : sanitized;
    },
    /**
     * Initializes a git repository at the given path and configures user identity
     * from environment variables (`GITHUB_USERNAME` / `GITHUB_EMAIL`).
     * Safe to call on an already-initialized repo — only runs `git init` when
     * `.git` is absent and always ensures user.name / user.email are set.
     * @param {object} opts
     * @param {string} opts.path       - Absolute or relative path to the repository.
     * @param {string} [opts.origin]   - If provided, sets or updates git remote `origin`.
     * @memberof UnderpostRepository
     */
    initLocalRepo({ path: repoPath, origin }) {
      const gitUsername = process.env.GITHUB_USERNAME || 'underpostnet';
      const gitEmail = process.env.GITHUB_EMAIL || `development@underpost.net`;

      if (!fs.existsSync(`${repoPath}/.git`)) {
        shellExec(`mkdir -p "${repoPath}" && git init "${repoPath}"`);
      }

      shellExec(`cd "${repoPath}" && git config user.name '${gitUsername}'`);
      shellExec(`cd "${repoPath}" && git config user.email '${gitEmail}'`);
      shellExec(`cd "${repoPath}" && git config core.filemode false`);
      if (origin) {
        const currentRemote = shellExec(`cd "${repoPath}" && git remote get-url origin`, {
          stdout: true,
          silent: true,
          silentOnError: true,
        }).trim();
        if (!currentRemote) {
          shellExec(`cd "${repoPath}" && git remote add origin "${origin}"`);
        } else if (currentRemote !== origin) {
          shellExec(`cd "${repoPath}" && git remote set-url origin "${origin}"`);
        }
      }
    },
    /**
     * Manages a cron-backup Git repository: clone, pull, commit, or push.
     * Resolves the repository path as `../<repoName>` relative to the CWD.
     * Requires the `GITHUB_USERNAME` environment variable to be set.
     * @param {object} params
     * @param {string} params.repoName - Repository name (e.g. `engine-cyberia-cron-backups`).
     * @param {'clone'|'pull'|'commit'|'push'} params.operation - Git operation to perform.
     * @param {string} [params.message=''] - Commit message (used by the `commit` operation).
     * @param {boolean} [params.forceClone=false] - Remove existing clone before re-cloning.
     * @returns {boolean} `true` on success, `false` if GITHUB_USERNAME is unset or on error.
     * @memberof UnderpostRepository
     */
    manageBackupRepo({ repoName, operation, message = '', forceClone = false }) {
      try {
        const username = process.env.GITHUB_USERNAME;
        if (!username) {
          logger.error('GITHUB_USERNAME environment variable not set');
          return false;
        }

        const repoPath = `../${repoName}`;

        switch (operation) {
          case 'clone':
            if (forceClone && fs.existsSync(repoPath)) {
              logger.info(`Force clone: removing existing repository: ${repoName}`);
              fs.removeSync(repoPath);
            }
            if (!fs.existsSync(repoPath)) {
              shellExec(`cd .. && underpost clone ${username}/${repoName}`);
              logger.info(`Cloned repository: ${repoName}`);
            }
            break;

          case 'pull':
            if (fs.existsSync(repoPath)) {
              shellExec(`cd ${repoPath} && git checkout . && git clean -f -d`);
              shellExec(`cd ${repoPath} && underpost pull . ${username}/${repoName}`, { silent: true });
              logger.info(`Pulled repository: ${repoName}`);
            }
            break;

          case 'commit':
            if (fs.existsSync(repoPath)) {
              shellExec(`cd ${repoPath} && git add .`);
              shellExec(`underpost cmt ${repoPath} backup '' '${message}'`);
              logger.info(`Committed to repository: ${repoName}`, { message });
            }
            break;

          case 'push':
            if (fs.existsSync(repoPath)) {
              shellExec(`cd ${repoPath} && underpost push . ${username}/${repoName}`, { silent: true });
              logger.info(`Pushed repository: ${repoName}`);
            }
            break;

          default:
            logger.warn(`Unknown git operation: ${operation}`);
            return false;
        }

        return true;
      } catch (error) {
        logger.error(`Git operation failed`, { repoName, operation, error: error.message });
        return false;
      }
    },

    /**
     * Resolves the in-pod site-root directory where a conf route's repository lives.
     *
     * General-purpose resolution, independent of any single runtime:
     *   1. An explicit `directory` (the conf-declared document root) always wins.
     *   2. Otherwise the runtime's base directory is used (e.g. `wp` → `/opt/lampp/htdocs/wp/<host>`).
     *   3. A subdirectory route (e.g. `/wp`) appends `<subDir>` to the resolved root.
     *
     * This mirrors {@link WpService.createApp}'s `vhostDir`/`wpDir` layout so backups target the
     * exact directory provisioning created, including subdirectory installs and custom directories.
     *
     * @param {object} opts
     * @param {string} opts.runtime - The runtime identifier (e.g. 'wp').
     * @param {string} opts.host - The virtual-host name.
     * @param {string} [opts.routePath='/'] - The conf route path the repository is mounted under.
     * @param {string} [opts.directory] - Explicit document root from conf; overrides the runtime base.
     * @returns {string|null} Absolute path inside the pod, or null when neither a directory nor a
     *   known runtime base resolves.
     * @memberof UnderpostRepository
     */
    runtimeSiteRoot({ runtime, host, routePath = '/', directory } = {}) {
      const runtimeBase = {
        wp: `/opt/lampp/htdocs/wp/${host}`,
      };
      const vhostDir = directory || runtimeBase[runtime];
      if (!vhostDir) return null;
      const subDir = routePath && routePath !== '/' ? routePath.replace(/^\/+/, '').replace(/\/+$/, '') : '';
      return subDir ? `${vhostDir}/${subDir}` : vhostDir;
    },

    /**
     * Probes a running pod for the first candidate directory that is a git repository
     * (i.e. contains a `.git` entry). Used to locate a site root before backing it up so a
     * missing/unprovisioned directory is detected up-front instead of producing an opaque
     * shell `exit 1` from a failed `cd`.
     *
     * @param {object}   opts
     * @param {string}   opts.podName   - Target pod name.
     * @param {string}   opts.namespace - Kubernetes namespace.
     * @param {string[]} opts.candidates - Absolute paths to probe, most-specific first.
     * @returns {string|null} The first candidate that is a git repo, or null if none match.
     * @memberof UnderpostRepository
     */
    podRepoDir({ podName, namespace, candidates }) {
      const probe = candidates.map((dir) => `if [ -d '${dir}/.git' ]; then echo '${dir}'; exit 0; fi`).join('; ');
      let out = '';
      try {
        out = Underpost.kubectl.exec({ podName, namespace, command: `${probe}; echo ''` }) || '';
      } catch (err) {
        logger.warn(`podRepoDir: probe failed in pod ${podName}`, err.message);
        return null;
      }
      return (
        out
          .split('\n')
          .map((line) => line.trim())
          .find(Boolean) || null
      );
    },

    /**
     * Backs up all repositories defined in a deployment's conf.server.json by executing
     * git commit+push inside the running deployment pod via `kubectl exec`.
     *
     * Scans every `server[host][path]` entry for a `repository` field. For each match
     * the runtime-specific site root is resolved and a git backup script is executed
     * inside the pod. GITHUB_TOKEN and GITHUB_USERNAME are injected as ephemeral
     * environment variables in the exec command — never persisted to the pod filesystem.
     *
     * @param {object} opts
     * @param {string}  opts.deployId   - Deployment ID (used to read conf.server.json and find pods).
     * @param {string}  [opts.namespace='default'] - Kubernetes namespace.
     * @param {string}  [opts.env='production'] - Deployment environment.
     * @returns {void}
     * @memberof UnderpostRepository
     */
    backupPodRepositories({ deployId, namespace = 'default', env = 'production' }) {
      const confServer = readConfJson(deployId, 'server', { resolve: true });
      const githubToken = process.env.GITHUB_TOKEN || '';
      const githubUsername = process.env.GITHUB_USERNAME || 'underpostnet';

      if (!githubToken) {
        logger.warn('backupPodRepositories: GITHUB_TOKEN not available — git push will fail');
      }

      // Resolve the active blue/green traffic colour so we target the correct pod
      const traffic = Underpost.deploy.getCurrentTraffic(deployId, { namespace, env });
      if (!traffic) {
        logger.warn(`backupPodRepositories: could not resolve current traffic for ${deployId} — skipping`);
        return;
      }

      // Find a running pod that matches the active traffic colour
      const pods = Underpost.kubectl.get(`${deployId}-${env}-${traffic}`, 'pods', namespace);
      const runningPod = pods.find((p) => p.STATUS === 'Running');
      if (!runningPod) {
        logger.warn(`backupPodRepositories: no running ${traffic} pod found for ${deployId} in namespace ${namespace}`);
        return;
      }
      const podName = runningPod.NAME;

      for (const host of Object.keys(confServer)) {
        for (const routePath of Object.keys(confServer[host])) {
          const entry = confServer[host][routePath];
          if (!entry.repository) continue;

          const siteRootArgs = { runtime: entry.runtime, host, directory: entry.directory };
          const repoRoot = Underpost.repo.runtimeSiteRoot({ ...siteRootArgs, routePath });
          if (!repoRoot) {
            logger.warn(`backupPodRepositories: no site-root mapping for runtime '${entry.runtime}' (${host})`);
            continue;
          }

          // Probe the pod for the actual git repo so an unprovisioned/missing site root is
          // detected and skipped cleanly instead of failing the in-pod `cd` with exit 1.
          // Fall back to the vhost dir (root route) to cover conf/path drift.
          const vhostRoot = Underpost.repo.runtimeSiteRoot({ ...siteRootArgs, routePath: '/' });
          const candidates = [...new Set([repoRoot, vhostRoot].filter(Boolean))];
          const siteRoot = Underpost.repo.podRepoDir({ podName, namespace, candidates });
          if (!siteRoot) {
            logger.warn(
              `backupPodRepositories: no git repository found in pod ${podName} for ${host} (checked ${candidates.join(
                ', ',
              )}) — site may not be provisioned yet; skipping`,
            );
            continue;
          }

          const repoName = entry.repository.split('/').pop().split('.')[0];

          // Build the backup script — secrets are injected as env vars in the exec,
          // never written to filesystem. The shell process inherits them ephemerally.
          const backupScript = [
            `export GITHUB_TOKEN='${githubToken.replace(/'/g, "'\\''")}'`,
            `export GITHUB_USERNAME='${githubUsername.replace(/'/g, "'\\''")}'`,
            `git config --global --add safe.directory '${siteRoot}' 2>/dev/null || true`,
            `cd '${siteRoot}' && git add -A && git commit -m 'backup $(date -u +%Y-%m-%dT%H:%M:%SZ)' || true`,
            `cd '${siteRoot}' && underpost push . ${githubUsername}/${repoName}`,
            `cd /home/dd/engine && node bin secret underpost --global-clean`,
          ].join(' && ');

          try {
            logger.info(`backupPodRepositories: backing up ${host} (${entry.runtime}) in pod ${podName}`);
            Underpost.kubectl.exec({ podName, namespace, command: backupScript });
            logger.info(`backupPodRepositories: git push done for ${host}`);
          } catch (err) {
            logger.error(`backupPodRepositories: backup failed for ${host}`, err.message);
          }
        }
      }
    },

    /**
     * Clones the deploy-specific private repository into `./engine-private`
     * when it does not already exist on disk.  Returns `{ ephemeral: true }`
     * If `./engine-private` already exists, the call is a no-op unless
     * `options.force` is `true`, in which case the directory is removed and
     * re-cloned.
     *
     * @param {string} [deployId] - Deploy ID (e.g. `dd-core`) used to derive
     *        the repo name `engine-{component}-private`.  Falls back to
     *        `process.env.DEFAULT_DEPLOY_ID`.  When neither is available the
     *        default repo name `engine-private` is used.
     * @param {object} [options]
     * @param {boolean} [options.force=false] - Remove existing `engine-private`
     *        and re-clone.
     * @memberof UnderpostRepository
     */
    privateEngineRepoFactory(deployId, options = { force: false }) {
      if (fs.existsSync('./engine-private') && !options.force) return;

      if (options.force && fs.existsSync('./engine-private')) {
        fs.removeSync('./engine-private');
        logger.info('engine-private removed (force re-clone)');
      }

      const effectiveDeployId = deployId || process.env.DEFAULT_DEPLOY_ID;

      const username = process.env.GITHUB_USERNAME;
      if (!username) {
        throw new Error('privateEngineRepoFactory: GITHUB_USERNAME not set');
      }

      const repoName = effectiveDeployId ? `engine-${effectiveDeployId.split('-')[1]}-private` : 'engine-private';
      logger.info(`engine-private missing — cloning ${username}/${repoName}`);
      shellExec(`underpost clone ${username}/${repoName}`);
      if (!fs.existsSync(`./${repoName}`)) {
        throw new Error(`privateEngineRepoFactory: clone failed for ${username}/${repoName}`);
      }
      if (repoName !== 'engine-private') shellExec(`mv ./${repoName} ./engine-private`);
    },

    /**
     * Removes the ephemeral `engine-private/` clone created by
     * `privateEngineRepoFactory()`.  No-op if the directory does not exist.
     * @memberof UnderpostRepository
     */
    cleanupPrivateEngineRepo() {
      if (fs.existsSync('./engine-private')) {
        fs.removeSync('./engine-private');
        logger.info('engine-private ephemeral clone removed');
      }
      if (fs.existsSync('/home/dd/engine-private')) {
        fs.removeSync('/home/dd/engine-private');
        logger.info('engine-private in /home/dd removed');
      }
    },

    /**
     * Resolves the GitHub repository for a given instance runtime by scanning
     * every `conf.instances.json` listed in `./engine-private/deploy/dd.router`.
     *
     * Resolution order:
     *  1. If `runtime` is falsy, returns `${GITHUB_USERNAME}/engine`.
     *  2. Iterates each deploy ID found in `dd.router` and looks for an instance
     *     whose `runtime` field matches the supplied value.
     *  3. When a match is found, returns `instance.metadata.repository`.
     *  4. Falls back to `${GITHUB_USERNAME}/engine` when no match is found.
     *
     * @param {string} [runtime=''] - The runtime identifier to look up (e.g. `'cyberia-server'`, `'cyberia-client'`).
     * @param {boolean} [ownRuntimeRepo=false] - Whether to check for the runtime's own repository.
     * @returns {string} The resolved `owner/repo` string.
     * @memberof UnderpostRepository
     */
    resolveInstanceRepo(runtime = '', ownRuntimeRepo = false) {
      const fallback = `${process.env.GITHUB_USERNAME}/engine`;
      if (!runtime) return fallback;
      // A `.dev` suffix selects the development image workflow
      // (docker-image.<runtime>.dev.ci.yml) but resolves to the same instance
      // repo as its production counterpart, so strip it before matching.
      runtime = runtime.replace(/\.dev$/, '');
      const ddRouter = './engine-private/deploy/dd.router';
      const deployIds = fs.existsSync(ddRouter)
        ? fs
            .readFileSync(ddRouter, 'utf8')
            .split(',')
            .map((s) => s.trim())
            .filter(Boolean)
        : [];
      for (const deployId of deployIds) {
        const confPath = `./engine-private/conf/${deployId}/conf.instances.json`;
        if (!fs.existsSync(confPath)) continue;
        try {
          // readConfInstances returns the bare array of entries. The template
          // entries carry runtime + metadata.repository, so the un-expanded list
          // is enough here (no need to expand variants).
          const match = readConfInstances(deployId).find((i) => i && i.runtime === runtime);
          if (match && match.metadata && match.metadata.repository) {
            logger.info(`[resolveInstanceRepo] resolved from ${confPath}`, {
              runtime,
              repo: match.metadata.repository,
            });
            return match.metadata.repository;
          }
        } catch (err) {
          logger.warn(`[resolveInstanceRepo] failed to parse ${confPath}: ${err.message}`);
        }
      }
      if (ownRuntimeRepo) {
        const runtimeRepo = Underpost.repo.isRemoteRepo(`${process.env.GITHUB_USERNAME}/${runtime}`);
        if (runtimeRepo) {
          logger.info(`[resolveInstanceRepo] resolved from ${process.env.GITHUB_USERNAME}/${runtime}`, {
            runtime,
            repo: `${process.env.GITHUB_USERNAME}/${runtime}`,
          });
          return `${process.env.GITHUB_USERNAME}/${runtime}`;
        }
      }
      return fallback;
    },

    /**
     * Performs a shallow sparse Git checkout of a single subdirectory from any
     * GitHub repository into a local target directory.
     *
     * Uses `--depth 1 --no-checkout` + `git sparse-checkout` so only the
     * requested path is fetched — no full clone of the remote repo.
     * Skips the clone entirely when `<targetDir>/<subPath>` already exists on
     * disk (idempotent).
     *
     * Requires `GITHUB_TOKEN` to be set in the environment for authenticated
     * access to private repositories.
     *
     * @param {string} subPath - The subdirectory path within the remote repo to
     *   check out (e.g. `'conf/dd-prototype'`, `'src/api/payments'`).
     * @param {object} [options]
     * @param {string} [options.repoOwner='underpostnet'] - GitHub organisation or
     *   user that owns the repository.
     * @param {string} [options.repoName='engine-private'] - Name of the
     *   repository on GitHub.
     * @param {string} [options.targetDir='./engine-private'] - Local directory
     *   where the repo will be cloned.
     * @returns {boolean} `true` when the checkout was performed, `false` when it
     *   was skipped because the target path already existed.
     * @memberof UnderpostRepository
     */
    sparseCheckoutDirectory(
      subPath,
      options = { repoOwner: 'underpostnet', repoName: 'engine-private', targetDir: './engine-private' },
    ) {
      const { repoOwner = 'underpostnet', repoName = 'engine-private', targetDir = './engine-private' } = options;
      const localPath = `${targetDir}/${subPath}`;
      if (fs.existsSync(localPath)) {
        logger.info('[sparseCheckoutDirectory] path already present, skipping', localPath);
        return false;
      }
      const authUrl = `https://${process.env.GITHUB_TOKEN}@github.com/${repoOwner}/${repoName}.git`;
      shellExec(`git clone --depth 1 --no-checkout ${authUrl} ${targetDir}`, { disableLog: true });
      shellExec(`cd ${targetDir} && git sparse-checkout set ${subPath} && git checkout`, { disableLog: true });
      logger.info('[sparseCheckoutDirectory] sparse checkout complete', localPath);
      return true;
    },

    /**
     * Ensures a deploy's public source repo (e.g. `engine-prototype`) is present
     * next to the engine and reset to a pristine HEAD, so catalog `sourceMoves`
     * can (re)pull custom sources even after a previous build moved them out of
     * the source tree.
     *
     * Clones `../<repoName>` when missing; otherwise restores a clean checkout
     * (`git checkout .` brings back any moved-out tracked files) and pulls latest.
     * Mirrors the sibling-repo handling used by `syncPrivateConf`.
     *
     * @param {string} repoName - Public source repo name (e.g. `engine-prototype`).
     * @returns {boolean} `true` when the repo is available on disk.
     * @memberof UnderpostRepository
     */
    pullSourceRepo(repoName) {
      const username = process.env.GITHUB_USERNAME;
      if (!username || !repoName) return false;
      const repoPath = `../${repoName}`;
      const gitUri = `${username}/${repoName}`;
      if (!fs.existsSync(repoPath)) {
        shellExec(`cd .. && underpost clone ${gitUri}`, { silent: true });
      } else {
        const repoAbsPath = path.resolve(repoPath);
        shellExec(`git config --global --add safe.directory '${repoAbsPath}'`);
        shellExec(`cd ${repoPath} && git checkout . && git clean -f -d && underpost pull . ${gitUri}`, {
          silent: true,
        });
      }
      return fs.existsSync(repoPath);
    },
  };
}

export default UnderpostRepository;