UNPKG

@haxtheweb/create

Version:

CLI for all things HAX the web

3,411 lines 144 kB
#!/usr/bin/env node
"use strict";

Object.defineProperty(exports, "__esModule", {
  value: true
});
exports.cleanupSiteForPublish = cleanupSiteForPublish;
exports.extractSkeletonPayload = extractSkeletonPayload;
exports.fixLegacyIgnoreFile = fixLegacyIgnoreFile;
exports.installSkeletonFile = installSkeletonFile;
exports.isObjectLike = isObjectLike;
exports.loadSkeletonFileData = loadSkeletonFileData;
exports.normalizeSkeletonMachineName = normalizeSkeletonMachineName;
exports.prepareSiteForStaticPublish = prepareSiteForStaticPublish;
exports.resolveAbsolutePath = resolveAbsolutePath;
exports.restoreSiteAfterStaticPublish = restoreSiteAfterStaticPublish;
exports.siteActions = siteActions;
exports.siteCommandDetected = siteCommandDetected;
exports.siteItemsOptionsList = siteItemsOptionsList;
exports.siteNodeOperations = siteNodeOperations;
exports.siteNodeStatsOperations = siteNodeStatsOperations;
exports.siteProcess = siteProcess;
exports.siteSkeletonList = siteSkeletonList;
exports.siteThemeList = siteThemeList;
var _promises = require("node:timers/promises");
var fs = _interopRequireWildcard(require("node:fs"));
var path = _interopRequireWildcard(require("node:path"));
var os = _interopRequireWildcard(require("node:os"));
var _open = _interopRequireDefault(require("open"));
var p = _interopRequireWildcard(require("@clack/prompts"));
var ejs = _interopRequireWildcard(require("ejs"));
var _picocolors = _interopRequireDefault(require("picocolors"));
var _jsYaml = require("js-yaml");
var winston = _interopRequireWildcard(require("winston"));
var _twig = _interopRequireDefault(require("twig"));
var _nodeHtmlParser = require("node-html-parser");
var _statements = require("../statements.js");
var _utils = require("../utils.js");
var _logging = require("../logging.js");
var _siteSecurity = require("../site-security.js");
var haxcmsLib = _interopRequireWildcard(require("@haxtheweb/haxcms-nodejs/dist/lib/HAXCMS.js"));
var allRoutesLib = _interopRequireWildcard(require("@haxtheweb/haxcms-nodejs/dist/lib/allRoutes.js"));
var josfile = _interopRequireWildcard(require("@haxtheweb/haxcms-nodejs/dist/lib/JSONOutlineSchema.js"));
var safeFetchLib = _interopRequireWildcard(require("@haxtheweb/haxcms-nodejs/dist/lib/safeFetch.js"));
function _interopRequireDefault(e) { return e && e.__esModule ? e : { default: e }; }
function _interopRequireWildcard(e, t) { if ("function" == typeof WeakMap) var r = new WeakMap(), n = new WeakMap(); return (_interopRequireWildcard = function (e, t) { if (!t && e && e.__esModule) return e; var o, i, f = { __proto__: null, default: e }; if (null === e || "object" != typeof e && "function" != typeof e) return f; if (o = t ? n : r) { if (o.has(e)) return o.get(e); o.set(e, f); } for (const t in e) "default" !== t && {}.hasOwnProperty.call(e, t) && ((i = (o = Object.defineProperty) && Object.getOwnPropertyDescriptor(e, t)) && (i.get || i.set) ? o(f, t, i) : f[t] = e[t]); return f; })(e, t); } // Security (Stream-A): the deprecated @haxtheweb/open-apis npm dependency was
// removed; converter calls now route in-process through haxcms-nodejs handlers
// via invokeRoute() (same pattern as download-skeleton below), so no cloud
// (open-apis.hax.cloud) URLs are referenced and the xlsx advisory is gone.
const JSONOutlineSchema = josfile.default;
// Security (H-1/H-2/H-3): reuse haxcms-nodejs' SSRF-guarded fetch wrapper so
// import/content/image fetches reject private/loopback/link-local/metadata
// IPs, cap redirects, and enforce timeouts — matching the server posture.

const safeFetch = safeFetchLib.safeFetch;
const assertUrlNotSSRF = safeFetchLib.assertUrlNotSSRF;
const HAXCMS = haxcmsLib.HAXCMS;
const systemStructureContext = haxcmsLib.systemStructureContext;
var sysSurge = true;
(0, _utils.exec)('surge --version', error => {
  if (error) {
    sysSurge = false;
  }
});
var sysNetlify = true;
(0, _utils.exec)('netlify --version', error => {
  if (error) {
    sysNetlify = false;
  }
});
var sysVercel = true;
(0, _utils.exec)('vercel --version', error => {
  if (error) {
    sysVercel = false;
  }
});
var sysRsync = true;
(0, _utils.exec)('rsync --version', error => {
  if (error) {
    sysRsync = false;
  }
});
const siteRecipeFile = 'create-cli.recipe';
const siteLoggingName = 'cli';
const logLevels = {};
logLevels[siteLoggingName] = 0;
let twigConstantFunctionRegistered = false;
let haxcmsNodejsCli = null;
function ensureTwigConstantFunction() {
  if (twigConstantFunctionRegistered) {
    return;
  }
  twigConstantFunctionRegistered = true;
  try {
    if (_twig.default && typeof _twig.default.extendFunction === 'function') {
      // Security (M-3): previously this read process.env[name] and
      // globalThis[name], which exposed arbitrary secrets/globals to any Twig
      // template rendered in this process. The only template usage is the
      // service-worker.js template's constant('JSON_PRETTY_PRINT'), a PHP
      // constant ported from the PHP HAXcms. Resolve that handful from a
      // static allowlist and return null for anything else — no env/global
      // access.
      _twig.default.extendFunction('constant', function constantLookup(name) {
        if (typeof name !== 'string') {
          return null;
        }
        if (Object.prototype.hasOwnProperty.call(TWIG_PHP_CONSTANTS, name)) {
          return TWIG_PHP_CONSTANTS[name];
        }
        return null;
      });
    }
  } catch (e) {}
}

// Static allowlist of PHP constants the Twig templates reference (ported from
// PHP HAXcms). Values match the standard PHP bitmask values.
const TWIG_PHP_CONSTANTS = {
  JSON_PRETTY_PRINT: 128,
  JSON_HEX_TAG: 1,
  JSON_HEX_AMP: 2,
  JSON_HEX_APOS: 4,
  JSON_HEX_QUOT: 8,
  JSON_FORCE_OBJECT: 16,
  JSON_NUMERIC_CHECK: 32,
  JSON_UNESCAPED_SLASHES: 64,
  JSON_UNESCAPED_UNICODE: 256
};
async function getHaxcmsNodejsCli() {
  if (!haxcmsNodejsCli) {
    haxcmsNodejsCli = await Promise.resolve().then(() => _interopRequireWildcard(require("@haxtheweb/haxcms-nodejs/dist/cli.js")));
  }
  return haxcmsNodejsCli;
}

// fake response class so we can capture the response from the headless route as opposed to print to console
// this way we can handle as data or if use is requesting output format to change we can respond
class Res {
  constructor() {
    this.query = {};
    this.data = null;
    this.statusCode = null;
  }
  send(data) {
    this.data = data;
    return this;
  }
  status(status) {
    this.statusCode = status;
    return this;
  }
  json(data) {
    this.data = JSON.parse(JSON.stringify(data));
    return this;
  }
  sendStatus(status) {
    this.statusCode = status;
    this.data = status;
    return this;
  }
  setHeader() {
    return this;
  }
}

// Security (Stream-A): params added so the site/import/:platform dispatcher
// (replacing the open-apis broker) can receive the platform name in-process.
// Backward-compatible: existing callers omit params, defaulting to {}.
async function invokeRoute(routeHandler, body = {}, query = {}, params = {}) {
  let res = new Res();
  // Site-scoped reads (site:search, site:tags, site:blocks, site:analytics,
  // site:list-files) pass query.siteName; set the auth context so
  // isAnonymousSiteApiRequest sees the CLI as authenticated and hidden/
  // unpublished items are included for the CLI owner. System-route calls
  // (site creation, skeletons, download-skeleton, save-as-template) pass no
  // siteName and never read haxcmsSiteApiAuth, so they are unaffected.
  const siteAuthContext = query.siteName ? {
    siteName: query.siteName,
    authenticated: true,
    securityLevel: 'authenticated-site'
  } : undefined;
  await routeHandler({
    body: body,
    query: query,
    params: params,
    headers: {
      'x-haxcms-user-token': query.user_token || 'fakeToken',
      'x-haxcms-site-token': query.site_token || 'fakeToken'
    },
    haxcmsSiteApiAuth: siteAuthContext
  }, res);
  return res;
}
// Security (Stream-A): map --import-structure names to on-prem haxcms-nodejs
// route handlers (replaces the deprecated @haxtheweb/open-apis broker). Platform
// converters go through the site/import/:platform dispatcher; docx/xlsx use their
// own actions routes. evolutionToSite (custom zip upload) is intentionally absent
// and falls through to the hidden-methodologies branch below.
const IMPORT_STRUCTURE_MAP = {
  haxcmsToSite: {
    platform: 'haxcms'
  },
  pressbooksToSite: {
    platform: 'pressbooks'
  },
  gitbookToSite: {
    platform: 'gitbook'
  },
  notionToSite: {
    platform: 'notion'
  },
  elmslnToSite: {
    platform: 'elmsln'
  },
  ploneToSite: {
    platform: 'plone'
  },
  wordpressPagesToSite: {
    platform: 'wordpress'
  },
  drupalBookToSite: {
    platform: 'drupal-book'
  },
  htmlToSite: {
    platform: 'html'
  },
  docxToSite: {
    routeKey: 'actions/import-docx'
  },
  xlsxToSite: {
    routeKey: 'actions/import-xlsx'
  }
};
function formatStructuredOutput(commandRun, value) {
  if (commandRun.options.format === 'yaml') {
    return (0, _jsYaml.dump)(value);
  }
  if (typeof value === 'string') {
    return value;
  }
  return JSON.stringify(value, null, 2);
}
function logStructuredOutput(commandRun, value, level = 'info') {
  (0, _logging.log)(formatStructuredOutput(commandRun, value), level);
}
function formatErrorForLogging(error) {
  if (error && typeof error.stderr === 'string' && error.stderr.trim().length > 0) {
    return error.stderr.trim();
  }
  if (error && typeof error.stdout === 'string' && error.stdout.trim().length > 0) {
    return error.stdout.trim();
  }
  if (error && typeof error.message === 'string' && error.message.trim().length > 0) {
    return error.message.trim();
  }
  if (typeof error === 'string' && error.trim().length > 0) {
    return error.trim();
  }
  try {
    const serializedError = JSON.stringify(error);
    if (serializedError && serializedError !== '{}') {
      return serializedError;
    }
  } catch (e) {}
  return 'Unknown error';
}

// Run the CLI against itself with an argument array and NO shell. Captures
// stdout/stderr to mirror the previous exec() behavior while removing the
// shell-injection vector. Resolves on exit 0, rejects otherwise.
function runCliNoShell(tokens) {
  return new Promise((resolve, reject) => {
    const createJsPath = process.mainModule.filename;
    const child = (0, _utils.spawn)(process.execPath, [createJsPath, ...tokens], {
      shell: false,
      stdio: ['ignore', 'pipe', 'pipe']
    });
    let stdout = '';
    let stderr = '';
    if (child.stdout) {
      child.stdout.on('data', d => {
        stdout += d;
      });
    }
    if (child.stderr) {
      child.stderr.on('data', d => {
        stderr += d;
      });
    }
    child.on('exit', code => {
      if (code === 0) {
        resolve({
          stdout,
          stderr
        });
      } else {
        const err = new Error(`Recipe command failed with code ${code}`);
        err.stdout = stdout;
        err.stderr = stderr;
        reject(err);
      }
    });
    child.on('error', err => reject(err));
  });
}
function cleanupSiteForPublish(siteDirectory) {
  const brokenSymlinks = [];
  try {
    if (!fs.existsSync(siteDirectory)) {
      return brokenSymlinks;
    }
    // Scan recursively for broken symlinks
    function scanDir(dir) {
      let items = [];
      try {
        items = fs.readdirSync(dir);
      } catch (e) {
        return;
      }
      for (const item of items) {
        const itemPath = path.join(dir, item);
        let lstat;
        try {
          lstat = fs.lstatSync(itemPath);
        } catch (e) {
          continue;
        }
        if (lstat.isSymbolicLink()) {
          try {
            fs.statSync(itemPath);
          } catch (e) {
            // Broken symlink: statSync fails but lstatSync succeeded
            brokenSymlinks.push(itemPath);
            try {
              fs.unlinkSync(itemPath);
            } catch (e2) {
              // Ignore unlink errors
            }
          }
        } else if (lstat.isDirectory() && item !== 'node_modules' && item !== '.git') {
          scanDir(itemPath);
        }
      }
    }
    scanDir(siteDirectory);
  } catch (e) {
    // Silent failure; cleanup is best-effort
  }
  return brokenSymlinks;
}
function fixLegacyIgnoreFile(siteDirectory, ignoreFileName) {
  const ignoreFilePath = path.join(siteDirectory, ignoreFileName);
  if (!fs.existsSync(ignoreFilePath)) {
    return false;
  }
  try {
    let contents = fs.readFileSync(ignoreFilePath, 'utf8');
    // Legacy templates had !node_modules/ which is the wrong syntax for
    // excluding directories. If we find that exact line, replace the whole file.
    if (contents.includes('!node_modules/')) {
      let newContents = '';
      if (ignoreFileName === '.surgeignore') {
        newContents = `node_modules
dist
!custom/build
!build/es6/node_modules
!build/es6/node_modules/**

# Version control
.git/

# IDE files
.vscode/
.idea/

# Local files
.DS_Store
Thumbs.db

# Logs
logs
*.log

# HAX development artifacts
.cache/
.tmp/
`;
      } else if (ignoreFileName === '.netlifyignore') {
        newContents = `node_modules
dist
!custom/build
!build/es6/node_modules
!build/es6/node_modules/**

# Local files
.DS_Store
Thumbs.db

# Logs
logs
*.log

# Development files
.env.local
.env.development.local
.env.test.local
.env.production.local

# IDE files
.vscode/
.idea/

# Version control
.git/

# HAX development artifacts
.cache/
.tmp/
`;
      } else if (ignoreFileName === '.vercelignore') {
        newContents = `node_modules
dist
!custom/build
!build/es6/node_modules
!build/es6/node_modules/**

# Local files
.DS_Store
Thumbs.db

# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*

# Development files
.env.local
.env.development.local
.env.test.local
.env.production.local

# IDE files
.vscode/
.idea/

# Version control
.git/

# Cache directories
.cache/
.tmp/
`;
      }
      if (newContents) {
        fs.writeFileSync(ignoreFilePath, newContents);
        return true;
      }
    }
  } catch (e) {
    // Silent failure; fix is best-effort
  }
  return false;
}
function prepareSiteForStaticPublish(siteDirectory) {
  let prepared = false;
  try {
    if (!fs.existsSync(siteDirectory)) {
      return false;
    }
    const indexPath = path.join(siteDirectory, 'index.html');
    const ghpagesPath = path.join(siteDirectory, 'ghpages.html');
    const backupPath = path.join(siteDirectory, 'index.html.bak');
    // Only prepare if ghpages.html exists and is different from index.html
    if (fs.existsSync(ghpagesPath)) {
      // Backup current index.html if it exists
      if (fs.existsSync(indexPath)) {
        fs.copyFileSync(indexPath, backupPath);
      }
      // Copy ghpages.html to index.html so the host serves it as the entry point
      fs.copyFileSync(ghpagesPath, indexPath);
      prepared = true;
    }
    // Remove local build artifacts that won't exist on a static host
    const buildDir = path.join(siteDirectory, 'build');
    const wcRegistryPath = path.join(siteDirectory, 'wc-registry.json');
    if (fs.existsSync(buildDir)) {
      try {
        fs.rmSync(buildDir, {
          recursive: true,
          force: true
        });
      } catch (e) {
        // best-effort removal
      }
    }
    if (fs.existsSync(wcRegistryPath)) {
      try {
        fs.unlinkSync(wcRegistryPath);
      } catch (e) {
        // best-effort removal
      }
    }
    // Remove babel* files in assets directory
    try {
      const assetsDir = path.join(siteDirectory, 'assets');
      if (fs.existsSync(assetsDir)) {
        const items = fs.readdirSync(assetsDir);
        for (const item of items) {
          if (item.startsWith('babel')) {
            fs.unlinkSync(path.join(assetsDir, item));
          }
        }
      }
    } catch (e) {
      // best-effort removal
    }
  } catch (e) {
    // Silent failure; prep is best-effort
  }
  return prepared;
}
function restoreSiteAfterStaticPublish(siteDirectory) {
  try {
    if (!fs.existsSync(siteDirectory)) {
      return false;
    }
    const indexPath = path.join(siteDirectory, 'index.html');
    const backupPath = path.join(siteDirectory, 'index.html.bak');
    if (fs.existsSync(backupPath)) {
      fs.copyFileSync(backupPath, indexPath);
      fs.unlinkSync(backupPath);
      return true;
    }
  } catch (e) {
    // Silent failure; restore is best-effort
  }
  return false;
}
function siteActions() {
  return [{
    value: 'start',
    label: "Launch site in browser (http://localhost)"
  }, {
    value: 'serve',
    label: "Launch site in development mode"
  }, {
    value: 'node:stats',
    label: "Node Stats / data"
  }, {
    value: 'node:add',
    label: "Add a new page"
  }, {
    value: 'node:edit',
    label: "Edit a page"
  }, {
    value: 'node:delete',
    label: "Delete a page"
  }, {
    value: 'site:stats',
    label: "Site Status / stats"
  }, {
    value: 'site:items',
    label: "Site items"
  }, {
    value: 'site:items-import',
    label: "Import items (JOS / site.json)"
  }, {
    value: 'site:list-files',
    label: "List site files"
  }, {
    value: 'site:search',
    label: "Search site content"
  }, {
    value: 'site:tags',
    label: "List site tags"
  }, {
    value: 'site:blocks',
    label: "List site block usage"
  }, {
    value: 'site:analytics',
    label: "Site analytics metadata"
  }, {
    value: 'site:revisions',
    label: "List or restore item revisions"
  }, {
    value: 'site:export',
    label: "Export site in a format"
  }, {
    value: 'site:theme',
    label: "Change theme"
  }, {
    value: 'site:element',
    label: "Add new Lit component to custom/src"
  }, {
    value: 'site:html',
    label: "Full site as HTML"
  }, {
    value: 'site:md',
    label: "Full site as Markdown"
  }, {
    value: 'site:schema',
    label: "Full site as HAXElementSchema"
  }, {
    value: 'site:skeleton-export',
    label: "Export site as skeleton template"
  }, {
    value: 'site:skeleton-install',
    label: "Install skeleton template"
  }, {
    value: 'site:sync',
    label: "Sync git repo"
  }, {
    value: 'site:rsync',
    label: "Rsync site to remote/local directory"
  }, {
    value: 'site:surge',
    label: "Publish site to Surge.sh"
  }, {
    value: 'site:netlify',
    label: "Publish site to Netlify"
  }, {
    value: 'site:vercel',
    label: "Publish site to Vercel"
  }, {
    value: 'setup:github-actions',
    label: "Setup GitHub Actions deployment"
  }, {
    value: 'setup:gitlab-ci',
    label: "Setup GitLab CI deployment"
  }, {
    value: 'recipe:read',
    label: "Read recipe file"
  }, {
    value: 'recipe:play',
    label: "Play recipe file"
  }, {
    value: 'issue:general',
    label: "Issue: Submit an issue or suggestion"
  }, {
    value: 'issue:theme',
    label: "Issue: Suggest custom theme"
  }];
}
async function siteCommandDetected(commandRun) {
  var activeHaxsite = await systemStructureContext();
  const recipeFileName = path.join(process.cwd(), siteRecipeFile);
  const recipeLogTransport = new winston.transports.File({
    filename: recipeFileName
  });
  const recipe = winston.createLogger({
    levels: logLevels,
    level: siteLoggingName,
    transports: [recipeLogTransport],
    format: winston.format.simple()
  });
  let actionAssigned = false;
  // default to status unless already set so we don't issue a create in a create
  if (!commandRun.arguments.action) {
    actionAssigned = true;
    commandRun.arguments.action = 'site:status';
  }
  commandRun.command = "site";
  if (!commandRun.options.y && commandRun.options.i && !commandRun.options.quiet) {
    p.intro(`${_picocolors.default.bgBlack(_picocolors.default.white(` HAXTheWeb : Site detected `))}`);
    p.intro(`${_picocolors.default.bgBlue(_picocolors.default.white(` Name: ${activeHaxsite.name} `))}`);
  }
  // defaults if nothing set via CLI
  let operation = {
    ...commandRun.arguments,
    ...commandRun.options
  };
  if (!commandRun.options.title) {
    commandRun.options.title = "New Page";
  }
  if (!commandRun.options.domain && commandRun.options.y) {
    commandRun.options.domain = `haxcli-${activeHaxsite.name}.surge.sh`;
  }
  // infinite loop until quitting the cli
  while (operation.action !== 'quit') {
    let actions = siteActions();
    actions.push({
      value: 'quit',
      label: "🚪 Quit"
    });
    if (!operation.action) {
      commandRun = {
        command: null,
        arguments: {},
        options: {}
      };
      // ensures data is updated and stateful per action
      activeHaxsite = await systemStructureContext();
      operation = await p.group({
        action: ({
          results
        }) => p.select({
          message: `Actions you can take`,
          options: actions
        })
      }, {
        onCancel: () => {
          if (!commandRun.options.quiet) {
            p.cancel('🧙 Merlin: Canceling CLI.. HAX ya later 🪄');
            (0, _statements.communityStatement)();
          }
          process.exit(0);
        }
      });
    }
    if (operation.action) {
      p.intro(`hax site ${_picocolors.default.bold(operation.action)}`);
    }
    switch (operation.action) {
      case "site:status": // easy mistype
      case "site:stats":
        const date = new Date(activeHaxsite.manifest.metadata.site.updated * 1000);
        let siteItems = [];
        if (commandRun.options.itemId != null) {
          siteItems = activeHaxsite.manifest.findBranch(commandRun.options.itemId);
        } else {
          siteItems = activeHaxsite.manifest.orderTree(activeHaxsite.manifest.items);
        }
        let els = {};
        for (var i in siteItems) {
          let page = activeHaxsite.loadNode(siteItems[i].id);
          let html = await activeHaxsite.getPageContent(page);
          let dom = (0, _nodeHtmlParser.parse)(`<div id="fullpage">${html}</div>`);
          for (var j in dom.querySelector('#fullpage').childNodes) {
            let node = dom.querySelector('#fullpage').childNodes[j];
            if (node && node.getAttribute) {
              let haxel = await nodeToHaxElement(node, null);
              if (!els[haxel.tag]) {
                els[haxel.tag] = 0;
              }
              els[haxel.tag]++;
            }
          }
        }
        let siteStats = {
          title: activeHaxsite.manifest.title,
          description: activeHaxsite.manifest.description,
          themeName: activeHaxsite.manifest.metadata.theme.name,
          themeElement: activeHaxsite.manifest.metadata.theme.element,
          pageCount: activeHaxsite.manifest.items.length,
          lastUpdated: date.toLocaleDateString("en-US"),
          tagUsage: els
        };
        if (!commandRun.options.format && !commandRun.options.quiet) {
          p.intro(`${_picocolors.default.bgBlue(_picocolors.default.white(` Title: ${siteStats.title} `))}`);
          p.intro(`${_picocolors.default.bgBlue(_picocolors.default.white(` Description: ${siteStats.description} `))}`);
          p.intro(`${_picocolors.default.bgBlue(_picocolors.default.white(` Theme: ${siteStats.themeName} (${siteStats.themeElement})`))}`);
          p.intro(`${_picocolors.default.bgBlue(_picocolors.default.white(` Pages: ${siteStats.pageCount} `))}`);
          p.intro(`${_picocolors.default.bgBlue(_picocolors.default.white(` Last updated: ${siteStats.lastUpdated} `))}`);
          p.intro(`${_picocolors.default.bgBlue(_picocolors.default.white(` Tags used: ${JSON.stringify(siteStats.tagUsage, null, 2)} `))}`);
        } else {
          logStructuredOutput(commandRun, siteStats);
        }
        // simple redirecting to file
        if (commandRun.options.toFile) {
          if (commandRun.options.format === 'yaml') {
            fs.writeFileSync(commandRun.options.toFile, (0, _jsYaml.dump)(siteStats));
          } else {
            fs.writeFileSync(commandRun.options.toFile, JSON.stringify(siteStats, null, 2));
          }
        }
        break;
      case "site:items":
        let siteitems = [];
        if (commandRun.options.itemId != null) {
          siteitems = activeHaxsite.manifest.findBranch(commandRun.options.itemId);
        } else {
          siteitems = activeHaxsite.manifest.orderTree(activeHaxsite.manifest.items);
        }
        for (let i in siteitems) {
          let page = await activeHaxsite.loadNode(siteitems[i].id);
          siteitems[i].content = await activeHaxsite.getPageContent(page);
        }
        // simple redirecting to file if asked for
        if (commandRun.options.toFile) {
          let contents = '';
          if (commandRun.options.format === 'yaml') {
            contents = (0, _jsYaml.dump)(siteitems);
          } else {
            contents = JSON.stringify(siteitems, null, 2);
          }
          fs.writeFileSync(commandRun.options.toFile, contents);
        } else {
          logStructuredOutput(commandRun, siteitems);
        }
        break;
      case "site:items-import":
        // need source, then resolve what it is
        if (commandRun.options.itemsImport) {
          let location = commandRun.options.itemsImport;
          let josImport = new JSONOutlineSchema();
          var itemsImport = [];
          // support for address, as in import from some place else
          if (location.startsWith('https://') || location.startsWith('http://')) {
            if (location.endsWith('/site.json')) {
              location = location.replace('/site.json', '');
            } else if (!location.endsWith('/')) {
              location = location + '/';
            }
            // Security (H-1): fetch the remote site.json through safeFetch so
            // loopback/private/metadata IPs are rejected before the request.
            let f;
            try {
              const resp = await safeFetch(`${location}site.json`);
              f = resp.ok ? await resp.json() : null;
            } catch (e) {
              if ((0, _siteSecurity.isSSRFError)(e)) {
                (0, _logging.log)(`Import URL rejected as SSRF target: ${location}site.json (${e.message})`, 'error');
              } else {
                (0, _logging.log)(formatErrorForLogging(e), 'error');
              }
              f = null;
            }
            if (f && f.items) {
              josImport.items = f.items;
            } else {
              // invalid data
              process.exit(0);
            }
          }
          // look on prem
          else if (fs.existsSync(location)) {
            // Security (L-1): canonicalize + reject null bytes for local paths.
            const localPath = (0, _siteSecurity.resolveLocalPath)(location);
            let fileContents = await fs.readFileSync(localPath);
            if (location.endsWith('.json')) {
              josImport.items = JSON.parse(fileContents);
            } else if (location.endsWith('.yaml')) {
              josImport.items = await (0, _jsYaml.load)(fileContents);
            }
          }
          // allows for filtering
          if (commandRun.options.itemId) {
            itemsImport = josImport.findBranch(commandRun.options.itemId);
          } else {
            itemsImport = josImport.items;
          }
          for (let i in josImport.items) {
            if (josImport.items[i].location && !josImport.items[i].content) {
              // Security (H-1): resolve each remote-controlled item location
              // against the import base via new URL() (so an absolute/
              // protocol-relative location can't bypass validation) and fetch
              // through safeFetch to block SSRF targets.
              try {
                const itemUrl = new URL(josImport.items[i].location, location).toString();
                const resp = await safeFetch(itemUrl);
                josImport.items[i].content = resp.ok ? await resp.text() : '';
              } catch (e) {
                if ((0, _siteSecurity.isSSRFError)(e)) {
                  (0, _logging.log)(`Import item URL rejected as SSRF target: ${josImport.items[i].location} (${e.message})`, 'error');
                } else {
                  (0, _logging.log)(formatErrorForLogging(e), 'error');
                }
                josImport.items[i].content = '';
              }
            }
          }
          let itemIdMap = {};
          for (let i in josImport.items) {
            // if we have a parent set by force to append this structure to
            // then see if parent = null (implying top level in full site import)
            // or match on itemId to imply that it's the top (no matter parent status)
            if (commandRun.options.parentId) {
              if (josImport.items[i].parent === null || josImport.items[i].id === commandRun.options.itemId) {
                josImport.items[i].parent = commandRun.options.parentId;
              }
            }
            // see if map has an entry that is already set
            if (itemIdMap[josImport.items[i].parent]) {
              // remaps the parent of this item bc the thing imported has changed ID
              josImport.items[i].parent = itemIdMap[josImport.items[i].parent];
            }
            let tmpAddedItem = await activeHaxsite.addPage(josImport.items[i].parent, josImport.items[i].title, 'html', josImport.items[i].slug, null, josImport.items[i].indent,
            // Security (H-5): sanitize remote item content before storing.
            (0, _siteSecurity.sanitizeIfString)(josImport.items[i].content), josImport.items[i].order, josImport.items[i].metadata);
            // set in the map for future translations
            itemIdMap[josImport.items[i].id] = tmpAddedItem.id;
          }
          if (!commandRun.options.quiet) {
            (0, _logging.log)(`${josImport.items.length} nodes imported`);
          }
          recipe.log(siteLoggingName, (0, _logging.commandString)(commandRun));
        } else if (!commandRun.options.quiet) {
          (0, _logging.log)('Must specify --items-import as path to valid item export file or URL', 'error');
        }
        break;
      case "start":
        try {
          const port = await (0, _utils.findAvailablePort)();
          if (!commandRun.options.quiet) {
            p.intro(`Starting server.. `);
            p.note(`🚀 Server running at: ${_picocolors.default.underline(_picocolors.default.cyan(`http://localhost:${port}`))}
⌨️  To stop server, press: ${_picocolors.default.bold(_picocolors.default.black(_picocolors.default.bgRed(` CTRL + C or CTRL + BREAK `)))}`);
          }
          // Security (M-4): HAXCMS_DISABLE_JWT_CHECKS is scoped to local
          // dev only. HOST=127.0.0.1 is a forward-compatible hint so the
          // server binds loopback once haxcms-nodejs honors it (today it
          // calls server.listen(port) with no host, binding all interfaces;
          // loopback enforcement requires a server-side change).
          await (0, _utils.exec)(`npx @haxtheweb/haxcms-nodejs`, {
            cwd: activeHaxsite.directory,
            env: {
              ...process.env,
              PORT: `${port}`,
              HOST: '127.0.0.1',
              HAXCMS_DISABLE_JWT_CHECKS: 'true'
            }
          });
        } catch (e) {
          (0, _logging.log)(formatErrorForLogging(e), 'error');
        }
        break;
      case "serve":
        try {
          const port = await (0, _utils.findAvailablePort)();
          if (!commandRun.options.quiet) {
            p.intro(`Starting server in development mode.. `);
            p.note(`🚀 Server running at: ${_picocolors.default.underline(_picocolors.default.cyan(`http://localhost:${port}`))}
💻 Site will live reload on changes to ${_picocolors.default.bold('custom/src')}
⌨️  To stop server, press: ${_picocolors.default.bold(_picocolors.default.black(_picocolors.default.bgRed(` CTRL + C or CTRL + BREAK `)))}`);
          }
          // Security (M-4): same HOST=127.0.0.1 hint + scoped JWT-disable as start.
          await (0, _utils.exec)(`npx @haxtheweb/haxcms-nodejs`, {
            cwd: activeHaxsite.directory,
            env: {
              ...process.env,
              PORT: `${port}`,
              HOST: '127.0.0.1',
              HAXCMS_DISABLE_JWT_CHECKS: 'true',
              NODE_ENV: 'development'
            }
          });
        } catch (e) {
          (0, _logging.log)(formatErrorForLogging(e), 'error');
        }
        break;
      case "node:status": // easy mistype
      case "node:stats":
        try {
          if (!commandRun.options.itemId) {
            commandRun.options.itemId = await p.select({
              message: `Select an item to edit`,
              required: true,
              options: [{
                value: null,
                label: "-- edit nothing, exit --"
              }, ...(await siteItemsOptionsList(activeHaxsite))]
            });
          }
          if (commandRun.options.itemId) {
            let nodeOps = siteNodeStatsOperations();
            let page = activeHaxsite.loadNode(commandRun.options.itemId);
            // select which aspect of this we are editing
            if (!commandRun.options.nodeOp) {
              commandRun.options.nodeOp = await p.select({
                message: `${page.title} (${page.id}) - Node operations`,
                required: true,
                options: [{
                  value: null,
                  label: "-- Exit --"
                }, ...nodeOps]
              });
            }
            if (commandRun.options.nodeOp && siteNodeStatsOperations(commandRun.options.nodeOp)) {
              switch (commandRun.options.nodeOp) {
                case 'details':
                  logStructuredOutput(commandRun, page);
                  // simple redirecting to file
                  if (commandRun.options.toFile) {
                    if (commandRun.options.format === 'yaml') {
                      fs.writeFileSync(commandRun.options.toFile, (0, _jsYaml.dump)(page));
                    } else {
                      fs.writeFileSync(commandRun.options.toFile, JSON.stringify(page, null, 2));
                    }
                  }
                  break;
                case 'html':
                  let itemHTML = await activeHaxsite.getPageContent(page);
                  // simple redirecting to file
                  if (commandRun.options.toFile) {
                    fs.writeFileSync(commandRun.options.toFile, itemHTML);
                  } else {
                    (0, _logging.log)(itemHTML);
                  }
                  break;
                case 'schema':
                  // next up
                  let html = await activeHaxsite.getPageContent(page);
                  let dom = (0, _nodeHtmlParser.parse)(`<div id="fullpage">${html}</div>`);
                  let els = [];
                  for (var i in dom.querySelector('#fullpage').childNodes) {
                    let node = dom.querySelector('#fullpage').childNodes[i];
                    if (node && node.getAttribute) {
                      els.push(await nodeToHaxElement(node, null));
                    }
                  }
                  // simple redirecting to file
                  if (commandRun.options.toFile) {
                    if (commandRun.options.format === 'yaml') {
                      fs.writeFileSync(commandRun.options.toFile, (0, _jsYaml.dump)(els));
                    } else {
                      fs.writeFileSync(commandRun.options.toFile, JSON.stringify(els, null, 2));
                    }
                  } else {
                    logStructuredOutput(commandRun, els);
                  }
                  break;
                case 'md':
                  // Security (Stream-A): on-prem haxcms-nodejs handler in-process
                  // (replaces @haxtheweb/open-apis broker); result string now at
                  // .data.data.contents (haxcms-nodejs response envelope).
                  let resp = await invokeRoute(allRoutesLib.allRoutes.system.map.post['actions/html-to-md'], {
                    html: await activeHaxsite.getPageContent(page)
                  });
                  let mdContent = resp && resp.data && resp.data.data ? resp.data.data.contents : '';
                  // simple redirecting to file
                  if (commandRun.options.toFile) {
                    fs.writeFileSync(commandRun.options.toFile, mdContent);
                  } else {
                    (0, _logging.log)(mdContent);
                  }
                  break;
              }
            }
          }
        } catch (e) {
          (0, _logging.log)(formatErrorForLogging(e), 'error');
        }
        break;
      case "node:add":
        try {
          if (!commandRun.options.title) {
            commandRun.options.title = await p.text({
              message: `Title for this page`,
              placeholder: "New page",
              initialValue: "New page",
              required: true,
              validate: value => {
                if (!value) {
                  return "Title must be set (Enter accepts default)";
                }
              }
            });
          }
          var createNodeBody = {
            site: activeHaxsite,
            node: {
              title: commandRun.options.title
            }
          };
          if (commandRun.options.parent && commandRun.options.parent !== '') {
            createNodeBody.parent = commandRun.options.parent;
          }
          if (commandRun.options.order && !Number.isNaN(parseInt(commandRun.options.order))) {
            createNodeBody.order = parseInt(commandRun.options.order);
          }
          if (commandRun.options.slug && commandRun.options.slug !== '') {
            createNodeBody.node.location = commandRun.options.slug;
          }
          if (commandRun.options.description && commandRun.options.description !== '') {
            createNodeBody.description = commandRun.options.description;
          }
          if (commandRun.options.tags && commandRun.options.tags !== '') {
            createNodeBody.metadata = {
              tags: commandRun.options.tags
            };
          }
          // this would be odd but could be direct with no format specified
          if (commandRun.options.content && !commandRun.options.format) {
            // only API where it's called contents and already out there {facepalm}
            // but user already has commands where it's --content as arg
            createNodeBody.node.contents = commandRun.options.content;
          } else if (commandRun.options.content && commandRun.options.format) {
            let locationContent = '';
            // if we have format set, then  we need to interpret content as a url
            let location = commandRun.options.content;
            // support for address, as in import from some place else
            if (location.startsWith('https://') || location.startsWith('http://')) {
              // Security (H-2): route through safeFetch to block SSRF targets
              // (private/loopback/link-local/metadata IPs) and cap redirects.
              const resp = await safeFetch(location);
              locationContent = resp.ok ? await resp.text() : '';
            }
            // look on prem
            else if (fs.existsSync(location)) {
              // Security (L-1): canonicalize + reject null bytes for local paths.
              locationContent = await fs.readFileSync((0, _siteSecurity.resolveLocalPath)(location));
            }
            // format dictates additional processing; html is default
            switch (commandRun.options.format) {
              case 'json':
                locationContent = JSON.parse(locationContent);
                break;
              case 'yaml':
                locationContent = await (0, _jsYaml.load)(locationContent);
                break;
              case 'md':
                // Security (Stream-A): on-prem haxcms-nodejs handler in-process
                // (replaces @haxtheweb/open-apis broker); raw mode dropped —
                // haxcms-nodejs returns HTML at .data.data.contents.
                let resp = await invokeRoute(allRoutesLib.allRoutes.system.map.post['actions/md-to-html'], {
                  md: locationContent
                });
                if (resp.data && resp.data.data && resp.data.data.contents) {
                  locationContent = resp.data.data.contents;
                }
                break;
            }
            // support for scraper mode to find title from the content responsee
            if (commandRun.options.titleScrape) {
              let dom = (0, _nodeHtmlParser.parse)(`${locationContent}`);
              createNodeBody.node.title = dom.querySelector(`${commandRun.options.titleScrape}`).textContent;
            }
            // support scraper mode which targets a wrapper for the actual content
            if (commandRun.options.contentScrape) {
              let dom = (0, _nodeHtmlParser.parse)(`${locationContent}`);
              locationContent = dom.querySelector(`${commandRun.options.contentScrape}`).innerHTML;
            }
            // Security (H-5): sanitize remote/scraped HTML before storing.
            // Objects (json/yaml format) pass through sanitizeIfString unchanged.
            createNodeBody.node.contents = (0, _siteSecurity.sanitizeIfString)(locationContent);
          }
          const cliBridge = await getHaxcmsNodejsCli();
          let resp = await cliBridge.cliBridge('v1/items', createNodeBody, 'post');
          recipe.log(siteLoggingName, (0, _logging.commandString)(commandRun));
          if (commandRun.options.v) {
            (0, _logging.log)(resp.res.data, 'silly');
          }
          if (!commandRun.options.quiet) {
            (0, _logging.log)(`"${createNodeBody.node.title}" added to site`, 'info', createNodeBody.node);
          }
        } catch (e) {
          (0, _logging.log)(formatErrorForLogging(e), 'error');
        }
        break;
      case "node:edit":
        try {
          if (!commandRun.options.itemId) {
            commandRun.options.itemId = await p.select({
              message: `Select an item to edit`,
              required: true,
              options: [{
                value: null,
                label: "-- edit nothing, exit --"
              }, ...(await siteItemsOptionsList(activeHaxsite))]
            });
          }
          if (commandRun.options.itemId) {
            let nodeOps = siteNodeOperations();
            let page = activeHaxsite.loadNode(commandRun.options.itemId);
            // select which aspect of this we are editing
            if (!commandRun.options.nodeOp) {
              commandRun.options.nodeOp = await p.select({
                message: `${page.title} (${page.id}) - Node operations`,
                required: true,
                options: [{
                  value: null,
                  label: "-- Exit --"
                }, ...nodeOps]
              });
            }
            if (commandRun.options.nodeOp && siteNodeOperations(commandRun.options.nodeOp)) {
              let nodeProp = commandRun.options.nodeOp;
              var propValue = commandRun.options[nodeProp];
              // verify we have a setting for the operation requested
              // otherwise we get interactive again
              if (!commandRun.options[nodeProp]) {
                let val = page[nodeProp];
                if (['tags', 'published', 'hideInMenu', 'theme'].includes(nodeProp)) {
                  val = page.metadata[nodeProp];
                } else if (nodeProp === 'content') {
                  val = await activeHaxsite.getPageContent(page);
                }
                //  boolean is confirm
                if (['published', 'hideInMenu'].includes(nodeProp)) {
                  propValue = await p.confirm({
                    message: `${nodeProp}:`,
                    initialValue: Boolean(val),
                    defaultValue: Boolean(val)
                  });
                }
                // these have fixed possible values
                else if (['parent', 'theme'].includes(nodeProp)) {
                  let l = nodeProp === 'parent' ? "-- no parent --" : "-- no theme --";
                  let list = nodeProp === 'parent' ? await siteItemsOptionsList(activeHaxsite, page.id) : await siteThemeList(true);
                  propValue = await p.select({
                    message: `${nodeProp}:`,
                    defaultValue: val,
                    initialValue: val,
                    options: [{
                      value: null,
                      label: l
                    }, ...list]
                  });
                } else {
                  propValue = await p.text({
                    message: `${nodeProp}:`,
                    initialValue: val,
                    defaultValue: val
                  });
                }
              }
              if (nodeProp === 'order') {
                propValue = parseInt(propValue);
              }
              // account for CLI
              if (propValue === "null") {
                propValue = null;
              }
              commandRun.options[nodeProp] = propValue;
            }
            // ensure we set empty values, just not completely undefined values
            if (typeof commandRun.options[commandRun.options.nodeOp] !== "undefined") {
              if (commandRun.options.nodeOp === 'content') {
                let locationContent = '';
                // this would be odd but could be direct with no format specified
                if (commandRun.options.content && !commandRun.options.format) {
                  locationContent = commandRun.options.content;
                }
                // this implies what we were given needs processing as a file / url
                else if (commandRun.options.content && commandRun.options.format) {
                  // if we have format set, then  we need to interpret content as a url
                  let location = commandRun.options.content;
                  // support for address, as in import from some place else
                  if (location.startsWith('https://') || location.startsWith('http://')) {
                    // Security (H-2): route through safeFetch to block SSRF targets
                    // (private/loopback/link-local/metadata IPs) and cap redirects.
                    const resp = await safeFetch(location);
                    locationContent = resp.ok ? await resp.text() : '';
                  }
                  // look on prem
                  else if (fs.existsSync(location)) {
                    // Security (L-1): canonicalize + reject null bytes for local paths.
                    locationContent = await fs.readFileSync((0, _siteSecurity.resolveLocalPath)(location));
                  }
                  // format dictates additional processing; html is default
                  switch (commandRun.options.format) {
                    case 'json':
                      locationContent = JSON.parse(locationContent);
                      break;
                    case 'yaml':
                      locationContent = await (0, _jsYaml.load)(locationContent);
                      break;
                    case 'md':
                      // Security (Stream-A): on-prem haxcms-nodejs handler in-process
                      // (replaces @haxtheweb/open-apis broker); raw mode dropped —
                      // haxcms-nodejs returns HTML at .data.data.contents.
                      let resp = await invokeRoute(allRoutesLib.allRoutes.system.map.post['actions/md-to-html'], {
                        md: locationContent
                      });
                      if (resp.data && resp.data.data && resp.data.data.contents) {
                        locationContent = resp.data.data.contents;
                      }
                      break;
                  }
                  // support scraper mode which targets a wrapper for the actual content
                  if (commandRun.options.contentScrape) {
                    let dom = (0, _nodeHtmlParser.parse)(`${locationContent}`);
                    locationContent = dom.querySelector(`${commandRun.options.contentScrape}`).innerHTML;
                  }
                }
                // Security (H-5): sanitize remote/scraped HTML before writing.
                // Objects (json/yaml format) pass through sanitizeIfString unchanged.
                const safeContent = (0, _siteSecurity.sanitizeIfString)(locationContent);
                // if we have content (meaning it's not blank) then try to write the page location                    
                if (safeContent && (await page.writeLocation(safeContent))) {
                  recipe.log(siteLoggingName, (0, _logging.commandString)(commandRun));
                  if (!commandRun.options.quiet) {
                    (0, _logging.log)(`node:edit success updated page content: "${page.id}`);
                  }
                } else {
                  console.warn(`node:edit failure to write page content : ${page.id}`);
                }
              } else {
                if (['tags', 'published', 'hideInMenu'].includes(commandRun.options.nodeOp)) {
                  page.metadata[commandRun.options.nodeOp] = commandRun.options[commandRun.options.nodeOp];
                } else if (commandRun.options.nodeOp === 'theme') {
                  let themes = await HAXCMS.getThemes();
                  page.metadata.theme = themes[commandRun.options[commandRun.options.nodeOp]];
                } else {
                  page[commandRun.options.nodeOp] = commandRun.options[commandRun.options.nodeOp];
                }
                let resp = await activeHaxsite.updateNode(page);
                recipe.log(siteLoggingName, (0, _logging.commandString)(commandRun));
                if (commandRun.options.v) {
                  (0, _logging.log)(resp, 'silly');
                }
              }
            }
          }
        } catch (e) {
          (0, _logging.log)(formatErrorForLogging(e), 'error');
        }
        break;
      case "node:delete":
        try {
          if (!commandRun.options.itemId) {
            commandRun.options.itemId = await p.select({
              message: `Select an item to delete`,
              required: true,
              options: [{
                value: null,
                label: "-- Delete nothing, exit --"
              }, ...(await siteItemsOptionsList(activeHaxsite))]
            });
          }
          if (commandRun.options.itemId) {
            let del = false;
            if (!commandRun.options.y) {
              del = await p.confirm({
                message: `Are you sure you want to delete ${commandRun.options.itemId}? (This cannot be undone)`,
                initialValue: true
              });
            } else {
              del = true;
            }
            // extra confirmation given destructive operation
            if (del) {
              const cliBridge = await getHaxcmsNodejsCli();
              let resp = await cliBridge.cliBridge('v1/items/' + commandRun.options.itemId, {
                site: activeHaxsite,
                node: {
                  id: commandRun.options.itemId
                }
              }, 'delete');
              // D1 error envelope: failures are {status:4xx/5xx,data:{message}}
              // objects, never the bare number 500. Match the site:export pattern.
              if (resp.res.data && resp.res.data.status >= 400) {
                console.warn(`node:delete failed "${commandRun.options.itemId} not found`);
              } else {
                recipe.log(siteLoggingName, (0, _logging.commandString)(commandRun));
                (0, _logging.log)(`"${commandRun.options.itemId}" deleted`);
              }
            } else {
              (0, _logging.log)(`Delete operation canceled`);
            }
          }
        } catch (e) {
          (0, _logging.log)(formatErrorForLogging(e), 'error');
        }
        break;
      case "site:skeleton-export":
        try {
          let skeletonResponse = await invokeRoute(allRoutesLib.allRoutes.system.map.post['sites/:siteName/download-skeleton'], {
            site: {
              name: activeHaxsite.name
            }
          }, {
            user_token: 'fakeToken'
          });
          if (!skeletonResponse || !skeletonResponse.data || skeletonResponse.data.status !== 200 || !skeletonResponse.data.data || !skeletonResponse.data.data.skeleton) {
            throw new Error('Failed to export skeleton for this site');
          }
          const skeletonData = skeletonResponse.data.data.skeleton;
          const fileName = skeletonResponse.data.data.filename ? skeletonResponse.data.data.filename : `${normalizeSkeletonMachineName(activeHaxsite.name)}.json`;
          let targetFilePath = commandRun.options.toFile ? commandRun.options.toFile : fileName;
          targetFilePath = resolveAbsolutePath(targetFilePath);
          fs.writeFileSync(targetFilePath, `${JSON.stringify(skeletonData, null, 2)}\n`);
          recipe.log(siteLoggingName, (0, _logging.commandString)(commandRun));
          if (!commandRun.options.quiet) {
            p.outro(`${_picocolors.default.green('✓')} Skeleton exported to ${targetFilePath}`);
          } else {
            logStructuredOutput(commandRun, {
              file: targetFilePath
            });
          }
        } catch (e) {
          (0, _logging.log)(`Skeleton export failed: ${e.message}`, 'error');
          if (!commandRun.options.quiet) {
            p.outro(`${_picocolors.default.red('✗')} ${e.message}`);
          }
        }
        break;
      case "site:skeleton-install":
        try {
          if (commandRun.options.skeletonFile) {
            const installData = installSkeletonFile(commandRun.options.skeletonFile, commandRun.options.skeletonMachineName);
            recipe.log(siteLoggingName, (0, _logging.commandString)(commandRun));
            if (!commandRun.options.quiet) {
              p.outro(`${_picocolors.default.green('✓')} Template installed as ${installData.machineName} (${installData.installPath})`);
            } else {
              logStructuredOutput(commandRun, installData);
            }
          } else {
            let saveResponse = await invokeRoute(allRoutesLib.allRoutes.system.map.post['sites/:siteName/save-as-template'], {
              site: {
                name: activeHaxsite.name
              }
            }, {
              user_token: 'fakeToken'
            });
            if (!saveResponse || !saveResponse.data || saveResponse.data.status !== 200 || !saveResponse.data.data) {
              throw new Error('Failed to save current site as skeleton template');
            }
            const installData = saveResponse.data.data;
            recipe.log(siteLoggingName, (0, _logging.commandString)(commandRun));
            if (!commandRun.options.quiet) {
              p.outro(`${_picocolors.default.green('✓')} Template installed as ${installData.name}`);
            } else {
              logStructuredOutput(commandRun, installData);
            }
          }
        } catch (e) {
          (0, _logging.log)(`Skeleton install failed: ${e.message}`, 'error');
          if (!commandRun.options.quiet) {
            p.outro(`${_picocolors.default.red('✗')} ${e.message}`);
          }
        }
        break;
      case "site:sync":
        // @todo git sync might need other arguments / be combined with publishing
        try {
          await (0, _utils.exec)(`cd ${activeHaxsite.directory} && git pull && git push`);
        } catch (e) {
          (0, _logging.log)(formatErrorForLogging(e), 'error');
        }
        break;
      case "site:rsync":
        try {
          if (!sysRsync) {
            if (!commandRun.options.quiet) {
              p.intro(`${_picocolors.default.bgRed(_picocolors.default.white(` ERROR: rsync not found `))}`);
              p.outro(`${_picocolors.default.red('rsync is required but not installed on this system.')}`);
              p.outro(`${_picocolors.default.yellow('Install rsync:')}`);
              p.outro(`${_picocolors.default.gray('  Ubuntu/Debian: sudo apt install rsync')}`);
              p.outro(`${_picocolors.default.gray('  macOS: brew install rsync')}`);
              p.outro(`${_picocolors.default.gray('  CentOS/RHEL: sudo yum install rsync')}`);
            }
            break;
          }
          let source = commandRun.options.source || activeHaxsite.directory;
          let destination = commandRun.options.destination;
          let excludePatterns = commandRun.options.exclude ? commandRun.options.exclude.split(',').map(p => p.trim()) : ['node_modules', '.git', '.DS_Store', 'dist', 'build'];
          let dryRun = commandRun.options.dryRun || false;

          // Interactive prompts if not provided via CLI
          if (!commandRun.options.y && !destination) {
            let action = await p.select({
              message: 'Rsync action:',
              options: [{
                value: 'to-remote',
                label: 'Sync site to remote server'
              }, {
                value: 'to-local',
                label: 'Sync site to local directory'
              }, {
                value: 'from-remote',
                label: 'Sync from remote server to site'
              }, {
                value: 'test',
                label: 'Test sync (dry run)'
              }]
            });
            if (action === 'test') {
              dryRun = true;
            }
            if (action === 'from-remote') {
              source = await p.text({
                message: 'Source (user@host:/path):',
                placeholder: 'user@example.com:/var/www/html',
                validate: value => {
                  if (!value) return 'Source is required';
                }
              });
              destination = activeHaxsite.directory;
            } else {
              destination = await p.text({
                message: action === 'to-remote' ? 'Destination (user@host:/path):' : 'Destination directory:',
                placeholder: action === 'to-remote' ? 'user@example.com:/var/www/html' : '/backup/location',
                validate: value => {
                  if (!value) return 'Destination is required';
                }
              });
            }
            let excludeInput = await p.text({
              message: 'Exclude patterns (comma-separated):',
              placeholder: 'node_modules,.git,.DS_Store,dist,build',
              initialValue: 'node_modules,.git,.DS_Store,dist,build'
            });
            if (excludeInput) {
              excludePatterns = excludeInput.split(',').map(p => p.trim());
            }
            if (!dryRun && action !== 'test') {
              dryRun = await p.confirm({
                message: 'Perform dry run first?',
                initialValue: true
              });
            }
          }
          if (!destination) {
            if (!commandRun.options.quiet) {
              p.intro(`${_picocolors.default.bgRed(_picocolors.default.white(` ERROR: destination required `))}`);
            }
            break;
          }

          // Build rsync command
          let rsyncArgs = ['-avz',
          // archive, verbose, compress
          '--progress',
          // show progress
          '--stats' // show stats
          ];

          // Add dry run flag if requested
          if (dryRun) {
            rsyncArgs.push('--dry-run');
          }

          // Add exclude patterns
          excludePatterns.forEach(pattern => {
            rsyncArgs.push('--exclude', pattern);
          });

          // Add delete flag to mirror source (be careful with this)
          if (commandRun.options.delete) {
            rsyncArgs.push('--delete');
          }

          // Add source and destination
          // Ensure source ends with / for directory contents
          if (!source.endsWith('/') && fs.lstatSync(source).isDirectory()) {
            source += '/';
          }
          rsyncArgs.push(source, destination);
          if (!commandRun.options.quiet) {
            p.intro(`${dryRun ? _picocolors.default.yellow('🧪 Dry run: ') : _picocolors.default.green('🚀 Running: ')}rsync ${rsyncArgs.join(' ')}`);
          }
          if (commandRun.options.i && !commandRun.options.quiet) {
            // Interactive execution for real-time progress
            await (0, _utils.interactiveExec)('rsync', rsyncArgs);
          } else {
            // Silent execution
            const result = await (0, _utils.exec)(`rsync ${rsyncArgs.join(' ')}`);
            if (!commandRun.options.quiet && result.stdout) {
              console.log(result.stdout);
            }
            if (result.stderr) {
              console.error(result.stderr);
            }
          }
          recipe.log(siteLoggingName, (0, _logging.commandString)(commandRun));
          if (!commandRun.options.quiet) {
            p.outro(`${_picocolors.default.green('✓')} ${dryRun ? 'Dry run completed' : 'Rsync completed successfully'}`);
          }
        } catch (e) {
          (0, _logging.log)(`Rsync error: ${e.message}`, 'error');
          if (!commandRun.options.quiet) {
            p.intro(`${_picocolors.default.bgRed(_picocolors.default.white(` Rsync Error `))}`);
            p.outro(`${_picocolors.default.red('✗')} ${e.message}`);
          }
        }
        break;
      case "site:theme":
        try {
          //theme
          activeHaxsite = await systemStructureContext();
          let list = await siteThemeList(true, activeHaxsite.directory);
          let val = activeHaxsite.manifest.metadata.theme.element;
          if (!commandRun.options.theme) {
            commandRun.options.theme = await p.select({
              message: `Select theme:`,
              defaultValue: val,
              initialValue: val,
              options: list
            });
          }
          if (commandRun.options.theme === "custom-theme") {
            if (!commandRun.options.customThemeName) {
              commandRun.options.customThemeName = await p.text({
                message: 'Theme Name:',
                placeholder: `custom-${activeHaxsite.name}-theme`,
                initialValue: `custom-${activeHaxsite.name}-theme`,
                required: false,
                validate: value => {
                  if (!value) {
                    return "Theme name is required (Enter accepts default)";
                  }
                  if (list.some(theme => theme.value === value)) {
                    return "Theme name is already in use";
                  }
                  if (/^\d/.test(value)) {
                    return "Theme name cannot start with a number";
                  }
                  if (/[A-Z]/.test(value)) {
                    return "No uppercase letters allowed in theme name";
                  }
                  if (value.indexOf(' ') !== -1) {
                    return "No spaces allowed in theme name";
                  }
                }
              });
            }
            if (!commandRun.options.customThemeTemplate) {
              const options = [{
                value: 'base',
                label: 'Vanilla Theme with Hearty Documentation'
              }, {
                value: 'polaris-flex',
                label: 'Minimalist Theme with Horizontal Nav'
              }, {
                value: 'polaris-sidebar',
                label: 'Content-Focused Theme with Flexible Sidebar'
              }];
              commandRun.options.customThemeTemplate = await p.select({
                message: 'Template:',
                required: false,
                options: options,
                initialValue: options[0]
              });
            }
          }
          let themes = await HAXCMS.getThemes();
          if (themes && commandRun.options.theme) {
            if (themes[commandRun.options.theme]) {
              activeHaxsite.manifest.metadata.theme = themes[commandRun.options.theme];
              activeHaxsite.manifest.save(false);
              recipe.log(siteLoggingName, (0, _logging.commandString)(commandRun));
            } else if (commandRun.options.theme === "custom-theme") {
              commandRun.options.name = activeHaxsite.name;
              commandRun.options.directory = activeHaxsite.directory;
              // temporary for proof of concept
              commandRun.options.npmClient = 'npm';
              await customSiteTheme(commandRun, {});
            } else if (!themes[commandRun.options.theme]) {
              let themeObj = {
                element: commandRun.options.theme,
                path: "./custom/build/custom.es6.js",
                name: (0, _utils.dashToCamel)(commandRun.options.theme)
              };
              activeHaxsite.manifest.metadata.theme = themeObj;
              activeHaxsite.manifest.save(false);
            }
          }
        } catch (e) {
          (0, _logging.log)(formatErrorForLogging(e), 'error');
        }
        break;
      case "site:element":
        try {
          const reservedNames = ["annotation-xml", "color-profile", "font-face", "font-face-src", "font-face-uri", "font-face-format", "font-face-name", "missing-glyph"];
          activeHaxsite = await systemStructureContext();
          if (!commandRun.options.name) {
            commandRun.options.name = await p.text({
              message: 'Component name:',
              placeholder: 'my-component',
              initialValue: 'my-component',
              required: true,
              validate: value => {
                if (!value) {
                  return "Name is required (Enter accepts default)";
                }
                if (reservedNames.includes(value)) {
                  return `Reserved name ${_picocolors.default.bold(value)} cannot be used`;
                }
                if (value.toLocaleLowerCase() !== value) {
                  return "Name must be lowercase";
                }
                if (/^\d/.test(value)) {
                  return "Name cannot start with a number";
                }
                if (/[`~!@#$%^&*()_=+\[\]{}|;:\'",<.>\/?\\]/.test(value)) {
                  return "No special characters allowed in name";
                }
                if (value.indexOf(' ') !== -1) {
                  return "No spaces allowed in name";
                }
                if (value.indexOf('-') === -1 || value.replace('--', '') !== value || value[0] === '-' || value[value.length - 1] === '-') {
                  return "Name must include at least one `-` and must not start or end name.";
                }
                // Check for any other syntax errors
                if (!/^[a-z][a-z0-9.\-]*\-[a-z0-9.\-]*$/.test(value)) {
                  return `Name must follow the syntax ${_picocolors.default.bold("my-component")}`;
                }
                // assumes auto was selected in CLI
                let joint = process.cwd();
                if (commandRun.options.path) {
                  joint = commandRun.options.path;
                }
                if (fs.existsSync(path.join(joint, value))) {
                  return `${path.join(joint, value)} exists, rename this project`;
                }
              }
            });
          } else {
            let value = commandRun.options.name;
            if (!value) {
              console.error(_picocolors.default.red("Name is required (Enter accepts default)"));
              process.exit(1);
            }
            if (reservedNames.includes(value)) {
              console.error(_picocolors.default.red(`Reserved name ${_picocolors.default.bold(value)} cannot be used`));
              process.exit(1);
            }
            if (value.toLocaleLowerCase() !== value) {
              console.error(_picocolors.default.red("Name must be lowercase"));
              process.exit(1);
            }
            if (/^\d/.test(value)) {
              console.error(_picocolors.default.red("Name cannot start with a number"));
              process.exit(1);
            }
            if (/[`~!@#$%^&*()_=+\[\]{}|;:\'",<.>\/?\\]/.test(value)) {
              console.error(_picocolors.default.red("No special characters allowed in name"));
              process.exit(1);
            }
            if (value.indexOf(' ') !== -1) {
              console.error(_picocolors.default.red("No spaces allowed in name"));
              process.exit(1);
            }
            if (value.indexOf('-') === -1 || value.replace('--', '') !== value || value[0] === '-' || value[value.length - 1] === '-') {
              console.error(_picocolors.default.red("Name must include at least one `-` and must not start or end name."));
              process.exit(1);
            }
            // Check for any other syntax errors
            if (!/^[a-z][a-z0-9.\-]*\-[a-z0-9.\-]*$/.test(value)) {
              console.error(_picocolors.default.red(`Name must follow the syntax ${_picocolors.default.bold("my-component")}`));
              process.exit(1);
            }
            // assumes auto was selected in CLI
            let joint = process.cwd();
            if (commandRun.options.path) {
              joint = commandRun.options.path;
            }
            if (fs.existsSync(path.join(joint, value))) {
              console.error(_picocolors.default.red(`${path.join(joint, value)} exists, rename this project`));
              process.exit(1);
            }
          }
          const project = {
            name: commandRun.options.name,
            path: activeHaxsite.directory,
            className: (0, _utils.dashToCamel)(commandRun.options.name),
            year: new Date().getFullYear()
          };
          if (!project.author) {
            try {
              let value = await (0, _utils.exec)(`git config user.name`);
              project.author = value.stdout.trim();
            } catch (e) {
              (0, _logging.log)(`
                   git user name not configured. Run the following to do this:\n
                   git config --global user.name "namehere"\n
                   git config --global user.email "email@here`, 'debug');
            }
          }
          const filePath = `${project.path}/custom/src/${project.name}.js`;
          await fs.copyFileSync(`${process.mainModule.path}/templates/generic/sitecomponent.js`, filePath);
          const ejsString = ejs.fileLoader(filePath, 'utf8');
          let content = ejs.render(ejsString, project);
          // file written successfully  
          fs.writeFileSync(filePath, content);
          if (!commandRun.options.npmClient) {
            commandRun.options.npmClient = 'npm';
          }
          if (fs.existsSync(`${project.path}/custom/custom-elements.json`)) {
            await (0, _utils.exec)(`cd custom && ${commandRun.options.npmClient} run analyze`);
          }
          p.note(`🧙  Add to another web component (.js): ${_picocolors.default.underline(_picocolors.default.bold(_picocolors.default.yellow(_picocolors.default.bgBlack(`import ./${project.name}.js`))))}`);
          // at least a second to see the message print at all
          await (0, _promises.setTimeout)(1000);
        } catch (e) {
          (0, _logging.log)(e.stderr);
          // Original ejs.render error checking
          console.error(_picocolors.default.red(process.cwd()));
          console.error(_picocolors.default.red(e));
        }
        break;
      case "site:surge":
        let surgePrepared = false;
        try {
          // Clean up broken symlinks and fix legacy ignore files before publishing
          let cleaned = cleanupSiteForPublish(activeHaxsite.directory);
          let fixedIgnore = fixLegacyIgnoreFile(activeHaxsite.directory, '.surgeignore');
          if (cleaned.length > 0 && !commandRun.options.quiet) {
            (0, _logging.log)(`Removed ${cleaned.length} broken symlinks: ${cleaned.join(', ')}`, 'info');
          }
          if (fixedIgnore && !commandRun.options.quiet) {
            (0, _logging.log)(`Updated legacy .surgeignore to exclude node_modules/`, 'info');
          }
          // Prepare for static publish: swap ghpages.html in as index.html and clean local artifacts
          surgePrepared = prepareSiteForStaticPublish(activeHaxsite.directory);
          if (surgePrepared && !commandRun.options.quiet) {
            (0, _logging.log)(`Prepared site for static publish (ghpages.html → index.html, build removed)`, 'info');
          }
          // attempt to install; implies they asked to publish with surge but
          // system test did not see it globally
          if (!sysSurge) {
            let s = p.spinner();
            s.start((0, _statements.merlinSays)('Installing Surge.sh globally so we can publish'));
            let execOutput = await (0, _utils.exec)(`npm install --global surge`);
            s.stop((0, _statements.merlinSays)('surge.sh installed globally'));
            (0, _logging.log)(execOutput.stdout.trim());
            sysSurge = true;
          }
          let execOutput;
          if (commandRun.options.domain && commandRun.options.y) {
            let s = p.spinner();
            s.start((0, _statements.merlinSays)('Sending site to Surge.sh ..'));
            execOutput = await (0, _utils.exec)(`cd ${activeHaxsite.directory} && surge . ${commandRun.options.domain}`);
            (0, _logging.log)(execOutput.stdout.trim());
            s.stop((0, _statements.merlinSays)(`Site published: https://${commandRun.options.domain}`));
          } else {
            let surgeArgs = ['.'];
            // could get here bc of being interactive, yet passed in a domain...
            if (commandRun.options.domain) {
              surgeArgs.push(commandRun.options.domain);
            }
            execOutput = await (0, _utils.interactiveExec)('surge', surgeArgs, {
              cwd: activeHaxsite.directory
            });
            if (commandRun.options.domain) {
              (0, _logging.log)((0, _statements.merlinSays)(`Site published: https://${commandRun.options.domain}`));
            } else {
              (0, _logging.log)((0, _statements.merlinSays)('Site published'));
            }
          }
        } catch (e) {
          (0, _logging.log)(formatErrorForLogging(e), 'error');
        } finally {
          if (surgePrepared) {
            let restored = restoreSiteAfterStaticPublish(activeHaxsite.directory);
            if (restored && !commandRun.options.quiet) {
              (0, _logging.log)(`Restored original index.html after publish`, 'info');
            }
          }
        }
        break;
      case "site:netlify":
        let netlifyPrepared = false;
        try {
          // Clean up broken symlinks and fix legacy ignore files before publishing
          let cleaned = cleanupSiteForPublish(activeHaxsite.directory);
          let fixedIgnore = fixLegacyIgnoreFile(activeHaxsite.directory, '.netlifyignore');
          if (cleaned.length > 0 && !commandRun.options.quiet) {
            (0, _logging.log)(`Removed ${cleaned.length} broken symlinks: ${cleaned.join(', ')}`, 'info');
          }
          if (fixedIgnore && !commandRun.options.quiet) {
            (0, _logging.log)(`Updated legacy .netlifyignore to exclude node_modules/`, 'info');
          }
          // Prepare for static publish: swap ghpages.html in as index.html and clean local artifacts
          netlifyPrepared = prepareSiteForStaticPublish(activeHaxsite.directory);
          if (netlifyPrepared && !commandRun.options.quiet) {
            (0, _logging.log)(`Prepared site for static publish (ghpages.html → index.html, build removed)`, 'info');
          }
          // attempt to install; implies they asked to publish with netlify but
          // system test did not see it globally
          if (!sysNetlify) {
            let s = p.spinner();
            s.start((0, _statements.merlinSays)('Installing Netlify CLI globally so we can publish'));
            let execOutput = await (0, _utils.exec)(`npm install --global netlify-cli`);
            s.stop((0, _statements.merlinSays)('Netlify CLI installed globally'));
            (0, _logging.log)(execOutput.stdout.trim());
            sysNetlify = true;
          }
          let execOutput;
          if (commandRun.options.y) {
            let s = p.spinner();
            s.start((0, _statements.merlinSays)('Deploying site to Netlify ..'));
            if (commandRun.options.domain) {
              // If specific site/domain is specified, deploy to existing site
              execOutput = await (0, _utils.exec)(`cd ${activeHaxsite.directory} && netlify deploy --prod --site ${commandRun.options.domain}`);
            } else {
              // Auto deploy - will create a new site or use existing site config
              execOutput = await (0, _utils.exec)(`cd ${activeHaxsite.directory} && netlify deploy --prod`);
            }
            (0, _logging.log)(execOutput.stdout.trim());
            s.stop((0, _statements.merlinSays)(`Site deployed to Netlify`));
          } else {
            let netlifyArgs = ['deploy', '--prod'];
            if (commandRun.options.domain) {
              netlifyArgs.push('--site', commandRun.options.domain);
            }
            execOutput = await (0, _utils.interactiveExec)('netlify', netlifyArgs, {
              cwd: activeHaxsite.directory
            });
            (0, _logging.log)((0, _statements.merlinSays)(`Site deployed to Netlify`));
          }
        } catch (e) {
          (0, _logging.log)(formatErrorForLogging(e), 'error');
        } finally {
          if (netlifyPrepared) {
            let restored = restoreSiteAfterStaticPublish(activeHaxsite.directory);
            if (restored && !commandRun.options.quiet) {
              (0, _logging.log)(`Restored original index.html after publish`, 'info');
            }
          }
        }
        break;
      case "site:vercel":
        let vercelPrepared = false;
        try {
          // Clean up broken symlinks and fix legacy ignore files before publishing
          let cleaned = cleanupSiteForPublish(activeHaxsite.directory);
          let fixedIgnore = fixLegacyIgnoreFile(activeHaxsite.directory, '.vercelignore');
          if (cleaned.length > 0 && !commandRun.options.quiet) {
            (0, _logging.log)(`Removed ${cleaned.length} broken symlinks: ${cleaned.join(', ')}`, 'info');
          }
          if (fixedIgnore && !commandRun.options.quiet) {
            (0, _logging.log)(`Updated legacy .vercelignore to exclude node_modules/`, 'info');
          }
          // Prepare for static publish: swap ghpages.html in as index.html and clean local artifacts
          vercelPrepared = prepareSiteForStaticPublish(activeHaxsite.directory);
          if (vercelPrepared && !commandRun.options.quiet) {
            (0, _logging.log)(`Prepared site for static publish (ghpages.html → index.html, build removed)`, 'info');
          }
          // attempt to install; implies they asked to publish with vercel but
          // system test did not see it globally
          if (!sysVercel) {
            let s = p.spinner();
            s.start((0, _statements.merlinSays)('Installing Vercel CLI globally so we can publish'));
            let execOutput = await (0, _utils.exec)(`npm install --global vercel`);
            s.stop((0, _statements.merlinSays)('Vercel CLI installed globally'));
            (0, _logging.log)(execOutput.stdout.trim());
            sysVercel = true;
          }
          let execOutput;
          if (commandRun.options.y) {
            let s = p.spinner();
            s.start((0, _statements.merlinSays)('Deploying site to Vercel ..'));
            if (commandRun.options.domain) {
              // Deploy with specific domain/project name
              execOutput = await (0, _utils.exec)(`cd ${activeHaxsite.directory} && vercel --prod --name ${commandRun.options.domain}`);
            } else {
              // Auto deploy with default settings
              execOutput = await (0, _utils.exec)(`cd ${activeHaxsite.directory} && vercel --prod`);
            }
            (0, _logging.log)(execOutput.stdout.trim());
            s.stop((0, _statements.merlinSays)(`Site deployed to Vercel`));
          } else {
            let vercelArgs = ['--prod'];
            if (commandRun.options.domain) {
              vercelArgs.push('--name', commandRun.options.domain);
            }
            execOutput = await (0, _utils.interactiveExec)('vercel', vercelArgs, {
              cwd: activeHaxsite.directory
            });
            (0, _logging.log)((0, _statements.merlinSays)(`Site deployed to Vercel`));
          }
        } catch (e) {
          (0, _logging.log)(formatErrorForLogging(e), 'error');
        } finally {
          if (vercelPrepared) {
            let restored = restoreSiteAfterStaticPublish(activeHaxsite.directory);
            if (restored && !commandRun.options.quiet) {
              (0, _logging.log)(`Restored original index.html after publish`, 'info');
            }
          }
        }
        break;
      case "setup:github-actions":
        try {
          let s = p.spinner();
          s.start((0, _statements.merlinSays)('Setting up GitHub Actions deployment workflow'));

          // Create .github/workflows directory
          const workflowDir = path.join(activeHaxsite.directory, '.github', 'workflows');
          if (!fs.existsSync(workflowDir)) {
            fs.mkdirSync(workflowDir, {
              recursive: true
            });
          }

          // Copy the workflow file
          const workflowFile = path.join(workflowDir, 'deploy.yml');
          if (fs.existsSync(workflowFile) && !commandRun.options.y) {
            s.stop((0, _statements.merlinSays)('GitHub Actions workflow already exists'));
            let overwrite = await p.confirm({
              message: 'GitHub Actions workflow file already exists. Overwrite?',
              initialValue: false
            });
            if (!overwrite) {
              (0, _logging.log)('Skipped GitHub Actions setup');
              break;
            }
          }
          await fs.copyFileSync(path.join(process.mainModule.path, 'templates/sitedotfiles/_github_workflows_deploy.yml'), workflowFile);
          s.stop((0, _statements.merlinSays)('GitHub Actions workflow created successfully'));
          if (!commandRun.options.quiet) {
            p.note(`🚀 GitHub Actions workflow has been set up!\n\nNext steps:\n1. Push your changes: ${_picocolors.default.bold('git add . && git commit -m "Add GitHub Actions workflow" && git push')}\n2. Enable GitHub Pages in your repository settings\n3. Select "GitHub Actions" as the source\n4. Your site will automatically deploy on every push to main/master`);
          }
        } catch (e) {
          (0, _logging.log)(formatErrorForLogging(e), 'error');
        }
        break;
      case "setup:gitlab-ci":
        try {
          let s = p.spinner();
          s.start((0, _statements.merlinSays)('Setting up GitLab CI deployment pipeline'));

          // Copy the GitLab CI file
          const ciFile = path.join(activeHaxsite.directory, '.gitlab-ci.yml');
          if (fs.existsSync(ciFile) && !commandRun.options.y) {
            s.stop((0, _statements.merlinSays)('GitLab CI file already exists'));
            let overwrite = await p.confirm({
              message: '.gitlab-ci.yml already exists. Overwrite?',
              initialValue: false
            });
            if (!overwrite) {
              (0, _logging.log)('Skipped GitLab CI setup');
              break;
            }
          }
          await fs.copyFileSync(path.join(process.mainModule.path, 'templates/sitedotfiles/_gitlab-ci.yml'), ciFile);
          s.stop((0, _statements.merlinSays)('GitLab CI pipeline created successfully'));
          if (!commandRun.options.quiet) {
            p.note(`🚀 GitLab CI pipeline has been set up!\n\nNext steps:\n1. Push your changes: ${_picocolors.default.bold('git add . && git commit -m "Add GitLab CI pipeline" && git push')}\n2. GitLab Pages will be automatically enabled\n3. Your site will deploy on every push to main/master\n4. Access your site at: ${_picocolors.default.cyan('https://yourusername.gitlab.io/yourproject')}`);
          }
        } catch (e) {
          (0, _logging.log)(formatErrorForLogging(e), 'error');
        }
        break;
      case "site:file-list":
      case "site:list-files":
        let res = await invokeRoute(allRoutesLib.allRoutes.site.map.get['v1/files'], {}, {
          siteName: activeHaxsite.name,
          filename: commandRun.options.filename,
          user_token: "fakeToken",
          site_token: "fakeToken"
        });
        logStructuredOutput(commandRun, res.data);
        break;
      case "site:search":
        try {
          if (!commandRun.options.search) {
            commandRun.options.search = await p.text({
              message: 'Search query (text search across site fields)',
              placeholder: 'lesson',
              validate: value => {
                if (!value) {
                  return 'Search query is required';
                }
                if (value.length > 256) {
                  return 'Search query is too long (max 256 characters)';
                }
              }
            });
          }
          // v1 search contract (GET /v1/search) requires `q` and supports
          // `fields` (CSV of: title,slug,description,tags,content,id,location),
          // `sort`, and `page.limit`/`page.offset` pagination. The legacy v0
          // params (searchCaseSensitive, searchSelector, searchMode) have no
          // v1 equivalent: v1 search is always case-insensitive and only does
          // text matching across fields (no selector/DOM-query mode). The
          // --search-selector / --search-mode CLI flags are kept to avoid a
          // breaking interface change but are intentionally not forwarded.
          let searchRouteParams = {
            siteName: activeHaxsite.name,
            q: commandRun.options.search,
            user_token: "fakeToken",
            site_token: "fakeToken"
          };
          if (commandRun.options.searchField) {
            searchRouteParams.fields = commandRun.options.searchField;
          }
          if (commandRun.options.searchLimit) {
            searchRouteParams['page.limit'] = commandRun.options.searchLimit;
          }
          let searchRes = await invokeRoute(allRoutesLib.allRoutes.site.map.get['v1/search'], {}, searchRouteParams);
          logStructuredOutput(commandRun, searchRes.data);
        } catch (e) {
          (0, _logging.log)(formatErrorForLogging(e), 'error');
        }
        break;
      case "site:tags":
        try {
          let res = await invokeRoute(allRoutesLib.allRoutes.site.map.get['v1/tags'], {}, {
            siteName: activeHaxsite.name,
            user_token: "fakeToken",
            site_token: "fakeToken"
          });
          logStructuredOutput(commandRun, res.data);
        } catch (e) {
          (0, _logging.log)(formatErrorForLogging(e), 'error');
        }
        break;
      case "site:blocks":
        try {
          let res = await invokeRoute(allRoutesLib.allRoutes.site.map.get['v1/blocks'], {}, {
            siteName: activeHaxsite.name,
            user_token: "fakeToken",
            site_token: "fakeToken"
          });
          logStructuredOutput(commandRun, res.data);
        } catch (e) {
          (0, _logging.log)(formatErrorForLogging(e), 'error');
        }
        break;
      case "site:analytics":
        try {
          let res = await invokeRoute(allRoutesLib.allRoutes.site.map.get['v1/analytics'], {}, {
            siteName: activeHaxsite.name,
            user_token: "fakeToken",
            site_token: "fakeToken"
          });
          logStructuredOutput(commandRun, res.data);
        } catch (e) {
          (0, _logging.log)(formatErrorForLogging(e), 'error');
        }
        break;
      case "site:revisions":
        try {
          if (!commandRun.options.itemId) {
            commandRun.options.itemId = await p.select({
              message: `Select an item to view revisions`,
              required: true,
              options: [{
                value: null,
                label: "-- exit --"
              }, ...(await siteItemsOptionsList(activeHaxsite))]
            });
          }
          if (commandRun.options.itemId) {
            const cliBridge = await getHaxcmsNodejsCli();
            if (commandRun.options.restore) {
              let revisionId = commandRun.options.revisionId;
              if (!revisionId && !commandRun.options.y) {
                revisionId = await p.text({
                  message: 'Revision ID to restore',
                  placeholder: 'abc1234',
                  validate: value => {
                    if (!value) return 'Revision ID is required';
                  }
                });
              }
              if (revisionId) {
                let del = true;
                if (!commandRun.options.y) {
                  del = await p.confirm({
                    message: `Are you sure you want to restore revision ${revisionId} for item ${commandRun.options.itemId}?`,
                    initialValue: true
                  });
                }
                if (del) {
                  let resp = await cliBridge.cliBridge(`v1/items/${commandRun.options.itemId}/revisions/${revisionId}/restore`, {
                    site: {
                      name: activeHaxsite.name
                    }
                  }, 'post');
                  logStructuredOutput(commandRun, resp.res.data);
                  recipe.log(siteLoggingName, (0, _logging.commandString)(commandRun));
                }
              } else {
                (0, _logging.log)('Revision ID is required for restore', 'error');
              }
            } else {
              let resp = await cliBridge.cliBridge(`v1/items/${commandRun.options.itemId}/revisions`, {
                site: {
                  name: activeHaxsite.name
                }
              }, 'get');
              logStructuredOutput(commandRun, resp.res.data);
            }
          }
        } catch (e) {
          (0, _logging.log)(formatErrorForLogging(e), 'error');
        }
        break;
      case "site:export":
        try {
          let exportFormat = commandRun.options.exportFormat || commandRun.options.format;
          const validExportFormats = ['pdf', 'docx', 'epub', 'html', 'zip', 'markdown', 'skeleton'];
          if (!exportFormat || !validExportFormats.includes(exportFormat)) {
            if (!commandRun.options.y) {
              exportFormat = await p.select({
                message: 'Export format',
                options: [{
                  value: 'pdf',
                  label: 'PDF'
                }, {
                  value: 'docx',
                  label: 'DOCX'
                }, {
                  value: 'epub',
                  label: 'EPUB'
                }, {
                  value: 'html',
                  label: 'HTML'
                }, {
                  value: 'zip',
                  label: 'ZIP'
                }, {
                  value: 'markdown',
                  label: 'Markdown'
                }, {
                  value: 'skeleton',
                  label: 'Skeleton'
                }]
              });
            } else {
              (0, _logging.log)('Export format is required (pdf, docx, epub, html, zip, markdown, skeleton)', 'error');
              break;
            }
          }
          if (exportFormat) {
            const cliBridge = await getHaxcmsNodejsCli();
            let resp = await cliBridge.cliBridge(`v1/site/export/${exportFormat}`, {
              site: {
                name: activeHaxsite.name
              }
            }, 'get');
            if (resp.res.statusCode >= 400) {
              (0, _logging.log)(`Export failed: ${typeof resp.res.data === 'string' ? resp.res.data : JSON.stringify(resp.res.data)}`, 'error');
              break;
            }
            // pdf/docx/epub return a binary buffer; html returns the full
            // HTML document as a string. Both are file downloads the CLI
            // writes directly to disk. zip/markdown/skeleton return a JSON
            // export descriptor (data.export.href points at the real
            // download) rather than the archive/markdown itself — printed
            // below so the caller can follow the href.
            const downloadFormats = ['pdf', 'docx', 'epub', 'html'];
            if (downloadFormats.includes(exportFormat) && resp.res.data) {
              let targetFile = commandRun.options.toFile;
              if (!targetFile) {
                targetFile = `${activeHaxsite.name}.${exportFormat}`;
              }
              targetFile = path.resolve(targetFile);
              fs.writeFileSync(targetFile, resp.res.data);
              if (!commandRun.options.quiet) {
                p.outro(`${_picocolors.default.green('✓')} Exported to ${targetFile}`);
              }
              recipe.log(siteLoggingName, (0, _logging.commandString)(commandRun));
            } else {
              // zip/markdown/skeleton: the v1 backend returns a JSON export
              // descriptor (follow data.export.href for the actual file),
              // not the archive/markdown itself.
              if (!commandRun.options.quiet) {
                (0, _logging.log)(`${exportFormat} export returned a JSON descriptor (follow data.export.href for the actual file):`);
              }
              logStructuredOutput(commandRun, resp.res.data);
              if (commandRun.options.toFile) {
                fs.writeFileSync(commandRun.options.toFile, formatStructuredOutput(commandRun, resp.res.data));
              }
            }
          }
        } catch (e) {
          (0, _logging.log)(formatErrorForLogging(e), 'error');
        }
        break;
      case "site:files-upload":
        try {
          if (!commandRun.options.source) {
            commandRun.options.source = await p.text({
              message: 'Source file or directory to upload',
              placeholder: './assets/image.png',
              validate: value => {
                if (!value) return 'Source path is required';
                if (!fs.existsSync(value)) return 'Source path does not exist';
              }
            });
          }
          if (commandRun.options.source && fs.existsSync(commandRun.options.source)) {
            const stats = fs.statSync(commandRun.options.source);
            const cliBridge = await getHaxcmsNodejsCli();
            if (stats.isDirectory()) {
              let files = fs.readdirSync(commandRun.options.source);
              let uploaded = 0;
              for (let file of files) {
                let filePath = path.join(commandRun.options.source, file);
                let fileStats = fs.statSync(filePath);
                if (fileStats.isFile()) {
                  let tmpFile = path.join(os.tmpdir(), `hax-cli-upload-${Date.now()}-${Math.floor(Math.random() * 1000000)}-${file}`);
                  fs.copyFileSync(filePath, tmpFile);
                  let fileObj = {
                    path: tmpFile,
                    originalname: file,
                    name: file,
                    size: fileStats.size
                  };
                  let resp = await cliBridge.cliBridge('v1/files', {
                    site: {
                      name: activeHaxsite.name
                    }
                  }, 'post', fileObj);
                  try {
                    fs.unlinkSync(tmpFile);
                  } catch (e) {}
                  if (resp.res.data && resp.res.data.status === 200) {
                    uploaded++;
                  }
                }
              }
              if (!commandRun.options.quiet) {
                p.outro(`${_picocolors.default.green('✓')} Uploaded ${uploaded} files`);
              }
              recipe.log(siteLoggingName, (0, _logging.commandString)(commandRun));
            } else {
              let tmpFile = path.join(os.tmpdir(), `hax-cli-upload-${Date.now()}-${Math.floor(Math.random() * 1000000)}-${path.basename(commandRun.options.source)}`);
              fs.copyFileSync(commandRun.options.source, tmpFile);
              let fileObj = {
                path: tmpFile,
                originalname: path.basename(commandRun.options.source),
                name: path.basename(commandRun.options.source),
                size: stats.size
              };
              let resp = await cliBridge.cliBridge('v1/files', {
                site: {
                  name: activeHaxsite.name
                }
              }, 'post', fileObj);
              try {
                fs.unlinkSync(tmpFile);
              } catch (e) {}
              logStructuredOutput(commandRun, resp.res.data);
              recipe.log(siteLoggingName, (0, _logging.commandString)(commandRun));
            }
          }
        } catch (e) {
          (0, _logging.log)(formatErrorForLogging(e), 'error');
        }
        break;
      case "site:files-delete":
        try {
          let fileUuid = commandRun.options.fileUuid;
          if (!fileUuid) {
            if (!commandRun.options.y) {
              fileUuid = await p.text({
                message: 'File UUID to delete',
                placeholder: 'abc12345-...',
                validate: value => {
                  if (!value) return 'File UUID is required';
                }
              });
            } else {
              (0, _logging.log)('File UUID is required for files-delete', 'error');
              break;
            }
          }
          if (fileUuid) {
            let del = true;
            if (!commandRun.options.y) {
              del = await p.confirm({
                message: `Are you sure you want to delete file ${fileUuid}? (This cannot be undone)`,
                initialValue: true
              });
            }
            if (del) {
              const cliBridge = await getHaxcmsNodejsCli();
              let resp = await cliBridge.cliBridge(`v1/files/${fileUuid}`, {
                site: {
                  name: activeHaxsite.name
                }
              }, 'delete');
              logStructuredOutput(commandRun, resp.res.data);
              recipe.log(siteLoggingName, (0, _logging.commandString)(commandRun));
            }
          }
        } catch (e) {
          (0, _logging.log)(formatErrorForLogging(e), 'error');
        }
        break;
      case "site:search-replace":
        try {
          if (!commandRun.options.search) {
            commandRun.options.search = await p.text({
              message: 'Search text to replace',
              placeholder: 'old text',
              validate: value => {
                if (!value) return 'Search text is required';
                if (value.length <= 1) return 'Search text must be more than 1 character';
              }
            });
          }
          if (!commandRun.options.replace && commandRun.options.replace !== '') {
            commandRun.options.replace = await p.text({
              message: 'Replacement text',
              placeholder: 'new text',
              initialValue: ''
            });
          }
          let replaceBody = {
            site: {
              name: activeHaxsite.name
            },
            operation: 'replace',
            search: commandRun.options.search,
            replace: commandRun.options.replace,
            searchCaseSensitive: !!commandRun.options.searchCaseSensitive
          };
          if (commandRun.options.replace === '') {
            if (commandRun.options.y || commandRun.options.destroyConfirm) {
              replaceBody.replaceDestroyConfirm = true;
            } else {
              let destroyConfirm = await p.confirm({
                message: 'You are about to remove matched text. Confirm?',
                initialValue: false
              });
              if (!destroyConfirm) {
                (0, _logging.log)('Operation cancelled');
                break;
              }
              replaceBody.replaceDestroyConfirm = true;
            }
          }
          if (commandRun.options.y || commandRun.options.confirm) {
            replaceBody.replaceConfirm = true;
          } else {
            let confirm = await p.confirm({
              message: `Replace all occurrences of "${commandRun.options.search}" with "${commandRun.options.replace}"?`,
              initialValue: true
            });
            if (!confirm) {
              (0, _logging.log)('Operation cancelled');
              break;
            }
            replaceBody.replaceConfirm = true;
          }
          const cliBridge = await getHaxcmsNodejsCli();
          let resp = await cliBridge.cliBridge('v1/content', replaceBody, 'patch');
          logStructuredOutput(commandRun, resp.res.data);
          recipe.log(siteLoggingName, (0, _logging.commandString)(commandRun));
        } catch (e) {
          (0, _logging.log)(formatErrorForLogging(e), 'error');
        }
        break;
      case "site:html":
      case "site:md":
      case "site:schema":
        let siteContent = '';
        activeHaxsite = await systemStructureContext();
        let items = [];
        if (commandRun.options.itemId != null) {
          items = activeHaxsite.manifest.findBranch(commandRun.options.itemId);
        } else {
          items = activeHaxsite.manifest.orderTree(activeHaxsite.manifest.items);
        }
        if (operation.action === 'site:schema') {
          let els = [];
          for (var i in items) {
            let page = activeHaxsite.loadNode(items[i].id);
            let html = await activeHaxsite.getPageContent(page);
            let dom = (0, _nodeHtmlParser.parse)(`<div id="fullpage">${html}</div>`);
            els.push({
              tag: "h1",
              properties: {
                "data-jos-item-id": items[i].id
              },
              content: `${items[i].title}`
            });
            for (var j in dom.querySelector('#fullpage').childNodes) {
              let node = dom.querySelector('#fullpage').childNodes[j];
              if (node && node.getAttribute) {
                els.push(await nodeToHaxElement(node, null));
              }
            }
          }
          // simple redirecting to file
          if (commandRun.options.toFile) {
            if (commandRun.options.format === 'yaml') {
              fs.writeFileSync(commandRun.options.toFile, (0, _jsYaml.dump)(els));
            } else {
              fs.writeFileSync(commandRun.options.toFile, JSON.stringify(els, null, 2));
            }
          } else {
            logStructuredOutput(commandRun, els);
          }
        } else {
          for (var i in items) {
            let page = activeHaxsite.loadNode(items[i].id);
            siteContent += `<h1>${items[i].title}</h1>\n\r`;
            siteContent += `<div data-jos-item-id="${items[i].id}">\n\r${await activeHaxsite.getPageContent(page)}\n\r</div>\n\r`;
          }
          if (operation.action === 'site:md') {
            // Security (Stream-A): on-prem haxcms-nodejs handler in-process
            // (replaces @haxtheweb/open-apis broker); result at .data.data.contents.
            let resp = await invokeRoute(allRoutesLib.allRoutes.system.map.post['actions/html-to-md'], {
              html: siteContent
            });
            let mdContent = resp && resp.data && resp.data.data ? resp.data.data.contents : '';
            if (commandRun.options.toFile) {
              fs.writeFileSync(commandRun.options.toFile, mdContent);
              if (!commandRun.options.quiet) {
                (0, _logging.log)(`${commandRun.options.toFile} written`);
              }
            } else {
              (0, _logging.log)(mdContent);
            }
          } else {
            if (commandRun.options.toFile) {
              fs.writeFileSync(commandRun.options.toFile, siteContent);
              if (!commandRun.options.quiet) {
                (0, _logging.log)(`${commandRun.options.toFile} written`);
              }
            } else {
              (0, _logging.log)(siteContent);
            }
          }
        }
        break;
      // @todo need to make these work..
      case "recipe:read":
        // just print the recipe out
        if (fs.existsSync(path.join(process.cwd(), `${siteRecipeFile}`))) {
          let recContents = await fs.readFileSync(path.join(process.cwd(), `${siteRecipeFile}`), 'utf8');
          console.log(recContents);
        }
        break;
      case "recipe:play":
        // step through and run each recipe once fed a file location
        // this allows for storing commands from a site and then replaying them with ease
        if (!commandRun.options.recipe) {
          commandRun.options.recipe = await p.text({
            message: `Select recipe:`,
            defaultValue: process.cwd(),
            initialValue: process.cwd(),
            validate: val => {
              if (!val.endsWith('.recipe')) {
                return 'HAX Recipe files must end in .recipe';
              }
            }
          });
        }
        if (fs.existsSync(commandRun.options.recipe)) {
          // Security (L-1): canonicalize + reject null bytes for the recipe path.
          let recContents = await fs.readFileSync((0, _siteSecurity.resolveLocalPath)(commandRun.options.recipe), 'utf8');
          // split into commands
          let commandList = recContents.replaceAll('cli: ', '').split("\n");
          // Security (H-4): rootDir is now an argv array (was a shell string).
          let rootDirTokens = [];
          // confirm each command or allow --y so that it auto applies
          for (var i in commandList) {
            // verify every command starts this way for safety
            if (commandList[i].startsWith('hax site')) {
              let confirmation;
              if (commandRun.options.y) {
                confirmation = true;
              } else {
                confirmation = await p.confirm({
                  message: `Do you want to run ${commandList[i]}? (This cannot be undone)`,
                  initialValue: true
                });
              }
              // confirmed; let's run!
              if (confirmation) {
                // Security (H-4): tokenize and invoke via spawn() (no shell)
                // so recipe contents cannot inject shell commands. Drop the
                // leading "hax" token; the rest is argv to the CLI.
                let tokens = commandList[i].split(' ').filter(t => t.length > 0).slice(1);
                let commandMatch = siteActions().filter(action => action.value === tokens[1]);
                // if we found a command that means it is a valid command to run against the site
                if (commandMatch.length > 0) {
                  (0, _siteSecurity.guardRecipeTokens)([...tokens, ...rootDirTokens]);
                  await runCliNoShell([...tokens, '--y', '--no-i', '--auto', '--quiet', ...rootDirTokens]);
                }
                // 1st command won't match as the argument creates a new site
                // but ensure we don't have a site context prior to running this
                // or we'll get a site in a site with the same name which is not
                // the desired result
                else if (!(await systemStructureContext())) {
                  (0, _siteSecurity.guardRecipeTokens)(tokens);
                  await runCliNoShell([...tokens, '--y', '--no-i', '--auto', '--quiet', '--no-extras']);
                  // site will have been created, obtain the site name and set root so
                  // the other commands get piped into it correctly
                  rootDirTokens = ['--root', tokens[1]];
                } else {
                  (0, _logging.log)('Did not run because we already have a site', 'warn');
                }
              }
            }
          }
        }
        break;
      case "issue:general":
        // open the issues
        p.intro(`${_picocolors.default.bgBlue(_picocolors.default.white(` Submit issue / suggestion on Github `))}`);
        p.intro(`${_picocolors.default.bgBlue(_picocolors.default.white(` Opening in browser `))}`);
        await (0, _open.default)("https://github.com/haxtheweb/issues/issues/new");
        p.outro(`${_picocolors.default.bgBlue(_picocolors.default.white(` https://github.com/haxtheweb/issues/issues/new `))}`);
        break;
      case "issue:theme":
        // open the issues
        p.intro(`${_picocolors.default.bgBlue(_picocolors.default.white(` Submit custom theme on Github `))}`);
        p.intro(`${_picocolors.default.bgBlue(_picocolors.default.white(` Opening in browser `))}`);
        let allContents = '';
        fs.readdir(`${activeHaxsite.directory}/custom/src`, (err, files) => {
          if (err) {
            console.error('Error reading directory:', err);
            return;
          }
          files.forEach((file, index) => {
            const filePath = path.join(`${activeHaxsite.directory}/custom/src`, file);
            if (fs.lstatSync(filePath).isFile()) {
              const content = fs.readFileSync(filePath, 'utf-8');
              // append file name as a JS comment
              allContents += `\n// FILENAME: ${file}\n` + content + '\n'; // Add newline between files if desired
            }
            if (index === files.length - 1) {
              console.log('Combined contents of all files:\n', allContents);
            }
          });
        });
        console.log(allContents);
        await (0, _open.default)(`https://github.com/haxtheweb/issues/issues/new?template=new-design.md`);
        p.outro(`${_picocolors.default.bgBlue(_picocolors.default.white(` Copy the output of the console into the issue's js template area `))}`);
        p.outro(`${_picocolors.default.bgBlue(_picocolors.default.white(` https://github.com/haxtheweb/issues/issues/new?template=new-design.md `))}`);
        break;
      case "quit":
        // quit
        process.exit(0);
        break;
    }
    // y or noi need to act like it ran and finish instead of looping options
    if (commandRun.options.y || !commandRun.options.i || !actionAssigned) {
      process.exit(0);
    }
    operation.action = null;
  }
  if (!commandRun.options.quiet) {
    (0, _statements.communityStatement)();
  }
}
function siteNodeStatsOperations(search = null) {
  let obj = [{
    value: 'details',
    label: "Details"
  }, {
    value: 'html',
    label: "Page as HTML source"
  }, {
    value: 'schema',
    label: "Page as HAXElementSchema"
  }, {
    value: 'md',
    label: "Page as Markdown"
  }];
  if (search) {
    for (const op of obj) {
      if (op.value === search) {
        return true;
      }
    }
    return false;
  }
  return obj;
}
function siteNodeOperations(search = null) {
  let obj = [{
    value: 'title',
    label: "Title"
  }, {
    value: 'content',
    label: "Page content"
  }, {
    value: 'slug',
    label: "Path (slug)"
  }, {
    value: 'published',
    label: "Publishing status"
  }, {
    value: 'tags',
    label: "Tags"
  }, {
    value: 'parent',
    label: "Parent"
  }, {
    value: 'order',
    label: "Order"
  }, {
    value: 'theme',
    label: "Theme"
  }, {
    value: 'hideInMenu',
    label: "Hide in menu"
  }];
  if (search) {
    for (const op of obj) {
      if (op.value === search) {
        return true;
      }
    }
    return false;
  }
  return obj;
}

// Security (Stream-A): openApiBroker() removed — the deprecated
// @haxtheweb/open-apis broker (dynamic-importing handlers from that package's
// dist/) was replaced by in-process invokeRoute() calls against haxcms-nodejs
// route handlers (see IMPORT_STRUCTURE_MAP above and the string-converter
// migrations). No cloud (open-apis.hax.cloud) URLs are referenced.

function applyImportedSiteMetadata(siteRequest, importedSiteData) {
  if (!siteRequest || !siteRequest.site) {
    return;
  }
  if (!importedSiteData || typeof importedSiteData !== 'object') {
    return;
  }
  if (typeof importedSiteData.license === 'string') {
    const license = importedSiteData.license.trim();
    if (license !== '') {
      siteRequest.site.license = license;
    }
  }
}
function isObjectLike(value) {
  return !!value && typeof value === 'object' && !Array.isArray(value);
}
function normalizeSkeletonMachineName(value) {
  if (typeof value !== 'string') {
    return '';
  }
  return HAXCMS.generateMachineName(value).replace(/\.json$/i, '').trim().toLowerCase();
}
function resolveAbsolutePath(pathValue) {
  if (!pathValue || typeof pathValue !== 'string') {
    return '';
  }
  if (path.isAbsolute(pathValue)) {
    return pathValue;
  }
  return path.join(process.cwd(), pathValue);
}
function extractSkeletonPayload(rawData) {
  let skeleton = rawData;
  if (isObjectLike(rawData) && isObjectLike(rawData.data) && isObjectLike(rawData.data.skeleton)) {
    skeleton = rawData.data.skeleton;
  }
  if (!isObjectLike(skeleton)) {
    throw new Error('Invalid skeleton JSON structure');
  }
  if (!isObjectLike(skeleton.meta)) {
    skeleton.meta = {};
  }
  if (!isObjectLike(skeleton.site)) {
    skeleton.site = {};
  }
  if (!isObjectLike(skeleton.build)) {
    skeleton.build = {};
  }
  if (!Array.isArray(skeleton.build.items)) {
    skeleton.build.items = [];
  }
  if (typeof skeleton.build.files === 'undefined' || skeleton.build.files === null) {
    skeleton.build.files = [];
  }
  if (!skeleton.build.structure) {
    skeleton.build.structure = 'from-skeleton';
  }
  if (!skeleton.build.type) {
    skeleton.build.type = 'skeleton';
  }
  const machineName = normalizeSkeletonMachineName(skeleton.meta.machineName ? skeleton.meta.machineName : skeleton.meta.name ? skeleton.meta.name : 'site-template');
  skeleton.meta.machineName = machineName;
  skeleton.meta.name = machineName;
  return {
    skeleton,
    machineName
  };
}
function loadSkeletonFileData(skeletonFilePath) {
  const absolutePath = resolveAbsolutePath(skeletonFilePath);
  if (!absolutePath) {
    throw new Error('Skeleton file path is required');
  }
  if (!fs.existsSync(absolutePath)) {
    throw new Error(`Skeleton file does not exist: ${absolutePath}`);
  }
  if (!fs.lstatSync(absolutePath).isFile()) {
    throw new Error(`Skeleton file path is not a file: ${absolutePath}`);
  }
  const rawContents = fs.readFileSync(absolutePath, 'utf8');
  const parsed = JSON.parse(rawContents);
  const normalized = extractSkeletonPayload(parsed);
  return {
    ...normalized,
    absolutePath
  };
}
function installSkeletonFile(skeletonFilePath, machineNameOverride = null) {
  const loadedSkeleton = loadSkeletonFileData(skeletonFilePath);
  const skeleton = loadedSkeleton.skeleton;
  const overrideName = normalizeSkeletonMachineName(machineNameOverride);
  const machineName = overrideName || loadedSkeleton.machineName;
  if (!machineName) {
    throw new Error('Skeleton machine name could not be resolved');
  }
  skeleton.meta.machineName = machineName;
  skeleton.meta.name = machineName;
  const skeletonTargetDirectory = path.join(HAXCMS.configDirectory, 'user', 'skeletons');
  if (!fs.existsSync(skeletonTargetDirectory)) {
    fs.mkdirSync(skeletonTargetDirectory, {
      recursive: true
    });
  }
  const installPath = path.join(skeletonTargetDirectory, `${machineName}.json`);
  fs.writeFileSync(installPath, `${JSON.stringify(skeleton, null, 2)}\n`);
  return {
    machineName,
    installPath
  };
}
// process site creation
async function siteProcess(commandRun, project, port = '3000') {
  // auto select operations to perform if requested
  // Security (M-2): defense-in-depth re-check of --npm-client at the point it
  // is interpolated into exec() shell strings. create.js validates earlier,
  // but the site/webcomponent subcommand option paths can bypass that check.
  if (commandRun.options.npmClient) {
    commandRun.options.npmClient = (0, _utils.validateNpmClient)(commandRun.options.npmClient);
  }
  var s = p.spinner();
  // if we have no extras, or they are empty then set for launch
  if (!project.extras) {
    project.extras = [];
    if (commandRun.options.i) {
      project.extras = ['launch'];
    }
  }
  let siteRequest = {
    "site": {
      "name": project.name,
      "description": "own course",
      "theme": commandRun.options.theme ? commandRun.options.theme : project.theme ? project.theme : "clean-one"
    },
    "build": {
      "type": "own",
      "structure": "course",
      "items": null,
      "files": null
    },
    "theme": {
      "color": "green",
      "icon": "av:library-add"
    }
  };
  let skeletonFilePath = commandRun.options.skeletonFile ? commandRun.options.skeletonFile : project.skeletonFile ? project.skeletonFile : null;
  let skeletonMachineName = commandRun.options.skeletonMachineName ? commandRun.options.skeletonMachineName : project.skeletonMachineName ? project.skeletonMachineName : null;
  if (skeletonFilePath && skeletonMachineName) {
    throw new Error('You can only pass one skeleton source when creating a site (--skeleton-file or --skeleton-machine-name)');
  }
  if ((skeletonFilePath || skeletonMachineName) && commandRun.options.importSite) {
    throw new Error('Skeleton template creation cannot be combined with --import-site');
  }
  if (skeletonFilePath || skeletonMachineName) {
    siteRequest.build.structure = 'from-skeleton';
    siteRequest.build.type = 'skeleton';
    siteRequest.build.items = [];
    siteRequest.build.files = [];
    if (skeletonMachineName) {
      const normalizedMachineName = normalizeSkeletonMachineName(skeletonMachineName);
      if (!normalizedMachineName) {
        throw new Error('Invalid skeleton machine name supplied');
      }
      siteRequest.build.skeletonMachineName = normalizedMachineName;
    } else if (skeletonFilePath) {
      const skeletonFileData = loadSkeletonFileData(skeletonFilePath);
      const skeleton = skeletonFileData.skeleton;
      siteRequest.build.structure = skeleton.build.structure;
      siteRequest.build.type = skeleton.build.type;
      siteRequest.build.items = skeleton.build.items;
      siteRequest.build.files = skeleton.build.files;
      if (isObjectLike(skeleton.site) && typeof skeleton.site.theme === 'string' && skeleton.site.theme !== '') {
        siteRequest.site.theme = skeleton.site.theme;
      }
      if (isObjectLike(skeleton.site) && typeof skeleton.site.description === 'string' && skeleton.site.description !== '') {
        siteRequest.site.description = skeleton.site.description;
      }
      applyImportedSiteMetadata(siteRequest, skeleton.site);
    }
  }
  // allow for importSite option
  if (commandRun.options.importSite) {
    if (!commandRun.options.importStructure) {
      // assume hax to hax if it's not defined
      commandRun.options.importStructure = 'haxcmsToSite';
    }
    // Security (Stream-A): verify this is a valid import structure and route
    // it in-process to the on-prem haxcms-nodejs handler (replaces the
    // @haxtheweb/open-apis broker + MicroFrontendRegistry lookup). Platform
    // converters go through the site/import/:platform dispatcher; docx/xlsx
    // use their own actions routes.
    const importConfig = IMPORT_STRUCTURE_MAP[commandRun.options.importStructure];
    if (commandRun.options.importStructure && importConfig) {
      let resp;
      if (importConfig.platform) {
        resp = await invokeRoute(allRoutesLib.allRoutes.system.map.post['site/import/:platform'], {
          repoUrl: commandRun.options.importSite
        }, {}, {
          platform: importConfig.platform
        });
      } else {
        resp = await invokeRoute(allRoutesLib.allRoutes.system.map.post[importConfig.routeKey], {
          repoUrl: commandRun.options.importSite
        });
      }
      const importedData = resp && resp.data && resp.data.data ? resp.data.data : null;
      if (importedData && importedData.items) {
        siteRequest.build.structure = 'import';
        siteRequest.build.items = importedData.items;
      }
      if (importedData && importedData.files) {
        siteRequest.build.files = importedData.files;
      }
      if (importedData && importedData.siteFiles) {
        siteRequest.build.siteFiles = importedData.siteFiles;
      }
      if (importedData && importedData.site) {
        applyImportedSiteMetadata(siteRequest, importedData.site);
      }
    }
    // hidden import methodologies
    else if (commandRun.options.importStructure) {
      if (commandRun.options.importStructure === 'drupal7-book-print-html') {
        // Security (H-2): fetch the import source through safeFetch so SSRF
        // targets (loopback/private/metadata) are rejected before the request.
        let siteContent = '';
        try {
          const resp = await safeFetch(commandRun.options.importSite);
          siteContent = resp.ok ? await resp.text() : '';
        } catch (e) {
          if ((0, _siteSecurity.isSSRFError)(e)) {
            (0, _logging.log)(`Import URL rejected as SSRF target: ${commandRun.options.importSite} (${e.message})`, 'error');
          } else {
            (0, _logging.log)(formatErrorForLogging(e), 'error');
          }
        }
        if (siteContent) {
          // @todo refactor to support 9 levels of hierarchy as this is technically what Drupal supports
          let dom = (0, _nodeHtmlParser.parse)(siteContent);
          // pull all of level 1 of hierarchy
          let depth;
          let order = 0;
          let parent = null;
          let items = [];
          for (let branch1 of dom.querySelectorAll('.section-2')) {
            parent = null;
            depth = 0;
            let itemID = branch1.getAttribute('id');
            let item = {
              id: itemID,
              order: order,
              indent: depth,
              title: branch1.querySelector('h1').innerText,
              slug: itemID.replace('-', '/'),
              // Security (H-5): sanitize scraped Drupal7 body HTML before storing.
              contents: (0, _siteSecurity.sanitizeIfString)(branch1.querySelector(`.field.field-name-body .field-item`).innerHTML),
              parent: parent
            };
            items.push(item);
            order++;
            depth = 1;
            let parent2 = itemID;
            let order2 = 0;
            for (let branch2 of branch1.querySelectorAll('.section-3')) {
              itemID = branch2.getAttribute('id');
              let item = {
                id: itemID,
                order: order2,
                indent: depth,
                title: branch2.querySelector('h1').innerText,
                slug: itemID.replace('-', '/'),
                // Security (H-5): sanitize scraped Drupal7 body HTML before storing.
                contents: (0, _siteSecurity.sanitizeIfString)(branch2.querySelector(`.field.field-name-body .field-item`).innerHTML),
                parent: parent2
              };
              items.push(item);
              order2++;
              depth = 2;
              let parent3 = itemID;
              let order3 = 0;
              for (let branch3 of branch2.querySelectorAll('.section-4')) {
                itemID = branch3.getAttribute('id');
                let item = {
                  id: itemID,
                  order: order3,
                  indent: depth,
                  title: branch3.querySelector('h1').innerText,
                  slug: itemID.replace('-', '/'),
                  // Security (H-5): sanitize scraped Drupal7 body HTML before storing.
                  contents: (0, _siteSecurity.sanitizeIfString)(branch3.querySelector(`.field.field-name-body .field-item`).innerHTML),
                  parent: parent3
                };
                items.push(item);
                order3++;
              }
            }
            // obtain all images on the system to bring along with additional spider request
            let location = new URL(commandRun.options.importSite).origin;
            var files = {};
            for (let image of dom.querySelectorAll("img[src^='/']")) {
              const imgSrc = image.getAttribute('src');
              if (imgSrc && !imgSrc.startsWith('//')) {
                // Security (H-3): validate each remote-controlled image URL
                // before recording it for download; reject SSRF targets.
                const imgUrl = `${location}${imgSrc}`;
                try {
                  await assertUrlNotSSRF(imgUrl);
                  files[imgSrc] = imgUrl;
                } catch (e) {
                  if (!commandRun.options.quiet) {
                    (0, _logging.log)(`Skipping image URL (SSRF-guarded): ${imgUrl}`, 'warn');
                  }
                }
              }
            }
            siteRequest.build.files = files;
          }
          siteRequest.build.structure = 'import';
          siteRequest.build.items = items;
        }
      }
    }
  }
  HAXCMS.cliWritePath = `${project.path}`;
  ensureTwigConstantFunction();
  let res = await invokeRoute(allRoutesLib.allRoutes.system.map.post['sites'], siteRequest, {
    user_token: "fakeToken",
    site_token: "fakeToken"
  });
  // so we run it and then clear the screen
  // this is a bit of a hack but it works to give the user the feedback that the site was
  // created successfully, but only if not in quiet mode (default)
  if (!commandRun.options.quiet) {
    process.stdout.write('\x1Bc');
    s.start((0, _statements.merlinSays)(`Creating new site: ${project.name}`));
    await (0, _promises.setTimeout)(1000);
  }
  // path different for this one as it's on the fly produced
  const recipeFileName = path.join(project.path, '/', project.name, `${siteRecipeFile}`);
  const recipeLogTransport = new winston.transports.File({
    filename: recipeFileName
  });
  const recipe = winston.createLogger({
    levels: logLevels,
    level: siteLoggingName,
    transports: [recipeLogTransport],
    format: winston.format.simple()
  });
  // matching the common object elsewhere tho different reference in this command since it creates from nothing
  // capture this if use input on the fly
  if (!commandRun.arguments.action) {
    commandRun.arguments.action = project.name;
  }
  commandRun.options.theme = project.theme;
  recipe.log(siteLoggingName, (0, _logging.commandString)(commandRun));
  if (commandRun.options.v) {
    logStructuredOutput(commandRun, res.data, 'silly');
  }
  if (!commandRun.options.quiet) {
    s.stop((0, _statements.merlinSays)(`${project.name} created successfully!`));
    await (0, _promises.setTimeout)(500);
  }

  // Write theme template to site/custom
  if (commandRun.options.theme === 'custom-theme' && commandRun.options.customThemeName && commandRun.options.customThemeTemplate || project.customThemeName && project.customThemeTemplate) {
    s.start((0, _statements.merlinSays)(`Creating new theme: ${commandRun.options.customThemeName ? commandRun.options.customThemeName : project.customThemeName}`));
    await customSiteTheme(commandRun, project);
    s.stop((0, _statements.merlinSays)(`${commandRun.options.customThemeName ? commandRun.options.customThemeName : project.customThemeName} theme created!`));
  }
  if (project.gitRepo && !commandRun.options.isMonorepo) {
    try {
      await (0, _utils.exec)(`cd ${project.path}/${project.name} && git init && git add -A && git commit -m "first commit" && git branch -M main${project.gitRepo ? ` && git remote add origin ${project.gitRepo}` : ''}`);
    } catch (e) {}
  }
  // ensure dot files is there because it doesn't copy for some reason for sites :\
  if (!fs.existsSync(`${project.path}/${project.name}/.gitignore`)) {
    await fs.copyFileSync(`${process.mainModule.path}/templates/sitedotfiles/_gitignore`, `${project.path}/${project.name}/.gitignore`);
  }
  if (!fs.existsSync(`${project.path}/${project.name}/._npmignore`)) {
    await fs.copyFileSync(`${process.mainModule.path}/templates/sitedotfiles/_npmignore`, `${project.path}/${project.name}/.npmignore`);
  }
  if (!fs.existsSync(`${project.path}/${project.name}/._surgeignore`)) {
    await fs.copyFileSync(`${process.mainModule.path}/templates/sitedotfiles/_surgeignore`, `${project.path}/${project.name}/.surgeignore`);
  }
  if (!fs.existsSync(`${project.path}/${project.name}/.netlifyignore`)) {
    await fs.copyFileSync(`${process.mainModule.path}/templates/sitedotfiles/_netlifyignore`, `${project.path}/${project.name}/.netlifyignore`);
  }
  if (!fs.existsSync(`${project.path}/${project.name}/.vercelignore`)) {
    await fs.copyFileSync(`${process.mainModule.path}/templates/sitedotfiles/_vercelignore`, `${project.path}/${project.name}/.vercelignore`);
  }
  // options for install, git and other extras
  // can't launch if we didn't install first so launch implies installation
  if (project.extras && project.extras.includes && project.extras.includes('launch')) {
    let optionPath = `${project.path}/${project.name}`;
    // Security (M-4): HOST=127.0.0.1 hint + scoped JWT-disable for local launch.
    let command = `HOST=127.0.0.1 HAXCMS_DISABLE_JWT_CHECKS=true npx @haxtheweb/haxcms-nodejs`;
    if (!commandRun.options.quiet) {
      p.note(`${(0, _statements.merlinSays)(`I have summoned a sub-process daemon 👹`)}

🚀  Running your ${_picocolors.default.bold(project.type)} ${_picocolors.default.bold(project.name)}:
${_picocolors.default.underline(_picocolors.default.cyan(`http://localhost:${port}`))}

🏠  Launched: ${_picocolors.default.underline(_picocolors.default.bold(_picocolors.default.yellow(_picocolors.default.bgBlack(`${optionPath}`))))}
💻  Folder: ${_picocolors.default.bold(_picocolors.default.yellow(_picocolors.default.bgBlack(`cd ${optionPath}`)))}
📂  Open folder: ${_picocolors.default.bold(_picocolors.default.yellow(_picocolors.default.bgBlack(`open ${optionPath}`)))}
📘  VS Code Project: ${_picocolors.default.bold(_picocolors.default.yellow(_picocolors.default.bgBlack(`code ${optionPath}`)))}
🚧  Launch later: ${_picocolors.default.bold(_picocolors.default.yellow(_picocolors.default.bgBlack(`${command}`)))}

⌨️  To resume 🧙 Merlin press: ${_picocolors.default.bold(_picocolors.default.black(_picocolors.default.bgRed(` CTRL + C or CTRL + BREAK `)))}
`);
    }
    // at least a second to see the message print at all
    await (0, _promises.setTimeout)(1000);
    try {
      await (0, _utils.exec)(`cd ${optionPath} && ${command}`);
    } catch (e) {
      // don't log bc output is weird
    }
  } else if (!commandRun.options.quiet) {
    let nextSteps = `cd ${project.path}/${project.name} && hax start`;
    p.note(`${project.name} is ready to go. Run the following to start working with it:`);
    p.outro(nextSteps);
  }
}
async function siteItemsOptionsList(activeHaxsite, skipId = null) {
  let items = activeHaxsite.manifest.orderTree(activeHaxsite.manifest.items);
  let optionItems = [];
  for (var i in items) {
    // ensure we remove self if operation is about page in question like parent selector
    if (items[i].id !== skipId) {
      optionItems.push({
        value: items[i].id,
        label: ` ${'-'.repeat(parseInt(items[i].indent))}${items[i].title}`
      });
    }
  }
  return optionItems;
}
async function siteSkeletonList(asOptions = false) {
  let skeletonListResponse = await invokeRoute(allRoutesLib.allRoutes.system.map.get['skeletons'], {}, {
    user_token: 'fakeToken'
  });
  let skeletons = [];
  if (skeletonListResponse && skeletonListResponse.data && Array.isArray(skeletonListResponse.data.data)) {
    skeletons = skeletonListResponse.data.data;
  }
  skeletons.sort((a, b) => {
    const aPriority = Number(a.priority);
    const bPriority = Number(b.priority);
    const normalizedAPriority = Number.isFinite(aPriority) ? aPriority : 0;
    const normalizedBPriority = Number.isFinite(bPriority) ? bPriority : 0;
    if (normalizedAPriority !== normalizedBPriority) {
      return normalizedAPriority - normalizedBPriority;
    }
    const aTitle = typeof a.title === 'string' ? a.title : '';
    const bTitle = typeof b.title === 'string' ? b.title : '';
    return aTitle.localeCompare(bTitle);
  });
  if (!asOptions) {
    return skeletons;
  }
  let options = [];
  for (var i in skeletons) {
    const skeleton = skeletons[i];
    const machineName = skeleton.machineName ? skeleton.machineName : skeleton['machine-name'] ? skeleton['machine-name'] : null;
    if (!machineName) {
      continue;
    }
    const title = skeleton.title ? skeleton.title : machineName;
    const scope = skeleton.scope ? `(${skeleton.scope})` : '';
    options.push({
      value: machineName,
      label: `${title} ${scope}`.trim()
    });
  }
  return options;
}
async function siteThemeList(coreOnly = true, directory = null) {
  const isTruthyValue = value => value === true || value === 'true' || value === 1 || value === '1';
  const isCommonTheme = (themeKey, themeData) => {
    const hidden = isTruthyValue(themeData.hidden);
    const terrible = isTruthyValue(themeData.terrible) || themeKey.indexOf('terrible') === 0;
    const legacy = isTruthyValue(themeData.legacy) || isTruthyValue(themeData.deprecated) || isTruthyValue(themeData.isLegacy);
    return !(hidden || terrible || legacy);
  };
  const themes = (await HAXCMS.getThemes()) || {};
  let items = [];
  let themedItems = [];
  for (var themeKey in themes) {
    const theme = themes[themeKey];
    if (!theme) {
      continue;
    }
    if (coreOnly && !isCommonTheme(themeKey, theme)) {
      continue;
    }
    const priority = Number(theme.priority);
    themedItems.push({
      value: themeKey,
      label: theme.name ? theme.name : themeKey,
      priority: Number.isFinite(priority) ? priority : 0
    });
  }
  themedItems.sort((a, b) => {
    if (a.priority !== b.priority) {
      return a.priority - b.priority;
    }
    return a.label.localeCompare(b.label);
  });
  for (var i in themedItems) {
    items.push({
      value: themedItems[i].value,
      label: themedItems[i].label
    });
  }
  if (coreOnly) {
    items.push({
      value: 'custom-theme',
      label: 'Create Custom Theme'
    });
    if (directory && fs.existsSync(`${directory}/custom/custom-elements.json`)) {
      try {
        const customThemeModules = JSON.parse(fs.readFileSync(`${directory}/custom/custom-elements.json`, 'utf8')).modules;
        if (Array.isArray(customThemeModules)) {
          for (var j in customThemeModules) {
            const module = customThemeModules[j];
            if (module && module.declarations && module.declarations[0] && module.declarations[0].superclass && module.declarations[0].superclass.name && (module.declarations[0].superclass.name === 'PolarisFlexTheme' || module.declarations[0].superclass.name === 'HAXCMSLitElementTheme') && module.declarations[0].tagName && !items.some(themeItem => themeItem.value === module.declarations[0].tagName)) {
              items.push({
                value: module.declarations[0].tagName,
                label: module.declarations[0].name ? module.declarations[0].name : module.declarations[0].tagName
              });
            }
          }
        }
      } catch (e) {}
    }
  }
  return items;
}
async function customSiteTheme(commandRun, project) {
  // pass theme name for twig templates
  project.customThemeName = commandRun.options.customThemeName ? commandRun.options.customThemeName : project.customThemeName;

  // validate start and end tags for theme name
  if (/^custom/.test(project.customThemeName) && !/^custom-/.test(project.customThemeName)) {
    project.customThemeName = project.customThemeName.replace(/^custom/, "custom-");
  } else if (!/^custom-/.test(project.customThemeName)) {
    project.customThemeName = `custom-${project.customThemeName}`;
  }
  if (/theme$/.test(project.customThemeName) && !/-theme$/.test(project.customThemeName)) {
    project.customThemeName = project.customThemeName.replace(/theme$/, "-theme");
  } else if (!/-theme$/.test(project.customThemeName)) {
    project.customThemeName = `${project.customThemeName}-theme`;
  }

  // set camel case class name
  project.className = (0, _utils.dashToCamel)(project.customThemeName);

  // path to hax site
  var sitePath;
  if (!commandRun.options.directory) {
    sitePath = `${commandRun.options.path ? commandRun.options.path : project.path}/${commandRun.options.name ? commandRun.options.name : project.name}`;
  } else {
    // existing sites
    sitePath = commandRun.options.directory;
  }

  // path to new theme file
  const filePath = `${sitePath}/custom/src/${project.customThemeName}.js`;
  if (!project.year) {
    project.year = new Date().getFullYear();
  }
  if (!project.author) {
    try {
      let value = await (0, _utils.exec)(`git config user.name`);
      project.author = value.stdout.trim();
    } catch (e) {
      (0, _logging.log)(`
          git user name not configured. Run the following to do this:\n
          git config --global user.name "namehere"\n
          git config --global user.email "email@here`, 'debug');
    }
  }

  // theme template to use
  const themeTemplate = commandRun.options.customThemeTemplate ? commandRun.options.customThemeTemplate : project.customThemeTemplate;
  if (themeTemplate === "polaris-flex") {
    await fs.copyFileSync(`${process.mainModule.path}/templates/sitetheme/flex-theme.js`, `${sitePath}/custom/src/flex-theme.js`);
    await fs.renameSync(`${sitePath}/custom/src/flex-theme.js`, filePath);
  } else if (themeTemplate === "polaris-sidebar") {
    await fs.copyFileSync(`${process.mainModule.path}/templates/sitetheme/sidebar-theme.js`, `${sitePath}/custom/src/sidebar-theme.js`);
    await fs.renameSync(`${sitePath}/custom/src/sidebar-theme.js`, filePath);
  } else {
    // vanilla theme is default
    await fs.copyFileSync(`${process.mainModule.path}/templates/sitetheme/base-theme.js`, `${sitePath}/custom/src/base-theme.js`);
    await fs.renameSync(`${sitePath}/custom/src/base-theme.js`, filePath);
  }
  try {
    // ensure we don't try to pattern rewrite image files
    if (!filePath.endsWith('.jpg') && !filePath.endsWith('.png')) {
      const ejsString = ejs.fileLoader(filePath, 'utf8');
      let content = ejs.render(ejsString, project);
      // file written successfully  
      fs.writeFileSync(filePath, content);
    }
  } catch (err) {
    console.error(filePath);
    console.error(err);
  }

  // import theme to custom.js
  await fs.appendFileSync(`${sitePath}/custom/src/custom.js`, `\n import "./${project.customThemeName}.js";`);
  var activeHaxsite = await systemStructureContext(sitePath);

  // add theme to site.json
  let themeObj = {
    element: project.customThemeName,
    path: "./custom/build/custom.es6.js",
    name: project.className
  };
  activeHaxsite.manifest.metadata.theme = themeObj;
  activeHaxsite.manifest.save(false);

  // install and build theme dependencies
  await (0, _utils.exec)(`cd ${sitePath}/custom/ && ${commandRun.options.npmClient} install && ${commandRun.options.npmClient} run build && ${commandRun.options.npmClient} run analyze && cd ${sitePath}`);
}

// @fork of the hax core util for this so that we avoid api difference between real dom and parse nodejs dom
async function nodeToHaxElement(node, eventName = "insert-element") {
  if (!node) {
    return null;
  }
  // build out the properties to send along
  var props = {};
  // support basic styles
  if (typeof node.getAttribute("style") !== typeof undefined) {
    props.style = node.getAttribute("style");
  }
  // don't set a null style
  if (props.style === null || props.style === "null") {
    delete props.style;
  }
  // test if a class exists, not everything scopes
  if (typeof node.getAttribute('class') !== typeof undefined) {
    props.class = node.getAttribute('class').replace("hax-active", "");
  }
  // test if a id exists as its a special case in attributes... of course
  if (typeof node.getAttribute('id') !== typeof undefined) {
    props.id = node.getAttribute("id");
  }
  let tmpProps;
  // weak fallback
  if (typeof tmpProps === typeof undefined) {
    tmpProps = node.__data;
  }
  // complex elements need complex support
  if (typeof tmpProps !== typeof undefined) {
    // run through attributes, though non-reflected props won't be here
    // run through props, we always defer to property values
    for (var property in tmpProps) {
      // make sure we only set things that have a value
      if (property != "class" && property != "style" && tmpProps.hasOwnProperty(property) && typeof node[property] !== undefined && node[property] != null && node[property] != "") {
        props[property] = node[property];
      }
      // special support for false boolean
      else if (node[property] === false) {
        props[property] = false;
      } else if (node[property] === true) {
        props[property] = true;
      } else if (node[property] === 0) {
        props[property] = 0;
      } else {
        // unknown prop setting / ignored
        //console.warn(node[property], property);
      }
    }
    for (var attribute in node._attrs) {
      // make sure we only set things that have a value
      if (typeof node._attrs[attribute] !== typeof undefined && attribute != "class" && attribute != "style" && attribute != "id" && typeof node._attrs[attribute] !== undefined && node._attrs[attribute] != null && node._attrs[attribute] != "") {
        props[attribute] = node._attrs[attribute];
      } else if (node._attrs[attribute] == "0") {
        props[attribute] = node._attrs[attribute];
      } else {
        // note: debug here if experiencing attributes that won't bind
      }
    }
  } else {
    // much easier case, usually just in primatives
    for (var attribute in node._attrs) {
      // make sure we only set things that have a value
      if (typeof node._attrs[attribute] !== typeof undefined && attribute != "class" && attribute != "style" && attribute != "id" && typeof node._attrs[attribute] !== undefined && node._attrs[attribute] != null && node._attrs[attribute] != "") {
        props[attribute] = node._attrs[attribute];
      }
    }
  }
  // support sandboxed environments which
  // will hate iframe tags but love webview
  let tag = node.tagName.toLowerCase();
  if (globalThis.HaxStore && globalThis.HaxStore.instance && globalThis.HaxStore.instance._isSandboxed && tag === "iframe") {
    tag = "webview";
  }
  let slotContent = '';
  // if hax store around, allow it to get slot content of the node
  if (globalThis.HaxStore && globalThis.HaxStore.instance) {
    slotContent = await globalThis.HaxStore.instance.getHAXSlot(node);
  } else {
    // if HAX isn't around, just return the innerHTML as a string for asignment to content
    slotContent = node.innerHTML;
  }
  // support fallback on inner text if there were no nodes
  if (slotContent == "") {
    slotContent = node.innerText;
  }
  let element = {
    tag: tag,
    properties: props,
    content: slotContent
  };
  if (eventName !== null) {
    element.eventName = eventName;
  }
  return element;
}