diff --git a/.changeset/purple-cups-teach.md b/.changeset/purple-cups-teach.md new file mode 100644 index 00000000..88ef0207 --- /dev/null +++ b/.changeset/purple-cups-teach.md @@ -0,0 +1,6 @@ +--- +"composable-cli": patch +"@elasticpath/d2c-schematics": patch +--- + +allow user to select their output location diff --git a/packages/composable-cli/src/commands/generate/d2c/d2c-command.tsx b/packages/composable-cli/src/commands/generate/d2c/d2c-command.tsx index dd508c64..0a1508a7 100644 --- a/packages/composable-cli/src/commands/generate/d2c/d2c-command.tsx +++ b/packages/composable-cli/src/commands/generate/d2c/d2c-command.tsx @@ -43,9 +43,7 @@ import { import { detect } from "../../../lib/detect-package-manager" import { getCredentials } from "../../../lib/authentication/get-token" import { paramCase } from "change-case" -import { retrieveComposableRcFile } from "../../../lib/config-middleware" -import findUp, { exists } from "find-up" -import path from "path" +import { exists } from "find-up" import { createD2CSetupTask } from "./tasks/setup" import { createManualTasks } from "../../payments/manual/tasks/manual" import { createEPPaymentTasks } from "../../payments/manual/tasks/ep-payment" @@ -60,20 +58,22 @@ import { outputToken, } from "../../output" import chalk from "chalk" +import { basename, resolvePath } from "../../path" +import { SchematicEngineHost } from "../utils/schematic-engine-host" export function createD2CCommand( ctx: CommandContext, ): yargs.CommandModule { return { - command: ["d2c [name]", "$0 [name]"], + command: ["d2c [location]", "$0 [location]"], aliases: ["storefront"], - describe: "generate Elasticpath storefront", + describe: "generate Elastic Path storefront", builder: async (yargs) => { const result = yargs .middleware(createAuthenticationCheckerMiddleware(ctx)) .middleware(createActiveStoreMiddleware(ctx)) - .positional("name", { - describe: "the name for this storefront project", + .positional("location", { + describe: "the location for this storefront project", type: "string", }) .option("pkg-manager", { @@ -92,11 +92,12 @@ export function createD2CCommand( const collectionName = resolveD2CCollectionName( process.env.NODE_ENV ?? "production", ) + const workflow = await getOrCreateWorkflowForBuilder( collectionName, "", "", - ) // TODO: add real root and workspace + ) const collection = workflow.engine.createCollection(collectionName) const schematicsNamesForOptions = [ @@ -189,7 +190,7 @@ export function createD2CCommandHandler( const detectedPkgManager = await detect() - const { cliOptions, schematicOptions, _, name, pkgManager } = parseArgs( + const { cliOptions, schematicOptions, _, location, pkgManager } = parseArgs( args, detectedPkgManager, ) @@ -230,126 +231,12 @@ export function createD2CCommandHandler( const skipInstall = !!cliOptions["skip-install"] const skipConfig = !!cliOptions["skip-config"] - /** Create the workflow scoped to the working directory that will be executed with this run. */ - const workflow = new NodeWorkflow(process.cwd(), { - force, - dryRun, - resolvePaths: [process.cwd(), __dirname], - schemaValidation: true, - }) - - /** If the user wants to list schematics, we simply show all the schematic names. */ - if (cliOptions["list-schematics"]) { - return _listSchematics(workflow, collectionName, logger) - } - - if (debug) { - renderInfo({ - body: `Debug mode enabled${ - isLocalCollection ? " by default for local collections" : "" - }.`, - }) - } - - // Indicate to the user when nothing has been done. This is automatically set to off when there's - // a new DryRunEvent. - let nothingDone = true - - // Logging queue that receives all the messages to show the users. This only get shown when no - // errors happened. - let loggingQueue: string[] = [] - let error = false - - /** - * Logs out dry run events. - * - * All events will always be executed here, in order of discovery. That means that an error would - * be shown along other events when it happens. Since errors in workflows will stop the Observable - * from completing successfully, we record any events other than errors, then on completion we - * show them. - * - * This is a simple way to only show errors when an error occur. - */ - - workflow.reporter.subscribe((event) => { - nothingDone = false - // Strip leading slash to prevent confusion. - const eventPath = event.path.startsWith("/") - ? event.path.slice(1) - : event.path - - if (dryRun) { - switch (event.kind) { - case "error": - error = true - - const desc = - event.description == "alreadyExist" - ? "already exists" - : "does not exist" - logger.error(`ERROR! ${eventPath} ${desc}.`) - break - case "update": - loggingQueue.push( - `${colors.cyan("UPDATE")} ${eventPath} (${ - event.content.length - } bytes)`, - ) - break - case "create": - loggingQueue.push( - `${colors.green("CREATE")} ${eventPath} (${ - event.content.length - } bytes)`, - ) - break - case "delete": - loggingQueue.push(`${colors.yellow("DELETE")} ${eventPath}`) - break - case "rename": - const eventToPath = event.to.startsWith("/") - ? event.to.slice(1) - : event.to - loggingQueue.push( - `${colors.blue("RENAME")} ${eventPath} => ${eventToPath}`, - ) - break - } - } - }) - - /** - * Listen to lifecycle events of the workflow to flush the logs between each phases. - */ - workflow.lifeCycle.subscribe((event) => { - if (event.kind == "workflow-end" || event.kind == "post-tasks-start") { - if (!error) { - // Flush the log queue and clean the error state. - loggingQueue.forEach((log) => logger.info(log)) - } - - loggingQueue = [] - error = false - } - }) - - // Show usage of deprecated options - workflow.registry.useXDeprecatedProvider((msg) => logger.warn(msg)) - - // Pass the rest of the arguments as the smart default "argv". Then delete it. - workflow.registry.addSmartDefaultProvider("argv", (schema) => - "index" in schema ? _[Number(schema["index"])] : _, - ) - - // Add prompts. - if (cliOptions.interactive && isTTY()) { - workflow.registry.usePromptProvider(_createPromptProvider()) - } - let gatheredOptions: { epccClientId?: string epccClientSecret?: string - name?: string | null + location?: string | null + name?: string + directory?: string epccEndpointUrl?: string plpType?: "Algolia" | "Simple" algoliaApplicationId?: string @@ -358,7 +245,7 @@ export function createD2CCommandHandler( epPaymentsStripeAccountId?: string epPaymentsStripePublishableKey?: string } = { - name, + location, } if (cliOptions.interactive && isTTY()) { @@ -366,27 +253,17 @@ export function createD2CCommandHandler( const creds = getCredentials(store) if (creds.success) { - let resolvedName = name - - if (!resolvedName) { - const { name: promptedName } = await inquirer.prompt([ - { - type: "input", - name: "name", - message: "What do you want to call the project?", - }, - ]) - - resolvedName = promptedName - } + // User entered path to project + const resolvedLocation = location ?? (await promptForProjectLocation()) + const projectLocation = extractProjectLocation(resolvedLocation) // Check if project folder already exists - const projectFolderExists = await exists(`./${gatheredOptions.name}`) + const projectFolderExists = await exists(projectLocation.directory) if (projectFolderExists) { const message = outputContent`A folder with the name ${outputToken.path( - `./${gatheredOptions.name}`, + projectLocation.location, )} already exists. Please remove it or choose a different project name.` .value @@ -431,7 +308,7 @@ export function createD2CCommandHandler( } } - const kebabCaseName = paramCase(resolvedName!) + const kebabCaseName = paramCase(projectLocation.name) const createResult = await createApplicationKeys( ctx.requester, @@ -454,7 +331,9 @@ export function createD2CCommandHandler( ...gatheredOptions, epccClientId: client_id, epccClientSecret: client_secret, - name: kebabCaseName, + location: projectLocation.location, + name: projectLocation.name, + directory: projectLocation.directory, } } @@ -486,6 +365,133 @@ export function createD2CCommandHandler( } } + /** + * Root at which the workflow will be executed. + */ + const workflowRoot = + gatheredOptions.directory?.substring( + 0, + gatheredOptions.directory?.lastIndexOf("/"), + ) ?? process.cwd() + + /** Create the workflow scoped to the working directory that will be executed with this run. */ + const workflow = new NodeWorkflow(workflowRoot, { + force, + dryRun, + resolvePaths: [__dirname, process.cwd(), workflowRoot], + schemaValidation: true, + engineHostCreator: (options) => + new SchematicEngineHost(options.resolvePaths), + }) + + /** If the user wants to list schematics, we simply show all the schematic names. */ + if (cliOptions["list-schematics"]) { + return _listSchematics(workflow, collectionName, logger) + } + + if (debug) { + renderInfo({ + body: `Debug mode enabled${ + isLocalCollection ? " by default for local collections" : "" + }.`, + }) + } + + // Indicate to the user when nothing has been done. This is automatically set to off when there's + // a new DryRunEvent. + let nothingDone = true + + // Logging queue that receives all the messages to show the users. This only get shown when no + // errors happened. + let loggingQueue: string[] = [] + let error = false + + /** + * Logs out dry run events. + * + * All events will always be executed here, in order of discovery. That means that an error would + * be shown along other events when it happens. Since errors in workflows will stop the Observable + * from completing successfully, we record any events other than errors, then on completion we + * show them. + * + * This is a simple way to only show errors when an error occur. + */ + + workflow.reporter.subscribe((event) => { + nothingDone = false + // Strip leading slash to prevent confusion. + const eventPath = event.path.startsWith("/") + ? event.path.slice(1) + : event.path + + if (dryRun) { + switch (event.kind) { + case "error": + error = true + + const desc = + event.description == "alreadyExist" + ? "already exists" + : "does not exist" + logger.error(`ERROR! ${eventPath} ${desc}.`) + break + case "update": + loggingQueue.push( + `${colors.cyan("UPDATE")} ${eventPath} (${ + event.content.length + } bytes)`, + ) + break + case "create": + loggingQueue.push( + `${colors.green("CREATE")} ${eventPath} (${ + event.content.length + } bytes)`, + ) + break + case "delete": + loggingQueue.push(`${colors.yellow("DELETE")} ${eventPath}`) + break + case "rename": + const eventToPath = event.to.startsWith("/") + ? event.to.slice(1) + : event.to + loggingQueue.push( + `${colors.blue("RENAME")} ${eventPath} => ${eventToPath}`, + ) + break + } + } + }) + + /** + * Listen to lifecycle events of the workflow to flush the logs between each phases. + */ + workflow.lifeCycle.subscribe((event) => { + if (event.kind == "workflow-end" || event.kind == "post-tasks-start") { + if (!error) { + // Flush the log queue and clean the error state. + loggingQueue.forEach((log) => logger.info(log)) + } + + loggingQueue = [] + error = false + } + }) + + // Show usage of deprecated options + workflow.registry.useXDeprecatedProvider((msg) => logger.warn(msg)) + + // Pass the rest of the arguments as the smart default "argv". Then delete it. + workflow.registry.addSmartDefaultProvider("argv", (schema) => + "index" in schema ? _[Number(schema["index"])] : _, + ) + + // Add prompts. + if (cliOptions.interactive && isTTY()) { + workflow.registry.usePromptProvider(_createPromptProvider()) + } + let unsubscribe: (() => void)[] = [] /** @@ -508,6 +514,7 @@ export function createD2CCommandHandler( skipConfig, packageManager: pkgManager, ...gatheredOptions, + directory: "", }, allowPrivate: allowPrivate, debug: debug, @@ -526,7 +533,7 @@ export function createD2CCommandHandler( } else { const updatedCtx = await getUpdatedCtx( ctx, - gatheredOptions.name ?? "unknown-project-name", + gatheredOptions.location ?? "unknown-project-name", ) const d2cSetupTasks = createD2CSetupTask() @@ -593,7 +600,7 @@ export function createD2CCommandHandler( body: "You skipped configuration", }) renderProjectReady({ - projectName: gatheredOptions.name, + projectName: gatheredOptions.location, pkgManager, notes: [], }) @@ -616,7 +623,7 @@ export function createD2CCommandHandler( const notes = processResultNotes(result) renderProjectReady({ - projectName: gatheredOptions.name, + projectName: gatheredOptions.location, pkgManager, notes, }) @@ -654,6 +661,32 @@ export function createD2CCommandHandler( type Note = { title: string; description: string } +type ProjectLocation = { + /** + * User entered project location + */ + location: string + /** + * Absolute path to the project location + */ + directory: string + /** + * Name of the project + */ + name: string +} + +function extractProjectLocation(location: string): ProjectLocation { + const directory = resolvePath(process.cwd(), location) + const name = basename(location) + + return { + location, + directory, + name, + } +} + function processResultNotes(result: D2CSetupTaskContext): Note[] { const colors = ansiColors.create() let notes: { title: string; description: string }[] = [] @@ -830,7 +863,9 @@ interface Options { _: string[] schematicOptions: Record cliOptions: Partial, boolean | null>> - name: string | null + location: string | null + workspace: string | null + root: string | null pkgManager: "npm" | "yarn" | "pnpm" | "bun" } @@ -839,7 +874,7 @@ function parseArgs( args: yargs.ArgumentsCamelCase, detectedPkgManager?: "npm" | "yarn" | "pnpm" | "bun", ): Options { - const { _, $0, name = null, ...options } = args + const { _, $0, location = null, ...options } = args // Camelize options as yargs will return the object in kebab-case when camel casing is disabled. const schematicOptions: Options["schematicOptions"] = {} @@ -869,7 +904,9 @@ function parseArgs( _: _.map((v) => v.toString()), schematicOptions, cliOptions, - name, + location, + workspace: location, + root: location, pkgManager: args["pkg-manager"] ?? detectedPkgManager ?? "npm", } } @@ -947,33 +984,25 @@ function _createPromptProvider(): schema.PromptProvider { } export async function getUpdatedCtx(ctx: CommandContext, projectName: string) { - const configPath = await findUp([`${projectName}/.composablerc`]) - - if (!configPath) { - renderWarning({ - body: `No .composablerc file found in directory`, - }) - return ctx - } - - const parsedConfig = await retrieveComposableRcFile(configPath) - - if (!parsedConfig.success) { - ctx.logger.warn( - `Failed to parse .composablerc ${parsedConfig.error.message}`, - ) - return ctx - } - - ctx.logger.debug(`Successfully read config ${path.basename(configPath)}`) - return { ...ctx, - composableRc: parsedConfig.data, - workspaceRoot: path.dirname(configPath), + workspaceRoot: projectName, } } +async function promptForProjectLocation(): Promise { + const { location: promptedLocation } = await inquirer.prompt([ + { + type: "input", + name: "location", + message: "Where do you want to output your project?", + default: "elastic-path-storefront", + }, + ]) + + return promptedLocation +} + function renderProjectReady({ pkgManager, projectName, diff --git a/packages/composable-cli/src/commands/generate/d2c/d2c.types.ts b/packages/composable-cli/src/commands/generate/d2c/d2c.types.ts index 23b0d622..cc03472e 100644 --- a/packages/composable-cli/src/commands/generate/d2c/d2c.types.ts +++ b/packages/composable-cli/src/commands/generate/d2c/d2c.types.ts @@ -8,6 +8,6 @@ export type D2CCommandError = { } export type D2CCommandArguments = { - name?: string + location?: string "pkg-manager"?: "npm" | "yarn" | "pnpm" | "bun" } & GenerateCommandArguments diff --git a/packages/composable-cli/src/commands/generate/generate-command.tsx b/packages/composable-cli/src/commands/generate/generate-command.tsx index 8fc4d5c9..95de535a 100644 --- a/packages/composable-cli/src/commands/generate/generate-command.tsx +++ b/packages/composable-cli/src/commands/generate/generate-command.tsx @@ -17,6 +17,8 @@ import { trackCommandHandler } from "../../util/track-command-handler" import { isTTY } from "../../util/is-tty" import { SetStoreCommandArguments } from "../store/store.types" import { renderInfo } from "../ui" +import { outputContent } from "../output" +import chalk from "chalk" export function createGenerateCommand( ctx: CommandContext, @@ -24,7 +26,7 @@ export function createGenerateCommand( return { command: "generate", aliases: ["g"], - describe: "generate Elasticpath storefront", + describe: "generate Elastic Path storefront", builder: (yargs) => { return yargs .option("debug", { @@ -123,7 +125,12 @@ export function createActiveStoreMiddleware( if (hasActiveStore(store) || !isTTY()) { const activeStore = ctx.store.get("store") as Record renderInfo({ - body: `Using store: ${activeStore?.name} - ${activeStore?.id}`, + body: [ + `Using store: ${activeStore?.name} - ${activeStore?.id}`, + outputContent`${chalk.dim( + `To change the active store, run: ep store set`, + )}`.value, + ].join("\n"), }) return } diff --git a/packages/composable-cli/src/commands/generate/utils/get-or-create-workflow-for-builder.ts b/packages/composable-cli/src/commands/generate/utils/get-or-create-workflow-for-builder.ts index 63193037..8a11beb8 100644 --- a/packages/composable-cli/src/commands/generate/utils/get-or-create-workflow-for-builder.ts +++ b/packages/composable-cli/src/commands/generate/utils/get-or-create-workflow-for-builder.ts @@ -6,10 +6,11 @@ const DEFAULT_SCHEMATICS_COLLECTION = "@elasticpath/d2c-schematics" export function getOrCreateWorkflowForBuilder( collectionName: string, root: string, - workspace: string + workspace: string, ): NodeWorkflow { + const resolvePaths = getResolvePaths(collectionName, workspace, root) return new NodeWorkflow(root, { - resolvePaths: getResolvePaths(collectionName, workspace, root), + resolvePaths: resolvePaths, engineHostCreator: (options) => new SchematicEngineHost(options.resolvePaths), }) @@ -18,12 +19,12 @@ export function getOrCreateWorkflowForBuilder( function getResolvePaths( collectionName: string, workspace: string, - root: string + root: string, ): string[] { return workspace ? // Workspace collectionName === DEFAULT_SCHEMATICS_COLLECTION - ? // Favor __dirname for @schematics/angular to use the build-in version + ? // Favor __dirname for "@elasticpath/d2c-schematics" to use the build-in version [__dirname, process.cwd(), root] : [process.cwd(), root, __dirname] : // Global diff --git a/packages/composable-cli/src/commands/login/unauthenticated-message.tsx b/packages/composable-cli/src/commands/login/unauthenticated-message.tsx deleted file mode 100644 index 8e0cef4e..00000000 --- a/packages/composable-cli/src/commands/login/unauthenticated-message.tsx +++ /dev/null @@ -1,19 +0,0 @@ -import React from "react" -import { Box, Text } from "ink" - -export function UnauthenticatedMessage() { - return ( - - It seems you are not currently authenticated. - - If you already have an Elasticpath account, you can use the following - command to authenticate and access your account: - - elasticpath login - - If you haven't registered for an Elasticpath account yet, you can sign - up for a free account by visiting our website: - - - ) -} diff --git a/packages/composable-cli/src/commands/logout/unauthenticated-message.tsx b/packages/composable-cli/src/commands/logout/unauthenticated-message.tsx deleted file mode 100644 index 8e0cef4e..00000000 --- a/packages/composable-cli/src/commands/logout/unauthenticated-message.tsx +++ /dev/null @@ -1,19 +0,0 @@ -import React from "react" -import { Box, Text } from "ink" - -export function UnauthenticatedMessage() { - return ( - - It seems you are not currently authenticated. - - If you already have an Elasticpath account, you can use the following - command to authenticate and access your account: - - elasticpath login - - If you haven't registered for an Elasticpath account yet, you can sign - up for a free account by visiting our website: - - - ) -} diff --git a/packages/composable-cli/src/commands/store/store-command.tsx b/packages/composable-cli/src/commands/store/store-command.tsx index 5ace5d1c..7b5f4430 100644 --- a/packages/composable-cli/src/commands/store/store-command.tsx +++ b/packages/composable-cli/src/commands/store/store-command.tsx @@ -35,7 +35,7 @@ export function createStoreCommand( ): yargs.CommandModule { return { command: "store", - describe: "interact with Elasticpath store", + describe: "interact with Elastic Path store", builder: (yargs) => { return yargs .command(createSetStoreCommand(ctx)) diff --git a/packages/d2c-schematics/application/index.ts b/packages/d2c-schematics/application/index.ts index f9b72faf..47d7b79b 100644 --- a/packages/d2c-schematics/application/index.ts +++ b/packages/d2c-schematics/application/index.ts @@ -26,7 +26,7 @@ export default function (options: ApplicationOptions): Rule { }), move(appDir), ]), - MergeStrategy.Overwrite + MergeStrategy.Overwrite, ), ]) } diff --git a/packages/d2c-schematics/application/schema.json b/packages/d2c-schematics/application/schema.json index d38a85d6..bcc1ba54 100644 --- a/packages/d2c-schematics/application/schema.json +++ b/packages/d2c-schematics/application/schema.json @@ -14,7 +14,6 @@ "name": { "description": "The name of the new app.", "type": "string", - "pattern": "^(?:@[a-zA-Z0-9-*~][a-zA-Z0-9-*._~]*/)?[a-zA-Z0-9-~][a-zA-Z0-9-._~]*$", "$default": { "$source": "argv", "index": 0 diff --git a/packages/d2c-schematics/d2c/index.ts b/packages/d2c-schematics/d2c/index.ts index 648fa517..46745407 100644 --- a/packages/d2c-schematics/d2c/index.ts +++ b/packages/d2c-schematics/d2c/index.ts @@ -1,7 +1,5 @@ import { Rule, - SchematicContext, - Tree, apply, chain, empty, @@ -9,7 +7,6 @@ import { move, schematic, } from "@angular-devkit/schematics" -import { RepositoryInitializerTask } from "@angular-devkit/schematics/tasks" import { Schema as ApplicationOptions } from "../application/schema" import { Schema as WorkspaceOptions } from "../workspace/schema" import { Schema as ProductListOptions } from "../product-list-page/schema" @@ -26,17 +23,22 @@ export default function (options: D2COptions): Rule { const projectRoot = "" + const nameWithoutPath = options.name + + if (!nameWithoutPath) { + throw new Error("Invalid project name") + } + const { epccEndpointUrl, epccClientSecret, epccClientId, plpType, skipTests, - name, packageManager, } = options const workspaceOptions: WorkspaceOptions = { - name, + name: nameWithoutPath, epccClientId, epccClientSecret, epccEndpointUrl, @@ -45,12 +47,13 @@ export default function (options: D2COptions): Rule { const applicationOptions: ApplicationOptions = { projectRoot, - name, + name: nameWithoutPath, skipTests, } const plpOptions: ProductListOptions = { ...options, + name: nameWithoutPath, path: projectRoot, skipTests, epccClientId, @@ -62,6 +65,7 @@ export default function (options: D2COptions): Rule { const checkoutOptions: CheckoutOptions = { ...options, + name: nameWithoutPath, path: projectRoot, skipTests, epccClientId, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1966cd6f..383e06e9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -18793,7 +18793,7 @@ packages: peerDependencies: eslint: ^3 || ^4 || ^5 || ^6 || ^7 || ^8 dependencies: - '@babel/runtime': 7.23.8 + '@babel/runtime': 7.23.4 aria-query: 5.3.0 array-includes: 3.1.7 array.prototype.flatmap: 1.3.2