@haxtheweb/create
Version:
CLI for all things HAX the web
1,039 lines • 53.8 kB
JavaScript
#!/usr/bin/env node
// forces middleware into CLI mode so we don't automatically perform certain operations like pathing context
"use strict";
var fs = _interopRequireWildcard(require("node:fs"));
var path = _interopRequireWildcard(require("node:path"));
var p = _interopRequireWildcard(require("@clack/prompts"));
var _picocolors = _interopRequireDefault(require("picocolors"));
var _statements = require("./lib/statements.js");
var _logging = require("./lib/logging.js");
var _audit = require("./lib/programs/audit.js");
var _party = require("./lib/programs/party.js");
var _webcomponent = require("./lib/programs/webcomponent.js");
var _site = require("./lib/programs/site.js");
var _skills = require("./lib/programs/skills.js");
var _utils = require("./lib/utils.js");
var haxcmsLib = _interopRequireWildcard(require("@haxtheweb/haxcms-nodejs/dist/lib/HAXCMS.js"));
var _commander = require("commander");
var _package = _interopRequireDefault(require("../package.json"));
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); }
process.env.haxcms_middleware = "node-cli";
const HAXCMS = haxcmsLib.HAXCMS;
const systemStructureContext = haxcmsLib.systemStructureContext;
// check the last time this was run
let lastTime = (0, _utils.readConfigFile)('hax-cli-last-run');
// first run in the event it is null assume now
if (!lastTime) {
lastTime = new Date().toISOString();
}
// check time difference broken down by unit
const timeSince = (0, _utils.getTimeDifference)(new Date().toISOString(), lastTime);
// write last run so that we know last time they were here
(0, _utils.writeConfigFile)('hax-cli-last-run', new Date().toISOString());
let sysGit = true;
(0, _utils.exec)('which git', error => {
if (error) {
sysGit = false;
}
});
async function main() {
let wcReg = {};
let regPath = path.join(__dirname, './lib/wc-registry.json');
if (fs.existsSync(regPath)) {
try {
wcReg = JSON.parse(fs.readFileSync(regPath));
} catch (e) {
// no registry for testing, probably busted package
}
}
var commandRun = {};
_commander.program.option('--v', 'Verbose output').option('--debug', 'Output for developers').option('--format <char>', 'Output format; json (default), yaml').option('--path <char>', 'where to perform operation').option('--name <char>', 'name of the project/web component').option('--npm-client <char>', 'npm client to use (must be installed) npm, yarn, pnpm', 'npm').option('--y', 'yes to all questions').option('--skip', 'skip frills like animations').option('--quiet', 'remove console logging').option('--auto', 'yes to all questions, alias of y').option('--no-i', 'prevent interactions / sub-process, good for scripting').option('--to-file <char>', 'redirect command output to a file').option('--no-extras', 'skip all extra / automatic command processing').option('--root <char>', 'root location to execute the command from')
// options for webcomponent
.option('--org <char>', 'organization for package.json').option('--author <char>', 'author for site / package.json').option('--template <char>', 'template type to use (compliant, minimal, training, monorepo)').option('--writeHaxProperties', 'Write haxProperties for the element').option('--force', 'force creation even if name exists in registry')
// options for site
.option('--import-site <char>', 'URL of site to import').option('--import-structure <char>', `import method to use:\n\rpressbooksToSite\n\relmslnToSite\n\rhaxcmsToSite\n\rnotionToSite\n\rgitbookToSite\n\revolutionToSite\n\rploneToSite\n\rwordpressPagesToSite\n\rdrupalBookToSite\n\rhtmlToSite\n\rdocxToSite`).option('--node-op <char>', 'node operation to perform').option('--item-id <char>', 'node ID to operate on').option('--domain <char>', 'published domain name').option('--title-scrape <char>', 'CSS Selector for `title` in resource').option('--content-scrape <char>', 'CSS Selector for `body` in resource').option('--items-import <char>', 'import items from a file / site').option('--recipe <char>', 'path to recipe file').option('--custom-theme-name <char>', 'custom theme name').option('--custom-theme-template <char>', 'custom theme template; (options: base, polaris-flex, polaris-sidebar)').option('--skeleton-file <char>', 'path to skeleton JSON file').option('--skeleton-machine-name <char>', 'skeleton machine name (installed template) to create a site from').option('--search <char>', 'search query text or simple selector (for site:search)').option('--search-field <char>', 'comma-separated fields for text search (title,slug,description,tags,content,all)').option('--search-case-sensitive', 'case-sensitive text search').option('--search-limit <char>', 'max number of site search matches to return').option('--search-selector', 'treat --search as a selector query (tag, tag[attr], tag[attr=\"value\"], [attr])').option('--search-mode <char>', 'search mode override (text or selector)')
// options for rsync
.option('--source <char>', 'rsync source directory or remote path').option('--destination <char>', 'rsync destination directory or remote path').option('--exclude <char>', 'comma-separated patterns to exclude from rsync').option('--dry-run', 'perform rsync dry run').option('--delete', 'delete extraneous files from destination')
// options for party
.option('--repos <char...>', 'repositories to clone')
// options for new site commands
.option('--export-format <char>', 'export format for site:export (pdf, docx, epub, html, zip, markdown, skeleton)').option('--revision-id <char>', 'revision ID for site:revisions').option('--restore', 'restore a revision (used with site:revisions)').option('--target-path <char>', 'target subdirectory for files-upload').option('--file-uuid <char>', 'file UUID for files-delete').option('--file-path <char>', 'file path (alternative to UUID) for files-delete').option('--replace <char>', 'replacement text for search-replace').option('--confirm', 'confirm destructive search-replace operation').option('--destroy-confirm', 'confirm removing matched text in search-replace').version(_package.default.version).helpCommand(true);
// default command which runs interactively
_commander.program.command('start').description('Select which hax sub-program to run').action(() => {
commandRun = {
command: 'start',
arguments: {},
options: {}
};
});
_commander.program.command('update').description('hax cli self update').option('--y', 'yes to all questions').action(() => {
commandRun = {
command: 'update',
arguments: {},
options: {}
};
});
_commander.program.command('serve').description('Launch HAXsite in development mode (http://localhost)').action(() => {
commandRun = {
command: 'serve',
arguments: {
action: 'serve'
},
options: {}
};
});
// site operations and actions
let strActions = '';
(0, _site.siteActions)().forEach(action => {
strActions += `${action.value} - ${action.label}` + "\n\r";
});
let siteProg = _commander.program.command('site').description('create or administer a HAXsite').argument('[action]', 'Actions to perform on site include:' + "\n\r" + strActions).action(action => {
commandRun = {
command: 'site',
arguments: {},
options: {}
};
if (action) {
commandRun.arguments.action = action;
commandRun.options.skip = true;
}
}).option('--v', 'Verbose output').option('--debug', 'Output for developers').option('--format <char>', 'Output format; json (default), yaml').option('--path <char>', 'where to perform operation').option('--npm-client <char>', 'npm client to use (must be installed) npm, yarn, pnpm', 'npm').option('--y', 'yes to all questions').option('--skip', 'skip frills like animations').option('--quiet', 'remove console logging').option('--auto', 'yes to all questions, alias of y').option('--no-i', 'prevent interactions / sub-process, good for scripting').option('--to-file <char>', 'redirect command output to a file').option('--no-extras', 'skip all extra / automatic command processing').option('--root <char>', 'root location to execute the command from').option('--import-site <char>', 'URL of site to import').option('--import-structure <char>', `import method to use:\n\rpressbooksToSite\n\relmslnToSite\n\rhaxcmsToSite\n\rnotionToSite\n\rgitbookToSite\n\revolutionToSite\n\rploneToSite\n\rwordpressPagesToSite\n\rdrupalBookToSite\n\rhtmlToSite\n\rdocxToSite`).option('--name <char>', 'name of the site (when creating a new one)').option('--domain <char>', 'published domain name').option('--node-op <char>', 'node operation to perform').option('--title-scrape <char>', 'CSS Selector for `title` in resource').option('--content-scrape <char>', 'CSS Selector for `body` in resource').option('--items-import <char>', 'import items from a file / site').option('--recipe <char>', 'path to recipe file').option('--custom-theme-name <char>', 'custom theme name').option('--custom-theme-template <char>', 'custom theme template (options: base, polaris-flex, polaris-sidebar)').option('--skeleton-file <char>', 'path to skeleton JSON file').option('--skeleton-machine-name <char>', 'skeleton machine name (installed template) to create a site from').option('--search <char>', 'search query text or simple selector').option('--search-field <char>', 'comma-separated fields for text search (title,slug,description,tags,content,all)').option('--search-case-sensitive', 'case-sensitive text search').option('--search-limit <char>', 'max number of site search matches to return').option('--search-selector', 'treat --search as a selector query (tag, tag[attr], tag[attr=\"value\"], [attr])').option('--search-mode <char>', 'search mode override (text or selector)').option('--source <char>', 'rsync source directory or remote path').option('--destination <char>', 'rsync destination directory or remote path').option('--exclude <char>', 'comma-separated patterns to exclude from rsync').option('--dry-run', 'perform rsync dry run').option('--delete', 'delete extraneous files from destination').option('--export-format <char>', 'export format for site:export (pdf, docx, epub, html, zip, markdown, skeleton)').option('--revision-id <char>', 'revision ID for site:revisions').option('--restore', 'restore a revision (used with site:revisions)').option('--target-path <char>', 'target subdirectory for files-upload').option('--file-uuid <char>', 'file UUID for files-delete').option('--file-path <char>', 'file path (alternative to UUID) for files-delete').option('--replace <char>', 'replacement text for search-replace').option('--confirm', 'confirm destructive search-replace operation').option('--destroy-confirm', 'confirm removing matched text in search-replace').version(_package.default.version);
let siteNodeOps = (0, _site.siteNodeOperations)();
for (var i in siteNodeOps) {
_commander.program.option(`--${(0, _utils.camelToDash)(siteNodeOps[i].value)} <char>`, `${siteNodeOps[i].label}`);
siteProg.option(`--${(0, _utils.camelToDash)(siteNodeOps[i].value)} <char>`, `${siteNodeOps[i].label}`);
}
// webcomponent program
let strWebcomponentActions = '';
(0, _webcomponent.webcomponentActions)().forEach(action => {
strWebcomponentActions += `${action.value} - ${action.label}` + "\n\r";
});
_commander.program.command('wc').alias('webcomponent').description('Create Lit based web components, with HAX recommendations').argument('[action]', 'Actions to perform on web component include:' + "\n\r" + strWebcomponentActions).action(action => {
commandRun = {
command: 'webcomponent',
arguments: {},
options: {}
};
// if name set, populate
if (action) {
commandRun.arguments.action = action;
commandRun.options.skip = true;
}
}).option('--path <char>', 'path the project should be created in').option('--org <char>', 'organization for package.json').option('--author <char>', 'author for site / package.json').option('--name <char>', 'name of the web component').option('--writeHaxProperties', 'Write haxProperties for the element').option('--template <char>', 'template type to use (compliant, minimal, training, monorepo)').option('--to-file <char>', 'redirect command output to a file').option('--no-extras', 'skip all extra / automatic command processing').option('--no-i', 'prevent interactions / sub-process, good for scripting').option('--root <char>', 'root location to execute the command from').option('--force', 'force creation even if name exists in registry').version(_package.default.version);
// audit program
_commander.program.command('audit').description('Audits web components for compliance with DDD (HAX design system)').action(() => {
commandRun = {
command: 'audit',
arguments: {},
options: {}
};
}).option('--debug', 'Output for developers').version(_package.default.version);
_commander.program.command('party').description('Party time! Join the HAX community and get involved!').argument('[action]', 'Actions to perform on web component include:' + "\n\r" + "test").action(action => {
commandRun = {
command: 'party',
arguments: {},
options: {}
};
if (action) {
commandRun.arguments.action = action;
commandRun.options.skip = true;
}
}).option('--root <char>', 'root location to execute the command from').option('--y', 'yes to all questions').option('--auto', 'yes to all questions, alias of y').option('--repos <char...>', 'repositories to clone').version(_package.default.version);
// skills program
let strSkillsActions = '';
(0, _skills.skillsActions)().forEach(action => {
strSkillsActions += `${action.value} - ${action.label}` + "\n\r";
});
_commander.program.command('skills').description('List and install bundled agent skills for HAX ecosystem agents').argument('[action]', 'Actions to perform:' + "\n\r" + strSkillsActions).action(action => {
commandRun = {
command: 'skills',
arguments: {},
options: {}
};
if (action) {
commandRun.arguments.action = action;
commandRun.options.skip = true;
}
}).option('--skill-name <char>', 'name of the skill to install (or "all")').option('--path <char>', 'target directory for skill installation (default: ~/.agents/skills/)').option('--y', 'yes to all questions').option('--auto', 'yes to all questions, alias of y').option('--no-i', 'prevent interactions / sub-process, good for scripting').option('--format <char>', 'Output format; json (default), yaml').option('--quiet', 'remove console logging').version(_package.default.version);
// process program arguments
_commander.program.parse();
commandRun.options = {
...commandRun.options,
..._commander.program.opts()
};
// this is a false positive bc of the no-extras flag. no-extras does not imply true if not there
// but that's how the CLI works. This then bombs things downstream. Its good to be false but NOT
// good to be true since we need an array of options
if (commandRun.options.extras === true) {
delete commandRun.options.extras;
}
// Security (M-2): validate the CLI-provided --npm-client early, before the
// packageData loop below can overwrite it. The loop unconditionally resets
// npmClient from the local package.json (or 'npm'), so a malicious CLI
// value would be clobbered before the post-loop check — validate it here
// while it still reflects the user's --npm-client argument.
if (commandRun.options.npmClient) {
try {
commandRun.options.npmClient = (0, _utils.validateNpmClient)(commandRun.options.npmClient);
} catch (e) {
_commander.program.error(_picocolors.default.red(e.message));
}
}
// Security (M-1/L-5): validate --root before chdir so a missing/invalid
// root fails with a clear message instead of a process.chdir exception.
// --root is not shell-interpolated (recipe replay now uses spawn args), but
// the metacharacter check is kept as defense-in-depth.
if (commandRun.options.root) {
try {
(0, _utils.rejectShellMetacharacters)(commandRun.options.root, 'root');
if (!fs.existsSync(commandRun.options.root) || !fs.statSync(commandRun.options.root).isDirectory()) {
_commander.program.error(_picocolors.default.red(`Invalid --root: ${commandRun.options.root} is not an existing directory.`));
}
} catch (e) {
_commander.program.error(_picocolors.default.red(e.message));
}
}
// allow execution of the command from a different location
if (commandRun.options.root) {
process.chdir(commandRun.options.root);
}
// bridge to log so we can respect this setting
if (commandRun.options.quiet) {
process.haxquiet = true;
// don't output to the console any messages on this run since told to be silent
// logging still happens to log files
_logging.logger.remove(_logging.consoleTransport);
}
if (commandRun.options.debug) {
(0, _logging.log)(commandRun, 'debug');
}
// auto and y assume same thing
if (commandRun.options.y || commandRun.options.auto) {
commandRun.options.y = true;
commandRun.options.auto = true;
}
let author = '';
// should be able to grab if not predefined
try {
let value = await (0, _utils.exec)(`git config user.name`);
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');
}
// skills install manages its own default (~/.agents/skills/) in skills.js,
// so don't let the action-triggered cwd default override it
if (!commandRun.options.path && commandRun.options.skip && commandRun.command !== 'skills') {
commandRun.options.path = process.cwd();
}
// if we skip stuff then set org/author automatically
if (commandRun.options.skip || commandRun.options.auto) {
if (!commandRun.options.org) {
commandRun.options.org = '';
}
if (!commandRun.options.author) {
commandRun.options.author = author;
}
}
// validate theme cli commands
if (commandRun.options.theme !== 'custom-theme' && (commandRun.options.customThemeName || commandRun.options.customThemeTemplate)) {
_commander.program.error(_picocolors.default.red('ERROR: You can only use the --custom-theme-name option with --theme custom-theme'));
}
let packageData = {};
let testPackages = [path.join(process.cwd(), 'package.json'), path.join(process.cwd(), '../', 'package.json'), path.join(process.cwd(), '../', '../', 'package.json')];
// test within reason, for package.json files seeing if anything is available to suggest
// that we might be in a local package or a monorepo.
while (testPackages.length > 0) {
let packLoc = testPackages.shift();
if (fs.existsSync(packLoc)) {
try {
packageData = {
...JSON.parse(fs.readFileSync(packLoc)),
...packageData
};
// assume we are working on a web component / existing if we find this key
if (packageData.hax && packageData.hax.cli) {
commandRun.program = 'webcomponent';
}
// leverage these values if they exist downstream
if (packageData.npmClient) {
commandRun.options.npmClient = packageData.npmClient;
} else {
commandRun.options.npmClient = 'npm';
}
// see if we're in a monorepo
if (packageData.useWorkspaces && packageData.workspaces && packageData.workspaces.packages && packageData.workspaces.packages[0]) {
if (!commandRun.options.quiet) {
p.intro(`${_picocolors.default.bgBlack(_picocolors.default.white(` Monorepo detected : Setting relative defaults `))}`);
}
commandRun.options.isMonorepo = true;
commandRun.options.auto = true;
commandRun.options.template = 'monorepo';
// assumed if monorepo
if (commandRun.command === "audit") {
(0, _audit.auditCommandDetected)(commandRun);
} else {
commandRun.command = 'webcomponent';
commandRun.options.path = path.join(process.cwd(), packageData.workspaces.packages[0].replace('/*', ''));
if (packageData.orgNpm) {
commandRun.options.org = packageData.orgNpm;
}
commandRun.options.gitRepo = packageData.repository.url;
commandRun.options.author = packageData.author.name ? packageData.author.name : author;
}
}
} catch (err) {
console.error(err);
}
}
}
if (commandRun.options.debug) {
(0, _logging.log)(packageData, 'debug');
}
// Security (M-2): validate --npm-client against a strict allowlist before it
// can be interpolated into any exec() shell string downstream. This runs
// after the packageData merge so a monorepo's npmClient is also covered.
if (commandRun.options.npmClient) {
try {
commandRun.options.npmClient = (0, _utils.validateNpmClient)(commandRun.options.npmClient);
} catch (e) {
_commander.program.error(_picocolors.default.red(e.message));
}
}
// Security (M-1): reject shell metacharacters in options that are
// interpolated into exec() shell strings across site.js/webcomponent.js/
// party.js (publish targets, git remote, rsync source/dest, excludes).
// --domain additionally gets a strict hostname charset. --path must be an
// existing directory (--root was validated earlier, before chdir). Validates
// at the boundary so the existing exec(...${opt}...) call sites are no longer
// exploitable, without a full exec()->spawn() migration.
try {
if (commandRun.options.domain) {
commandRun.options.domain = (0, _utils.validateDomain)(commandRun.options.domain);
}
(0, _utils.rejectShellMetacharacters)(commandRun.options.author, 'author');
(0, _utils.rejectShellMetacharacters)(commandRun.options.gitRepo, 'git-repo');
(0, _utils.rejectShellMetacharacters)(commandRun.options.source, 'source');
(0, _utils.rejectShellMetacharacters)(commandRun.options.destination, 'destination');
if (commandRun.options.exclude) {
String(commandRun.options.exclude).split(',').forEach(pat => (0, _utils.rejectShellMetacharacters)(pat.trim(), 'exclude'));
}
if (commandRun.options.path) {
(0, _utils.rejectShellMetacharacters)(commandRun.options.path, 'path');
if (!fs.existsSync(commandRun.options.path) || !fs.statSync(commandRun.options.path).isDirectory()) {
_commander.program.error(_picocolors.default.red(`Invalid --path: ${commandRun.options.path} is not an existing directory.`));
}
}
} catch (e) {
_commander.program.error(_picocolors.default.red(e.message));
}
// test for updating to latest or just run the command
if (commandRun.command === "update") {
await testForUpdates(commandRun);
} else if (commandRun.command === 'audit') {
let customPath = null;
// test for haxcms context
if ((await systemStructureContext()) && fs.existsSync(`${process.cwd()}/custom`)) {
customPath = path.join(process.cwd(), 'custom');
}
(0, _audit.auditCommandDetected)(commandRun, customPath);
} else if (commandRun.command === 'party') {
commandRun.options.author = author;
await (0, _party.partyCommandDetected)(commandRun);
} else if (commandRun.command === 'skills') {
await (0, _skills.skillsCommandDetected)(commandRun);
}
// CLI works within context of the site if one is detected, otherwise we can do other things
else if (await systemStructureContext()) {
if (commandRun.command === 'serve') {
commandRun.program = 'serve';
commandRun.options.skip = true;
await (0, _site.siteCommandDetected)(commandRun);
} else {
commandRun.program = 'site';
commandRun.options.skip = true;
await (0, _site.siteCommandDetected)(commandRun);
}
} else if (packageData && (packageData.customElements || packageData.hax && packageData.hax.cli) && packageData.scripts.start) {
if (commandRun.command === 'serve') {
commandRun.program = 'serve';
commandRun.options.skip = true;
await (0, _webcomponent.webcomponentCommandDetected)(commandRun, packageData);
} else {
commandRun.program = 'webcomponent';
commandRun.options.skip = true;
await (0, _webcomponent.webcomponentCommandDetected)(commandRun, packageData);
}
} else {
if (commandRun.command === 'start' && !commandRun.options.y && !commandRun.options.auto && !commandRun.options.skip && !commandRun.options.quiet) {
await (0, _statements.haxIntro)();
}
// it's been 7 days since last check, do the check and offer command to resolve if needed
if (timeSince.days > 7) {
// check for updates
let execOut = await (0, _utils.exec)(`npm view @haxtheweb/create version`);
let updateCheck = execOut.stdout.trim();
if (updateCheck !== _package.default.version && !commandRun.options.quiet) {
p.intro(`${_picocolors.default.bgBlack(_picocolors.default.white(` HAX cli updates available! `))}`);
p.intro(`Current version: ${_package.default.version}`);
p.intro(`Latest version: ${updateCheck}`);
p.intro(`Run ${_picocolors.default.bold(_picocolors.default.black(_picocolors.default.bgGreen('hax update')))} to update to the latest version`);
}
}
let activeProject = null;
let project = {
type: null
};
while (project.type !== 'quit' && project.type !== 'update') {
if (activeProject) {
if (!commandRun.options.quiet) {
p.note(` 🧙🪄 BE GONE ${_picocolors.default.bold(_picocolors.default.black(_picocolors.default.bgGreen(activeProject)))} sub-process daemon! 🪄 + ✨ 👹 = 💀 `);
}
// ensure if we were automatically running the command we end
if (commandRun.options.y) {
if (!commandRun.options.quiet) {
(0, _statements.communityStatement)();
}
process.exit(0);
}
// otherwise null to reset the program to run again
commandRun = {
command: null,
arguments: {},
options: {}
};
}
if (['site', 'webcomponent', 'audit'].includes(commandRun.command)) {
project = {
type: commandRun.command
};
} else if (commandRun.options.i) {
let buildOptions = [{
value: 'webcomponent',
label: '🏗️ Create a Web Component'
}, {
value: 'site',
label: '🏡 Create a HAXsite'
}, {
value: 'party',
label: '🎉 Join the HAX community'
}, {
value: 'update',
label: '🤓 Check for hax cli updates'
}, {
value: 'quit',
label: '🚪 Quit'
}];
project = await p.group({
type: ({
results
}) => p.select({
message: !activeProject ? `What should we build?` : `Thirsty for more? What should we create now?`,
initialValue: 'webcomponent',
required: true,
options: buildOptions
})
}, {
onCancel: () => {
if (!commandRun.options.quiet) {
p.cancel('🧙🪄 Merlin: Canceling cli.. HAX ya later');
(0, _statements.communityStatement)();
}
process.exit(0);
}
});
} else if (!commandRun.options.i) {
process.exit(0);
}
if (project.type === "update") {
await testForUpdates(commandRun);
} else if (project.type === "party") {
await (0, _party.partyCommandDetected)(commandRun);
}
// detect being in a haxcms scaffold. easiest way is _sites being in this directory
// set the path automatically so we skip the question
else if (commandRun.command === "site" && fs.existsSync(`${process.cwd()}/_sites`)) {
if (!commandRun.options.quiet) {
p.intro(`${_picocolors.default.bgBlack(_picocolors.default.white(` HAXcms detected : Path set automatically `))}`);
}
commandRun.options.path = `${process.cwd()}/_sites`;
}
activeProject = project.type;
// silly but this way we don't have to take options for quitting
if (['site', 'webcomponent'].includes(project.type)) {
// global spot for core themes list
let coreThemes = await (0, _site.siteThemeList)(true);
const cleanOneTheme = coreThemes.find(themeOption => themeOption.value === 'clean-one');
const defaultCoreTheme = cleanOneTheme ? cleanOneTheme : coreThemes[0];
let availableSkeletons = await (0, _site.siteSkeletonList)(true);
project = await p.group({
type: ({
results
}) => {
return new Promise((resolve, reject) => {
resolve(activeProject);
});
},
path: ({
results
}) => {
let initialPath = `${process.cwd()}`;
if (!commandRun.options.path && !commandRun.options.auto && !commandRun.options.skip) {
return p.text({
message: `What folder will your ${commandRun.command === "webcomponent" || results.type === "webcomponent" ? "project" : "site"} live in?`,
placeholder: initialPath,
initialValue: initialPath,
required: true,
validate: value => {
if (!value) {
return "Path is required (Enter accepts default)";
}
if (!fs.existsSync(value)) {
return `${value} does not exist. Select a valid folder`;
}
}
});
}
},
name: ({
results
}) => {
const reservedNames = ["annotation-xml", "color-profile", "font-face", "font-face-src", "font-face-uri", "font-face-format", "font-face-name", "missing-glyph"];
if (!commandRun.arguments.action && !commandRun.options.name) {
if (commandRun.options.auto || commandRun.options.skip || !commandRun.options.i) {
_commander.program.error(_picocolors.default.red("Name is required when running non-interactively. Pass --name <value>."));
process.exit(1);
}
let placeholder = "mysite";
let message = "Site name:";
if (commandRun.command === "webcomponent" || results.type === "webcomponent") {
placeholder = "my-element";
message = "Element name:";
}
return p.text({
message: message,
placeholder: placeholder,
initialValue: placeholder,
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 (results.type === "webcomponent" && (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.";
}
// test that this is not an existing element we use in the registry
if (results.type === "webcomponent" && wcReg[value] && !commandRun.options.force) {
return "Name is already a web component in the wc-registry published for HAX.";
}
// Check for any other syntax errors
if (results.type === "webcomponent" && !/^[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;
} else if (results.path) {
joint = results.path;
}
if (fs.existsSync(path.join(joint, value))) {
return `${path.join(joint, value)} exists, rename this project`;
}
}
});
}
if (commandRun.arguments.action || commandRun.options.name) {
let knownActions = [];
if (results.type === "webcomponent") {
knownActions = (0, _webcomponent.webcomponentActions)().map(action => action.value);
} else if (results.type === "site") {
knownActions = (0, _site.siteActions)().map(action => action.value);
}
let value = knownActions.includes(commandRun.arguments.action) ? commandRun.options.name : commandRun.arguments.action || commandRun.options.name;
if (!value) {
_commander.program.error(_picocolors.default.red("Name is required (Enter accepts default)."));
process.exit(1);
}
if (reservedNames.includes(value)) {
_commander.program.error(_picocolors.default.red(`Reserved name ${_picocolors.default.bold(value)} cannot be used`));
process.exit(1);
}
if (value.toLocaleLowerCase() !== value) {
_commander.program.error(_picocolors.default.red("Name must be lowercase"));
process.exit(1);
}
if (/^\d/.test(value)) {
_commander.program.error(_picocolors.default.red("Name cannot start with a number"));
process.exit(1);
}
if (value.indexOf(' ') !== -1) {
_commander.program.error(_picocolors.default.red("No spaces allowed in name"));
process.exit(1);
}
if (results.type === "webcomponent" && (value.indexOf('-') === -1 || value.replace('--', '') !== value || value[0] === '-' || value[value.length - 1] === '-')) {
_commander.program.error(_picocolors.default.red("Name must include at least one `-` and must not start or end name."));
process.exit(1);
}
// test that this is not an existing element we use in the registry
if (results.type === "webcomponent" && wcReg[value] && !commandRun.options.force) {
_commander.program.error(_picocolors.default.red("Name is already a web component in the wc-registry published for HAX."));
process.exit(1);
}
// Check for any other syntax errors
if (results.type === "webcomponent" && !/^[a-z][a-z0-9.\-]*\-[a-z0-9.\-]*$/.test(value)) {
_commander.program.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;
} else if (results.path) {
joint = results.path;
}
if (fs.existsSync(path.join(joint, value))) {
_commander.program.error(_picocolors.default.red(`${path.join(joint, value)} exists, rename this project`));
process.exit(1);
}
}
},
org: ({
results
}) => {
if (results.type === "webcomponent" && !commandRun.options.org && !commandRun.options.auto && !commandRun.options.skip) {
let initialOrg = '@yourOrganization';
return p.text({
message: 'Organization:',
placeholder: initialOrg,
initialValue: initialOrg,
required: false,
validate: value => {
if (value && !value.startsWith('@')) {
return "Organizations are not required, but organizations must start with @ if used";
}
}
});
}
},
author: ({
results
}) => {
if (!commandRun.options.author && !commandRun.options.auto && !commandRun.options.skip) {
return p.text({
message: 'Author:',
required: false,
initialValue: author
});
}
},
template: ({
results
}) => {
if (results.type === 'webcomponent' && !commandRun.options.isMonorepo && !commandRun.options.template && !commandRun.options.auto && !commandRun.options.skip && commandRun.options.i) {
return p.select({
message: 'Template:',
required: false,
options: [{
value: 'compliant',
label: 'Standard — DDD, i18n, HAX, full build (recommended)'
}, {
value: 'minimal',
label: 'Minimal — Lit only, no docs, no build pipeline'
}, {
value: 'training',
label: 'Training — Heavily documented tutorial example'
}],
initialValue: 'compliant'
});
}
},
siteCreationMode: async ({
results
}) => {
if (results.type === "site") {
const hasSkeletonFile = Boolean(commandRun.options.skeletonFile);
const hasSkeletonMachineName = Boolean(commandRun.options.skeletonMachineName);
if (hasSkeletonFile && hasSkeletonMachineName) {
_commander.program.error(_picocolors.default.red('You can only use one skeleton source when creating a site (--skeleton-file or --skeleton-machine-name).'));
process.exit(1);
}
if ((hasSkeletonFile || hasSkeletonMachineName) && commandRun.options.theme) {
_commander.program.error(_picocolors.default.red('Skeleton template creation and --theme are mutually exclusive. Use Start from Scratch when selecting a theme.'));
process.exit(1);
}
if ((hasSkeletonFile || hasSkeletonMachineName) && commandRun.options.importSite) {
_commander.program.error(_picocolors.default.red('Skeleton template creation and --import-site are mutually exclusive.'));
process.exit(1);
}
if (hasSkeletonFile || hasSkeletonMachineName) {
return 'template';
}
if (commandRun.options.theme) {
return 'scratch';
}
if (!commandRun.options.i || commandRun.options.auto || commandRun.options.skip) {
return 'scratch';
}
return p.select({
message: 'How should this site start?',
required: true,
options: [{
value: 'scratch',
label: 'Start from Scratch (pick a theme)'
}, {
value: 'template',
label: 'Start from Template (pick a skeleton)'
}],
initialValue: 'scratch'
});
}
},
skeletonMachineName: async ({
results
}) => {
if (results.type === "site" && results.siteCreationMode === 'template') {
if (commandRun.options.skeletonMachineName) {
return commandRun.options.skeletonMachineName;
}
if (commandRun.options.skeletonFile) {
return null;
}
if (!commandRun.options.i) {
_commander.program.error(_picocolors.default.red('Template mode requires --skeleton-file or --skeleton-machine-name when running non-interactively.'));
process.exit(1);
}
if (availableSkeletons.length === 0) {
return null;
}
let skeletonOptions = [...availableSkeletons];
skeletonOptions.push({
value: '__from_file__',
label: 'Use skeleton JSON file'
});
const selectedSkeleton = await p.select({
message: 'Template:',
required: true,
options: skeletonOptions,
initialValue: skeletonOptions[0].value
});
if (selectedSkeleton === '__from_file__') {
return null;
}
commandRun.options.skeletonMachineName = selectedSkeleton;
return selectedSkeleton;
}
},
skeletonFile: ({
results
}) => {
if (results.type === "site" && results.siteCreationMode === 'template' && !commandRun.options.skeletonMachineName && !results.skeletonMachineName && !commandRun.options.skeletonFile) {
return p.text({
message: 'Template JSON file path:',
placeholder: `${process.cwd()}/template.json`,
initialValue: `${process.cwd()}/template.json`,
required: true,
validate: value => {
if (!value) {
return 'Template JSON file path is required';
}
if (!fs.existsSync(value)) {
return `${value} does not exist. Select a valid file`;
}
if (!value.endsWith('.json')) {
return 'Template file must be a JSON file';
}
}
});
}
},
theme: async ({
results
}) => {
if (results.type === "site" && results.siteCreationMode !== 'template' && !commandRun.options.theme) {
// support having no theme but autoselecting
if (commandRun.options.auto && commandRun.options.skip) {
commandRun.options.theme = defaultCoreTheme.value;
} else {
return p.select({
message: "Theme:",
required: false,
options: coreThemes,
initialValue: defaultCoreTheme.value
});
}
} else if (results.type === "site" && results.siteCreationMode !== 'template' && commandRun.options.theme) {
if (coreThemes.filter(item => item.value === commandRun.options.theme).length === 0) {
_commander.program.error(_picocolors.default.red('Theme is not in the list of valid themes'));
process.exit(1);
}
}
},
customThemeName: async ({
results
}) => {
if (results.theme === "custom-theme") {
let tmpCustomName = await p.text({
message: 'Theme Name:',
placeholder: `custom-${commandRun.arguments.action ? commandRun.arguments.action : results.name ? results.name : commandRun.options.name}-theme`,
initialValue: `custom-${commandRun.arguments.action ? commandRun.arguments.action : results.name ? results.name : commandRun.options.name}-theme`,
required: false,
validate: value => {
if (!value) {
return "Theme name is required (Enter accepts default)";
}
if (coreThemes.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";
}
}
});
return tmpCustomName;
} else if (results.type === "site") {
// need to validate theme from CLI arguments
let value = `${commandRun.options.customThemeName ? commandRun.options.customThemeName : results.name ? results.name : commandRun.arguments.action ? commandRun.arguments.action : commandRun.options.name}`;
if (!value) {
_commander.program.error(_picocolors.default.red("Theme name is required (Enter accepts default)"));
}
if (coreThemes.some(theme => theme.value === value)) {
_commander.program.error(_picocolors.default.red("Theme name is already in use"));
}
if (/^\d/.test(value)) {
_commander.program.error(_picocolors.default.red("Theme name cannot start with a number"));
}
if (/[A-Z]/.test(value)) {
_commander.program.error(_picocolors.default.red("No uppercase letters allowed in theme name"));
}
if (value.indexOf(' ') !== -1) {
_commander.program.error(_picocolors.default.red("No spaces allowed in theme name"));
}
}
},
customThemeTemplate: ({
results
}) => {
if (results.theme === "custom-theme") {
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'
}];
return p.select({
message: 'Template:',
required: false,
options: options,
initialValue: `${results.theme}`
});
}
},
extras: ({
results
}) => {
if (!commandRun.options.auto && commandRun.options.i) {
let options = [];
let initialValues = [];
if (commandRun.command === "webcomponent" || results.type === "webcomponent") {
options = [{
value: 'launch',
label: 'Launch project',
hint: 'recommended'
}, {
value: 'install',
label: `Install dependencies via ${commandRun.options.npmClient}`,
hint: 'recommended'
}, {
value: 'git',
label: 'Apply version control via git',
hint: 'recommended'
}];
initialValues = ['launch', 'install', 'git'];
if (!sysGit || commandRun.options.isMonorepo) {
options.pop();
initialValues.pop();
}
} else {
options = [{
value: 'launch',
label: 'Launch project on creation',
hint: 'recommended'
}];
initialValues = ['launch'];
}
return p.multiselect({
message: 'Additional setup',
initialValues: initialValues,
options: options,
required: false
});
}
}
}, {
onCancel: () => {
if (!commandRun.options.quiet) {
p.cancel('🧙🪄 Merlin: Canceling cli.. HAX ya later');
(0, _statements.communityStatement)();
}
process.exit(0);
}
});
// merge cli options with project options assume this is NOT a monorepo
// but spread will overwrite if needed
if (commandRun.command === 'webcomponent' && !commandRun.arguments.action) {
project = {
isMonorepo: false,
...project,
...commandRun.options
};
} else {
project = {
isMonorepo: false,
...project,
...commandRun.arguments,
...commandRun.options
};
}
project.year = new Date().getFullYear();
project.version = _package.default.version;
if (!project.name && commandRun.arguments.action) {
project.name = commandRun.arguments.action;
}
if (project.type === 'webcomponent' && !project.template) {
project.template = 'compliant';
}
// resolve site vs multi-site
switch (project.type) {
case 'site':
// only set path if not already set, normalize across clack and commander
if (commandRun.options.path && !project.path) {
project.path = commandRun.options.path;
}
if (!project.path) {
project.path = process.cwd();
}
if (!commandRun.options.path) {
commandRun.options.path = project.path;
}
await (0, _site.siteProcess)(commandRun, project);
break;
case 'webcomponent':
await (0, _webcomponent.webcomponentProcess)(commandRun, project);
break;
}
}
}
if (!commandRun.options.quiet && project.type !== 'update') {
(0, _statements.communityStatement)();
}
}
}
// check for updates
async function testForUpdates(commandRun) {
let execOut = await (0, _utils.exec)(`npm view @haxtheweb/create version`);
let latest = execOut.stdout.trim();
if (latest !== _package.default.version) {
if (!commandRun.options.quiet) {
p.intro(`${_picocolors.default.bgBlack(_picocolors.default.white(` HAX cli updates available! `))}`);
p.intro(`Current version: ${_package.default.version}`);
p.intro(`Latest version: ${latest}`);
}
let runUpdates = {
answer: false
};
if (!commandRun.options.y) {
runUpdates = await p.group({
answer: ({
results
}) => p.confirm({
message: `Do you want to update the cli to the latest version? (${latest})`,
initialValue: true
})
}, {
onCancel: () => {
if (!commandRun.options.quiet) {
p.cancel('🧙🪄 Merlin: Leaving so soon? HAX ya later');
p.outro(`
🧙 Upgrade at any time: ${_picocolors.default.yellow('npm install --global @haxtheweb/create')}
💡 ${_picocolors.default.bold(_picocolors.default.white(`Never. Stop. Innovating.`))}
`);
}
process.exit(0);
}
});
} else {
runUpdates.answer = true; // automatic
}
// ensure they wanted to run them
if (runUpdates.answer) {
await (0, _utils.interactiveExec)('npm', ['install', '--global', '@haxtheweb/create']);
if (!commandRun.options.quiet) {
p.outro(`
🔮 HAX CLI updated to : ${_picocolors.default.yellow(latest)}
🧙 Type ${_picocolors.default.yellow('hax help')} for latest commands
💡 ${_picocolors.default.bold(_picocolors.default.white(`Never. Stop. Innovating.`))}
`);
}
} else {
if (!commandRun.options.quiet) {
p.outro(`
🧙 Upgrade at any time: ${_picocolors.default.yellow('npm install --global @haxtheweb/create')}
💡 ${_picocolors.default.bold(_picocolors.default.white(`Never. Stop. Innovating.`))}
`);
}
}
} else {
if (!commandRun.options.quiet) {
p.intro(`${_picocolors.default.bgBlack(_picocolors.default.white(` HAX CLI (${_package.default.version}) is up to date `))}`);
}
}
}
main().catch(console.error);