@vtex/fsp-cli
Version:
A VTEX CLI
687 lines (676 loc) • 20.3 kB
JavaScript
// src/commands/dev.ts
import { Args, Command } from "@oclif/core";
import { start } from "@vtex/fsp-local";
// src/modules.ts
import { readFileSync } from "fs";
import path from "path";
import { loadConfig } from "@vtex/fsp-config";
var moduleCliMap = {
checkout: "@vtex/checkout",
discovery: "@faststore/cli",
"sales-app": "@vtex/sales-app"
};
var moduleCLIPathMap = {
"@vtex/checkout": "@vtex/checkout/cli",
"@faststore/cli": "@faststore/cli",
"@vtex/sales-app": "@vtex/sales-app/cli"
};
var availableModules = Object.keys(moduleCliMap);
async function loadModules(account, moduleFilterFn = () => true) {
const { stores } = await loadConfig();
const accountConfigs = stores[account];
if (!accountConfigs) {
const availableAccounts = Object.keys(stores).join(", ");
throw new Error(
`Could not find account "${account}". Found accounts: ${availableAccounts}`
);
}
const modules = Object.keys(accountConfigs).filter(moduleFilterFn).map((module) => {
const moduleConfig = accountConfigs[module];
const cli = moduleConfig?.cli ?? moduleCliMap[module];
if (!cli) {
throw new Error("CLI not found! Provide a valid module or a CLI");
}
return {
...accountConfigs[module],
cli
};
});
return await Promise.all(modules.map((module) => loadModule(module)));
}
async function loadModule(module) {
const foundDirectory = path.join(process.cwd(), module.path);
if (!foundDirectory) {
throw new Error("Module not found");
}
const loadedCli = await load(module);
return { ...module, loadedCli };
}
async function load(module) {
let rootPackageJson = {};
try {
rootPackageJson = JSON.parse(
readFileSync(path.join(process.cwd(), "package.json")).toString()
);
} catch {
throw new Error("Could not find package.json");
}
if (!rootPackageJson.devDependencies?.[module.cli]) {
throw new Error(
`You must add ${module.cli} to your devDependencies and install it`
);
}
try {
const importedModule = await import(moduleCLIPathMap[module.cli]);
const isEsm = !!importedModule.__esModule;
return isEsm ? importedModule.default : importedModule;
} catch {
throw new Error(`Could not import module ${module.cli}`);
}
}
// src/commands/dev.ts
var Dev = class _Dev extends Command {
static args = {
account: Args.string({
required: true,
description: "Store name key to be considered. It must match the keys on faststore.json, otherwise the first one found will be used."
})
};
static description = "Runs the development server for the local storefront environment.";
static examples = ["<%= config.bin %> <%= command.id %>"];
static flags = {};
async run() {
this.log("Running faststore dev \u{1F680}");
const {
args: { account }
} = await this.parse(_Dev);
const modules = await loadModules(account);
try {
await this.runPreTasks({ modules, account });
await this.runMainTasks({ modules, account });
await this.runPostTasks({ modules, account });
await start(account);
} catch (error) {
this.logToStderr("Something went wrong.");
console.log(error);
}
}
async runMainTasks({ modules, account }) {
try {
await Promise.all(
modules.map(({ loadedCli, path: path6, port }) => {
const { dev } = loadedCli.commands;
return dev.run([account, path6, port?.toString() ?? ""]);
})
);
} catch (error) {
console.log(error);
}
}
async runPostTasks({ modules }) {
try {
await Promise.all(
modules.map(({ loadedCli }) => {
const postDev = loadedCli.hooks?.postDev;
if (postDev) return postDev();
return Promise.resolve();
})
);
} catch (error) {
console.log(error);
}
}
async runPreTasks({ modules }) {
try {
await Promise.all(
modules.map(({ loadedCli }) => {
const preDev = loadedCli.hooks?.preDev;
if (preDev) return preDev();
return Promise.resolve();
})
);
} catch (error) {
this.logToStderr("Something went wrong.");
console.log(error);
}
}
};
// src/commands/build.ts
import { Args as Args2, Command as Command2 } from "@oclif/core";
var Build = class _Build extends Command2 {
static args = {
account: Args2.string({
required: true,
description: "Store name key to be considered. It must match the keys on faststore.json, otherwise the first one found will be used."
}),
moduleList: Args2.string({
required: false,
description: "Modules to build. Separated by comma (,)"
})
};
static description = "Initiates the build process for the storefront project.";
static examples = ["<%= config.bin %> <%= command.id %>"];
static flags = {};
async run() {
this.log("Running faststore build \u{1F680}");
const {
args: { account, moduleList }
} = await this.parse(_Build);
const modules = await loadModules(
account,
moduleList ? (currentModule) => moduleList.split(",").includes(currentModule) : () => true
);
try {
await this.runPreTasks({ modules, account });
await this.runMainTasks({ modules, account });
await this.runPostTasks({ modules, account });
} catch (error) {
this.logToStderr("Something went wrong.");
console.log(error);
}
}
async runMainTasks({ modules, account }) {
try {
await Promise.all(
modules.map(({ loadedCli, path: path6 }) => {
const { build } = loadedCli.commands;
return build.run([account, path6]);
})
);
} catch (error) {
console.log(error);
}
}
async runPostTasks({ modules }) {
try {
await Promise.all(
modules.map(({ loadedCli }) => {
const postBuild = loadedCli.hooks?.postBuild;
if (postBuild) return postBuild();
return Promise.resolve();
})
);
} catch (error) {
console.log(error);
}
}
async runPreTasks({ modules }) {
try {
await Promise.all(
modules.map(({ loadedCli }) => {
const preBuild = loadedCli.hooks?.preBuild;
if (preBuild) return preBuild();
return Promise.resolve();
})
);
} catch (error) {
this.logToStderr("Something went wrong.");
console.log(error);
}
}
};
// src/commands/create.ts
import { writeFile } from "fs/promises";
import path2 from "path";
import { cwd } from "process";
import { input, select } from "@inquirer/prompts";
import { Args as Args3, Command as Command3 } from "@oclif/core";
import { loadConfig as loadConfig2 } from "@vtex/fsp-config";
var Create = class _Create extends Command3 {
static ACCOUNT_PROMPT = "What is the account name?";
static MODULE_PROMPT = "Which module do you want to initialize?";
static PATH_PROMPT = (moduleName) => {
return `What should be the path to initialize ${moduleName}?`;
};
static args = {
account: Args3.string({
required: false,
description: "Name of the account to be initialized"
}),
moduleName: Args3.string({
required: false,
description: "Name of the module to be initialized"
}),
path: Args3.string({
required: false,
description: "Path of where to initialize the module"
})
};
static description = "Add a new faststore module on the monorepo.";
static examples = ["<%= config.bin %> <%= command.id %>"];
static flags = {};
async run() {
const { args } = await this.parse(_Create);
let account = args.account;
if (!account) {
account = await input({ message: _Create.ACCOUNT_PROMPT });
}
let moduleName = args.moduleName;
if (!moduleName) {
moduleName = await select({
message: _Create.MODULE_PROMPT,
choices: availableModules.map((module) => ({
name: module,
value: module
}))
});
}
let modulePath = args.path;
if (!modulePath) {
modulePath = await input({
message: _Create.PATH_PROMPT(moduleName),
default: `./packages/${moduleName}`
});
}
const currentConfig = await loadConfig2();
currentConfig.stores = currentConfig.stores ?? {};
if (currentConfig.stores?.[account]?.[moduleName]) {
this.error(`${moduleName} has already been initialized for ${account}.`, {
code: "Already Initialized Module",
exit: 1
});
}
const accountConfig = currentConfig.stores[account] ?? {};
const hasModules = Object.keys(accountConfig).length > 0;
let modulePort = 3001;
if (hasModules) {
let largestPort = modulePort;
for (const module of Object.keys(accountConfig)) {
const port = accountConfig[module].port;
if (port && port > largestPort) {
largestPort = port;
}
}
modulePort = largestPort + 1;
}
currentConfig.stores[account] = {
...accountConfig,
[moduleName]: {
path: modulePath,
port: modulePort
}
};
this.log(`Initializing ${moduleName} for ${account} in ${modulePath}`);
const cli = await load({ cli: moduleCliMap[moduleName], path: modulePath });
await cli.commands.create.run([modulePath]);
await writeFile(
path2.join(cwd(), "faststore.json"),
`${JSON.stringify(currentConfig, void 0, 2)}
`
);
}
};
// src/commands/serve.ts
import { Args as Args4, Command as Command4 } from "@oclif/core";
var Serve = class _Serve extends Command4 {
static args = {
account: Args4.string({
required: true,
description: "Store name key to be considered. It must match the keys on faststore.json, otherwise the first one found will be used."
})
};
static description = "Runs the local server for the storefront environment.";
static examples = ["<%= config.bin %> <%= command.id %>"];
static flags = {};
async run() {
this.log("Running faststore serve \u{1F680}");
const {
args: { account }
} = await this.parse(_Serve);
const modules = await loadModules(account);
try {
await this.runPreTasks({ modules, account });
await this.runMainTasks({ modules, account });
await this.runPostTasks({ modules, account });
} catch (error) {
this.logToStderr("Something went wrong.");
console.log(error);
}
}
async runMainTasks({ modules, account }) {
try {
await Promise.all(
modules.map(({ loadedCli, path: path6, port }) => {
const { serve } = loadedCli.commands;
return serve.run([account, path6, port?.toString() ?? ""]);
})
);
} catch (error) {
console.log(error);
}
}
async runPostTasks({ modules }) {
try {
await Promise.all(
modules.map(({ loadedCli }) => {
const postServe = loadedCli.hooks?.postServe;
if (postServe) return postServe();
return Promise.resolve();
})
);
} catch (error) {
console.log(error);
}
}
async runPreTasks({ modules }) {
try {
await Promise.all(
modules.map(({ loadedCli }) => {
const preServe = loadedCli.hooks?.preServe;
if (preServe) return preServe();
return Promise.resolve();
})
);
} catch (error) {
this.logToStderr("Something went wrong.");
console.log(error);
}
}
};
// src/commands/init.ts
import {
existsSync as existsSync2,
mkdirSync as mkdirSync2,
readFileSync as readFileSync2,
readdirSync,
renameSync,
unlinkSync,
writeFileSync
} from "fs";
import path5 from "path";
import { cwd as cwd2 } from "process";
import { fileURLToPath } from "url";
import { checkbox, input as input2 } from "@inquirer/prompts";
import { Command as Command5, Flags } from "@oclif/core";
import { loadConfig as loadConfig3 } from "@vtex/fsp-config";
import merge from "deepmerge";
import Handlebars from "handlebars";
import ora from "ora";
import * as prettier from "prettier";
// src/utils/copy-file.ts
import { cpSync, existsSync, mkdirSync } from "fs";
import path3 from "path";
function copyFile(sourceDir, destDir, file, rename) {
if (!existsSync(destDir)) {
mkdirSync(destDir, { recursive: true });
}
const sourceFile = path3.join(sourceDir, file);
const destFile = path3.join(destDir, rename ?? file);
try {
cpSync(sourceFile, destFile, { recursive: true });
} catch (e) {
console.error(e);
}
}
// src/utils/get-file-info.ts
import path4 from "path";
function getFileInfo(file) {
const extension = path4.extname(file);
const name = path4.basename(file, extension);
return {
name,
extension
};
}
// src/utils/npm-registry.ts
async function getPackageLatestVersion(packageName) {
try {
const request = await fetch(`https://registry.npmjs.org/${packageName}`);
const { "dist-tags": distTags } = await request.json();
return distTags.latest;
} catch {
return "latest";
}
}
// src/commands/init.ts
var Init = class _Init extends Command5 {
static args = {};
static description = "Initialize a new FastStore monorepo project from scratch.";
static examples = ["<%= config.bin %> <%= command.id %>"];
static flags = {
"from-discovery": Flags.boolean({
required: false,
description: "Migrates the current faststore discovery to the monorepo structure"
})
};
async run() {
const currentConfig = await loadConfig3();
if (currentConfig?.stores) {
this.error("Already initialized");
}
const { flags } = await this.parse(_Init);
if (flags["from-discovery"]) {
await this.migrate();
} else {
this.freshStart();
}
}
/**
* Starts a store fresh
*/
async freshStart() {
const appName = await input2({
message: "What is the application name?",
default: "faststore-app"
});
if (appName.length === 0) {
this.error("App name is required");
}
await this.execTemplate({
templateName: "default",
destination: path5.join(process.cwd(), appName),
aditionalDependencies: ["@biomejs/biome", "turbo"],
optionalTemplateData: {
package: {
name: appName
}
}
});
}
/**
* Use a template from the available templates
*/
async execTemplate(props) {
const {
templateName,
destination,
aditionalDependencies = [],
optionalTemplateData = {}
} = props;
try {
this.log("Copying template files");
const devDependencies = await this.fetchDevDependencies(
aditionalDependencies
);
const templateData = merge(
{
package: {
name: "faststore-monorepo",
devDependencies
}
},
optionalTemplateData
);
Handlebars.registerHelper("json", (context) => {
return JSON.stringify(context, void 0, 2);
});
const dirname = path5.dirname(fileURLToPath(import.meta.url));
const templatePath = path5.join(dirname, "../src/templates", templateName);
const templateDirectory = readdirSync(templatePath);
for (const file of templateDirectory) {
const fileInfo = getFileInfo(file);
const filePath = path5.join(templatePath, file);
if (fileInfo.extension !== ".hbs") {
copyFile(templatePath, destination, file);
continue;
}
const parserOptions = {
".json": "json",
".js": "babel",
".ts": "babel-ts"
};
const fileBuffer = readFileSync2(filePath);
const handlebarsTemplate = Handlebars.compile(fileBuffer.toString());
let textContent = handlebarsTemplate(templateData);
const newFileExtension = path5.extname(fileInfo.name);
const parser = parserOptions[newFileExtension];
if (parser) {
try {
textContent = await prettier.format(textContent, {
semi: false,
singleQuote: true,
trailingComma: "es5",
parser
});
} catch (e) {
console.error(e);
}
}
writeFileSync(path5.join(destination, fileInfo.name), textContent);
}
this.log("All files copied");
} catch (e) {
this.log("Could not copy files");
}
}
async fetchDevDependencies(additionalDeps = []) {
const devDependencies = {};
const spinner = ora("Fetching dependencies");
try {
spinner.start();
const allClis = Object.values(moduleCliMap);
const dependenciesToFetch = [
"@vtex/fsp-cli",
...allClis,
...additionalDeps
];
const appsVersion = await Promise.all(
dependenciesToFetch.map(getPackageLatestVersion)
);
dependenciesToFetch.forEach((dependency, index) => {
devDependencies[dependency] = appsVersion[index];
});
return devDependencies;
} catch {
spinner.fail("Could not fetch dependencies");
return devDependencies;
} finally {
spinner.succeed("All dependencies fetched");
}
}
/**
* Migrates an existent store to the monorepo struture
*/
async migrate() {
this.log("\u26A1 Starting the migration of your store");
const sourceDir = cwd2();
const destDir = path5.join(cwd2(), "packages/discovery");
const files = readdirSync(sourceDir);
if (files.length === 0) {
return;
}
const filesToIgnore = {
".git": true,
".github": true,
"yarn.lock": true
};
const mandatoryFiles = {
src: true,
"vtex.env": true,
"vercel.json": true,
packages: true,
public: true,
"next-env.d.ts": true,
"package.json": true,
"faststore.config.js": true,
"discovery.config.js": true,
".gitignore": true
};
const fileRenames = {
"faststore.config.js": "discovery.config.js"
};
const filesToDelete = {
"vercel.json": true
};
const optionalFilesToIgnore = await checkbox({
message: "Choose the files that should remain on the repository",
choices: files.filter(
(file) => !filesToIgnore[file] && !mandatoryFiles[file]
).map((file) => ({
name: file,
value: file
}))
});
for (const file of optionalFilesToIgnore) {
filesToIgnore[file] = true;
}
let accountName = "";
for (const file of files) {
if (filesToIgnore[file]) {
continue;
}
if (filesToDelete[file]) {
try {
unlinkSync(path5.join(sourceDir, file));
this.log(`\u2705 ${file} deleted`);
} catch {
this.log(`\u274C Could not delete ${file}`);
}
continue;
}
if (file === "package.json") {
const { name = "" } = JSON.parse(
readFileSync2(file).toString()
);
accountName = String(name).replace(".store", "");
}
this.moveFile(
sourceDir,
destDir,
file,
fileRenames[file]
);
}
await this.execTemplate({
templateName: "from-discovery",
destination: sourceDir,
aditionalDependencies: ["turbo"],
optionalTemplateData: {
accountName
}
});
this.log("\u{1F984} Store migrated. You can install your packages with yarn.");
}
/**
* Move a file between two directories.
* It handles the creation of the destDir case its needed.
* @param sourceDir Path of the source directory
* @param destDir Path of the destination directory
* @param file Name of the file
*/
moveFile(sourceDir, destDir, file, rename) {
if (!existsSync2(destDir)) {
mkdirSync2(destDir, { recursive: true });
}
const sourceFile = path5.join(sourceDir, file);
const destFile = path5.join(destDir, rename ?? file);
try {
renameSync(sourceFile, destFile);
this.log(`\u2705 Moved: ${file}`);
} catch {
this.log(`\u274C Could not move the file: ${file}`);
}
}
};
// src/index.ts
var COMMANDS = {
dev: Dev,
serve: Serve,
create: Create,
build: Build,
init: Init
};
export {
COMMANDS
};
//# sourceMappingURL=index.js.map