UNPKG

@haxtheweb/create

Version:

CLI for all things HAX the web

277 lines (264 loc) 9.79 kB
"use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.SITE_FILE_NAME = void 0; exports.camelToDash = camelToDash; exports.capitalizeFirstLetter = capitalizeFirstLetter; exports.dashToCamel = dashToCamel; exports.exec = void 0; exports.findAvailablePort = findAvailablePort; exports.generateUUID = generateUUID; exports.getTimeDifference = getTimeDifference; exports.interactiveExec = interactiveExec; exports.readAllFiles = readAllFiles; exports.readConfigFile = readConfigFile; exports.rejectShellMetacharacters = rejectShellMetacharacters; exports.spawn = void 0; exports.validateDomain = validateDomain; exports.validateNpmClient = validateNpmClient; exports.validateWebcomponentName = validateWebcomponentName; exports.writeConfigFile = writeConfigFile; var fs = _interopRequireWildcard(require("node:fs")); var os = _interopRequireWildcard(require("node:os")); var path = _interopRequireWildcard(require("node:path")); var child_process = _interopRequireWildcard(require("child_process")); var util = _interopRequireWildcard(require("node:util")); var _nodeNet = require("node:net"); var _nodeCrypto = require("node:crypto"); 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); } const exec = exports.exec = util.promisify(child_process.exec); const spawn = exports.spawn = child_process.spawn; function getTimeDifference(timestamp1, timestamp2) { const time1 = new Date(timestamp1).getTime(); const time2 = new Date(timestamp2).getTime(); if (isNaN(time1) || isNaN(time2)) { return "Invalid date format"; } const difference = Math.abs(time2 - time1); const seconds = Math.floor(difference / 1000); const minutes = Math.floor(seconds / 60); const hours = Math.floor(minutes / 60); const days = Math.floor(hours / 24); return { days, hours: hours % 24, minutes: minutes % 60, seconds: seconds % 60 }; } // write user config file function writeConfigFile(filename, data) { let tempDir = os.homedir(); if (process.env.VERCEL_ENV) { tempDir = "/tmp/"; } const filePath = path.join(tempDir, '.haxtheweb', filename); try { fs.writeFileSync(filePath, data); return filePath; } catch (error) { return null; } } // read user config file function readConfigFile(filename) { let tempDir = os.homedir(); if (process.env.VERCEL_ENV) { tempDir = "/tmp/"; } const filePath = path.join(tempDir, '.haxtheweb', filename); try { let file = fs.readFileSync(filePath, 'utf8'); return file; } catch (error) { return null; } } async function interactiveExec(command, args = [], options = {}) { return new Promise((resolve, reject) => { process.env.NODE_NO_WARNINGS = 1; const spawnOptions = { stdio: 'inherit', ...options }; if (process.platform === 'win32' && typeof spawnOptions.shell === 'undefined') { spawnOptions.shell = true; } const child = spawn(command, args, spawnOptions); child.on('exit', code => { if (code === 0) { resolve(); } else { reject(new Error(`Command failed with code ${code}`)); } }); child.on('error', err => { reject(err); }); }); } function findAvailablePort(startPort = 3000, maxPort = 65535) { return new Promise((resolve, reject) => { function tryPort(port) { if (port > maxPort) { reject(new Error('No available ports found')); return; } const server = (0, _nodeNet.createServer)(); server.once('error', err => { if (err.code === 'EADDRINUSE') { tryPort(port + 1); } else { reject(err); } }); server.once('listening', () => { server.close(() => resolve(port)); }); server.listen(port); } tryPort(startPort); }); } const SITE_FILE_NAME = exports.SITE_FILE_NAME = "site.json"; /** * Helper to convert dash to camel; important when reading attributes. */ function dashToCamel(str) { return capitalizeFirstLetter(str.replace(/-([a-z0-9])/g, function (g) { return g[1].toUpperCase(); })); } //capitalize only the first letter of the string. function capitalizeFirstLetter(string) { return string.charAt(0).toUpperCase() + string.slice(1); } // generate a cryptographically-secure unique id (L-3: was Math.random-based). function generateUUID() { return (0, _nodeCrypto.randomUUID)(); } /** * Helper to convert camel case to dash; important when setting attributes. */ function camelToDash(str) { return str.replace(/\W+/g, "-").replace(/([a-z\d])([A-Z])/g, "$1-$2").toLowerCase(); } // read in all files recursively for rewriting function* readAllFiles(dir) { const files = fs.readdirSync(dir, { withFileTypes: true }); for (const file of files) { if (file.isDirectory()) { yield* readAllFiles(path.join(dir, file.name)); } else { yield path.join(dir, file.name); } } } // Allowed npm clients that get interpolated into shell commands (M-2). // Keep this allowlist in sync with the --npm-client option help text. const ALLOWED_NPM_CLIENTS = new Set(["npm", "yarn", "pnpm"]); /** * Validate --npm-client against a strict allowlist. * Security (M-2): npmClient is interpolated into many exec() shell strings; * an unvalidated value like "npm; rm -rf ~" would be command injection. * Returns the client if allowed, otherwise throws. * @param {string} client * @returns {string} */ function validateNpmClient(client) { if (typeof client !== "string" || !ALLOWED_NPM_CLIENTS.has(client)) { throw new Error(`Invalid --npm-client "${client}". Allowed values: ${Array.from(ALLOWED_NPM_CLIENTS).join(", ")}`); } return client; } // Security (M-1): characters that are dangerous when a value is interpolated // into an exec() shell string. Rejecting these at the option boundary closes // the command-injection vector at every exec(...${opt}...) call site without // requiring a full exec()->spawn() migration of the publish/clone flows. const SHELL_METACHARACTER_RE = /[;&|`$<>!(){}#\n\r\\]/; /** * Reject shell metacharacters in a value that will be interpolated into a * shell string. Returns the value if safe, otherwise throws naming the option. * @param {string} value * @param {string} optionName * @returns {string} */ function rejectShellMetacharacters(value, optionName) { if (value === undefined || value === null || value === '') { return value; } if (typeof value !== 'string' || SHELL_METACHARACTER_RE.test(value)) { throw new Error(`Invalid --${optionName} value: shell metacharacters are not allowed.`); } return value; } // Security (M-1): allowlist for --domain / --site (surge/netlify/vercel deploy // targets). Hostnames, ports, and dotted domain labels only. const DOMAIN_RE = /^[A-Za-z0-9.\-:]+$/; /** * Validate a publish --domain value against a strict hostname/domain charset. * @param {string} value * @returns {string} */ function validateDomain(value) { if (value === undefined || value === null || value === '') { return value; } if (typeof value !== 'string' || !DOMAIN_RE.test(value)) { throw new Error(`Invalid --domain value: only letters, digits, dots, hyphens, and colons are allowed.`); } return value; } const reservedNames = ["annotation-xml", "color-profile", "font-face", "font-face-src", "font-face-uri", "font-face-format", "font-face-name", "missing-glyph"]; /** * Validate a web component name. Returns an error string if invalid, or null if valid. * @param {string} value - the proposed name * @param {object} options * @param {object} [options.wcReg] - wc-registry object to check for collisions * @param {boolean} [options.force] - skip wc-registry collision check * @param {string} [options.joint] - base directory to check for existing folder * @param {boolean} [options.checkExists] - whether to check if directory already exists * @returns {string|null} error message or null */ function validateWebcomponentName(value, options = {}) { const { wcReg, force, joint, checkExists = true } = options; if (!value) { return "Name is required (Enter accepts default)"; } if (reservedNames.includes(value)) { return `Reserved name ${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."; } if (!/^[a-z][a-z0-9.\-]*\-[a-z0-9.\-]*$/.test(value)) { return `Name must follow the syntax my-component`; } if (wcReg && wcReg[value] && !force) { return "Name is already a web component in the wc-registry published for HAX."; } if (checkExists && joint && fs.existsSync(path.join(joint, value))) { return `${path.join(joint, value)} exists, rename this project`; } return null; }