@jsenv/cli
Version:
Command Line Interface for jsenv
401 lines (390 loc) • 12.3 kB
JavaScript
// see https://docs.npmjs.com/cli/v8/commands/npm-init#description
import { ANSI, createTaskLog, UNICODE } from "@jsenv/humanize";
import {
ensurePathnameTrailingSlash,
urlToFilename,
urlToRelativeUrl,
} from "@jsenv/urls";
import { execSync } from "node:child_process";
import {
existsSync,
mkdirSync,
readdirSync,
readFileSync,
statSync,
writeFileSync,
} from "node:fs";
import { relative } from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
import { parseArgs } from "node:util";
import prompts from "prompts";
// not using readdir to control order
const availableTemplateNameArray = [
"web",
"web-components",
"web-react",
"web-preact",
"node-package",
];
const options = {
help: {
type: "boolean",
},
web: {
type: "boolean",
},
["web-components"]: {
type: "boolean",
},
["web-react"]: {
type: "boolean",
},
["web-preact"]: {
type: "boolean",
},
["node-package"]: {
type: "boolean",
},
};
const { values, positionals } = parseArgs({
options,
allowPositionals: true,
});
if (values.help) {
console.log(`@jsenv/cli: Init jsenv in a directory.
Usage: npx @jsenv/cli <dir> [options]
https://github.com/jsenv/core/tree/main/packages/related/cli
<dir> Where to install jsenv files; Otherwise you'll be prompted to select.
Options:
--help Display this message.
--web Consider directory as "web" project; Otherwise you'll be prompted to select.
--web-components Consider directory as "web-components" project; Otherwise you'll be prompted to select.
--web-react Consider directory as "web-react" project; Otherwise you'll be prompted to select.
--web-preact Consider directory as "web-preact" project; Otherwise you'll be prompted to select.
--node-package Consider directory as "node-package" project; Otherwise you'll be prompted to select.
`);
process.exit(0);
}
console.log("Welcome in jsenv CLI; this will install jsenv in a directory.");
const commands = [];
const cwdUrl = ensurePathnameTrailingSlash(pathToFileURL(process.cwd()));
let directoryUrl;
dir: {
const directoryPathFromArg = positionals[0];
if (directoryPathFromArg) {
directoryUrl = ensurePathnameTrailingSlash(
new URL(directoryPathFromArg, cwdUrl),
);
console.log(`${UNICODE.OK} Enter a directory: ${directoryPathFromArg}`);
break dir;
}
const result = await prompts(
{
type: "text",
name: "directory",
message: `Directory: ${ANSI.color(`(Return to use current directory)`, ANSI.GREY)}`,
initial: ".",
},
{
onCancel: () => {
console.log("Aborted, can be resumed any time");
process.exit(0);
},
},
);
let { directory } = result;
directoryUrl = ensurePathnameTrailingSlash(new URL(directory, cwdUrl));
}
const runWithChildProcess = (command) => {
console.log(command);
execSync(command, {
cwd: directoryUrl,
stdio: [0, 1, 2],
});
};
if (directoryUrl.href !== cwdUrl.href) {
const dir = relative(fileURLToPath(cwdUrl), fileURLToPath(directoryUrl));
commands.push({
label: `cd ${dir}`,
run: () => {},
});
}
let templateName;
template: {
const templateNameFromArg = availableTemplateNameArray.find(
(availableTemplateName) => values[availableTemplateName],
);
if (templateNameFromArg) {
templateName = templateNameFromArg;
console.log(`${UNICODE.OK} Select a template: ${templateNameFromArg}`);
break template;
}
const result = await prompts(
[
{
type: "select",
name: "templateName",
message: "Select a template:",
initial: 0,
choices: availableTemplateNameArray.map((availableTemplateName) => {
return {
title: availableTemplateName,
value: availableTemplateName,
};
}),
},
],
{
onCancel: () => {
console.log("Aborted, can be resumed any time");
process.exit(0);
},
},
);
templateName = result.templateName;
}
write_files: {
const writeFilesTask = createTaskLog(`init jsenv in "${directoryUrl}"`);
const mergeTwoIgnoreFileContents = (left, right) => {
const leftLines = String(left)
.split("\n")
.map((l) => l.trim());
const rightLines = String(right)
.split("\n")
.map((l) => l.trim());
let finalContent = left;
for (const rightLine of rightLines) {
if (!leftLines.includes(rightLine)) {
finalContent += "\n";
finalContent += rightLine;
}
}
return finalContent;
};
const overrideHandlers = {
"package.json": (existingContent, templateContent) => {
const existingPackage = JSON.parse(existingContent);
const templatePackage = JSON.parse(templateContent);
const override = (left, right, { parentKey, allowedKeys }) => {
if (right === null) {
return left === undefined ? null : left;
}
if (Array.isArray(right)) {
if (Array.isArray(left)) {
for (const valueFromRight of right) {
if (!left.includes(valueFromRight)) {
left.push(valueFromRight);
}
}
return left;
}
return left === undefined ? right : left;
}
if (typeof right === "object") {
if (left && typeof left === "object") {
const keysToVisit = allowedKeys || Object.keys(right);
for (const keyToVisit of keysToVisit) {
const rightValue = right[keyToVisit];
if (rightValue === undefined) {
continue;
}
const leftValue = left[keyToVisit];
const finalValue = override(leftValue, rightValue, {
parentKey: keyToVisit,
});
left[keyToVisit] = finalValue;
if (parentKey === "scripts" && keyToVisit === "start") {
commands.push({
label: "npm start",
run: () => runWithChildProcess("npm start"),
});
}
}
return left;
}
return left === undefined ? right : left;
}
return left === undefined ? right : left;
};
const existingDependencies = existingPackage.dependencies
? { ...existingPackage.dependencies }
: {};
const existingDevDependencies = existingPackage.devDependencies
? { ...existingPackage.devDependencies }
: {};
override(existingPackage, templatePackage, {
allowedKeys: ["scripts", "dependencies", "devDependencies"],
});
const finalDependencies = existingPackage.dependencies || {};
const finalDevDependencies = existingPackage.devDependencies || {};
if (
JSON.stringify(existingDependencies) !==
JSON.stringify(finalDependencies) ||
JSON.stringify(existingDevDependencies) !==
JSON.stringify(finalDevDependencies)
) {
commands.push({
label: "npm install",
run: () => runWithChildProcess("npm install"),
});
}
if (existingContent.startsWith("{\n")) {
return JSON.stringify(existingPackage, null, " ");
}
return JSON.stringify(existingPackage);
},
".gitignore": mergeTwoIgnoreFileContents,
".eslintignore": mergeTwoIgnoreFileContents,
};
const templateSourceDirectoryUrl = new URL(
`./template-${templateName}/`,
import.meta.url,
);
const isWebAndHaveRootHtmlFile = (() => {
if (!templateName.startsWith("web")) {
return false;
}
if (existsSync(new URL("./index.html", directoryUrl))) {
return true;
}
if (existsSync(new URL("./main.html", directoryUrl))) {
return true;
}
return false;
})();
const copyDirectoryContent = (fromDirectoryUrl, toDirectoryUrl) => {
const directoryEntryNameArray = readdirSync(fromDirectoryUrl);
for (const directoryEntryName of directoryEntryNameArray) {
if (
directoryEntryName === ".jsenv" ||
directoryEntryName === "dist" ||
directoryEntryName === "node_modules" ||
directoryEntryName === "readme.md"
) {
continue;
}
const fromUrl = new URL(directoryEntryName, fromDirectoryUrl);
const toUrl = new URL(
directoryEntryName === "_gitignore" ? ".gitignore" : directoryEntryName,
toDirectoryUrl,
);
const fromStat = statSync(fromUrl);
if (fromStat.isDirectory()) {
if (directoryEntryName === "src" || directoryEntryName === "tests") {
if (existsSync(toUrl) && readdirSync(toUrl).length > 0) {
// copy src and tests if they don't exists or are empty
continue;
}
if (isWebAndHaveRootHtmlFile) {
// for web the presence of index.html or main.html at the root
// prevent src/ content from being copied
continue;
}
}
copyDirectoryContent(
ensurePathnameTrailingSlash(fromUrl),
ensurePathnameTrailingSlash(toUrl),
);
continue;
}
let templateFileContent = String(readFileSync(fromUrl));
if (isWebAndHaveRootHtmlFile) {
if (directoryEntryName === ".eslintrc.cjs") {
templateFileContent = templateFileContent.replaceAll(
'files: ["./src/**"]',
'files: ["./index.html", "./src/**"]',
);
} else {
templateFileContent = templateFileContent.replaceAll(
'new URL("../src/", import.meta.url)',
'new URL("../", import.meta.url)',
);
}
}
if (!existsSync(toUrl)) {
const parentDirectoryUrl = new URL("./", toUrl);
if (!existsSync(parentDirectoryUrl)) {
mkdirSync(parentDirectoryUrl, { recursive: true });
}
if (directoryEntryName === "package.json") {
commands.push({
label: "npm install",
run: () => runWithChildProcess("npm install"),
});
commands.push({
label: "npm start",
run: () => runWithChildProcess("npm start"),
});
const packageTemplateObject = JSON.parse(templateFileContent);
packageTemplateObject.name = urlToFilename(directoryUrl);
const packageString = JSON.stringify(
packageTemplateObject,
null,
" ",
);
writeFileSync(toUrl, packageString);
} else {
writeFileSync(toUrl, templateFileContent);
}
continue;
}
const relativeUrl = urlToRelativeUrl(fromUrl, templateSourceDirectoryUrl);
const overrideHandler = overrideHandlers[relativeUrl];
if (!overrideHandler) {
// when there is no handler the file is kept as is
continue;
}
const existingContent = String(readFileSync(toUrl));
const finalContent = overrideHandler(
existingContent,
templateFileContent,
);
writeFileSync(toUrl, finalContent);
}
};
copyDirectoryContent(
new URL(templateSourceDirectoryUrl),
new URL(directoryUrl),
);
writeFilesTask.done();
}
run_commands: {
if (commands.length === 0) {
break run_commands;
}
let message;
if (commands.length === 1) {
console.log(`----- 1 command to run -----
${commands[0].label}
---------------------------`);
// we can't run cd for the parent terminal
// so we'll just print the command in that case
// and user will have to run it if he want to go into the
// directory
if (commands[0].label.startsWith("cd")) {
console.log("Done, thank you");
process.exit(0);
}
message = "Can we run the command";
} else {
console.log(`----- ${commands.length} commands to run -----
${commands.map((c) => c.label).join("\n")}
-----------------------------`);
message = "Can we run the commands";
}
const { value } = await prompts({
type: "confirm",
name: "value",
message,
initial: true,
});
if (!value) {
console.log("Done, thank you");
process.exit(0);
}
for (const command of commands) {
await command.run();
}
console.log("Done, thank you");
}