From 1f72605ef26718890aabf002085a7c736b6cc628 Mon Sep 17 00:00:00 2001 From: Ali Asghar <75574550+aliasghar98@users.noreply.github.com> Date: Wed, 9 Apr 2025 13:37:15 +0500 Subject: [PATCH 1/7] fix: various improvements --- README.md | 4 +- src/commands/portal/generate.ts | 35 ++++++--- src/commands/portal/quickstart.ts | 39 ++++------ src/commands/portal/serve.ts | 16 +--- src/controllers/api/validate.ts | 26 +++---- src/controllers/portal/quickstart.ts | 86 +++++++++++++++++++-- src/controllers/portal/serve.ts | 13 ++-- src/prompts/portal/generate.ts | 67 ++++++++++++++++ src/prompts/portal/quickstart.ts | 23 +++--- src/utils/utils.ts | 14 +++- src/validators/common/directoryValidator.ts | 6 +- 11 files changed, 230 insertions(+), 99 deletions(-) create mode 100644 src/prompts/portal/generate.ts diff --git a/README.md b/README.md index e2b1c3df..a1f3eff2 100644 --- a/README.md +++ b/README.md @@ -272,7 +272,7 @@ USAGE $ apimatic portal:serve [-p ] [-d ] [-s ] [-o] [--no-reload] [-i ] [--auth-key ] FLAGS - -d, --destination= [default: ./api-portal] Directory to store and serve the generated portal. + -d, --destination= [default: ./generated_portal] Directory to store and serve the generated portal. -i, --ignore= Comma-separated list of files/directories to ignore. -o, --open Open the portal in the default browser. -p, --port= [default: 3000] Port to serve the portal. @@ -285,7 +285,7 @@ DESCRIPTION Generate and deploy a Docs as Code portal with hot reload. EXAMPLES - $ apimatic portal:serve --source="./" --destination="./api-portal" --port=3000 --open --no-reload + $ apimatic portal:serve --source="./" --destination="./generated_portal" --port=3000 --open --no-reload ``` _See code: [src/commands/portal/serve.ts](https://github.com/apimatic/apimatic-cli/blob/v1.0.1-alpha.11/src/commands/portal/serve.ts)_ diff --git a/src/commands/portal/generate.ts b/src/commands/portal/generate.ts index 81f9542e..3bf110c2 100644 --- a/src/commands/portal/generate.ts +++ b/src/commands/portal/generate.ts @@ -1,15 +1,16 @@ import * as path from "path"; import * as fs from "fs-extra"; -import { ux, Command, Flags } from "@oclif/core"; +import { Command, Flags } from "@oclif/core"; import { Client, DocsPortalManagementController } from "@apimatic/sdk"; import { AxiosError } from "axios"; import { SDKClient } from "../../client-utils/sdk-client"; import { GeneratePortalParams } from "../../types/portal/generate"; import { downloadDocsPortal } from "../../controllers/portal/generate"; -import { zipDirectory, replaceHTML, isJSONParsable } from "../../utils/utils"; +import { replaceHTML, isJSONParsable, validateAndZipPortalSource, getGeneratedFilesPaths } from "../../utils/utils"; import { AuthenticationError } from "../../types/utils"; +import { PortalGeneratePrompts } from "../../prompts/portal/generate"; export default class PortalGenerate extends Command { static description = @@ -45,26 +46,31 @@ Your portal has been generated at D:/ const zip = flags.zip; const sourceFolderPath: string = flags.folder; const portalFolderPath: string = path.join(flags.destination, "generated_portal"); - const zippedPortalPath: string = path.join(flags.destination, "generated_portal.zip"); - + const zippedPortalPath: string = path.join(flags.destination, ".generated_portal.zip"); const overrideAuthKey: string | null = flags["auth-key"] ? flags["auth-key"] : null; + const prompts = new PortalGeneratePrompts(); // Check if at destination, portal already exists and throw error if force flag is not set for both zip and extracted if (fs.existsSync(portalFolderPath) && !flags.force && !zip) { - throw new Error(`Can't download portal to path ${portalFolderPath}, because it already exists`); + await prompts.existingDestinationPortalFolderPrompt(); } else if (fs.existsSync(zippedPortalPath) && !flags.force && zip) { - throw new Error(`Can't download portal to path ${zippedPortalPath}, because it already exists`); + await prompts.existingDestinationPortalZipPrompt(); } try { if (!(await fs.pathExists(flags.destination))) { - throw new Error(`Destination path ${flags.destination} does not exist`); + throw new Error(`Destination path ${flags.destination} does not exist.`); } else if (!(await fs.pathExists(flags.folder))) { - throw new Error(`Portal build folder ${flags.folder} does not exist`); + throw new Error(`Portal build folder ${flags.folder} does not exist.`); } const client: Client = await SDKClient.getInstance().getClient(overrideAuthKey, this.config.configDir); const docsPortalController: DocsPortalManagementController = new DocsPortalManagementController(client); - const zippedBuildFilePath = await zipDirectory(sourceFolderPath, flags.destination); + const pathsToIgnore = getGeneratedFilesPaths(sourceFolderPath, portalFolderPath); + const zippedBuildFilePath = await validateAndZipPortalSource( + sourceFolderPath, + path.join(sourceFolderPath, ".portal_source.zip"), + pathsToIgnore + ); const generatePortalParams: GeneratePortalParams = { zippedBuildFilePath, @@ -74,11 +80,16 @@ Your portal has been generated at D:/ overrideAuthKey, zip }; - ux.action.start('Generating portal'); + + prompts.displayPortalGenerationMessage(); + const generatedPortalPath: string = await downloadDocsPortal(generatePortalParams, this.config.configDir); - ux.action.stop(); - this.log(`Your portal has been generated at ${generatedPortalPath}`); + + prompts.displayPortalGenerationSuccessMessage(); + + prompts.displayOutroMessage(generatedPortalPath); } catch (error) { + prompts.displayPortalGenerationErrorMessage(); if (error && (error as AxiosError).response) { const apiError = error as AxiosError; const apiResponse = apiError.response; diff --git a/src/commands/portal/quickstart.ts b/src/commands/portal/quickstart.ts index 4649513f..10cafeb4 100644 --- a/src/commands/portal/quickstart.ts +++ b/src/commands/portal/quickstart.ts @@ -15,9 +15,9 @@ export default class PortalQuickstart extends Command { prompts: PortalQuickstartPrompts, controller: PortalQuickstartController ): Promise { - const spec = await prompts.specPrompt(); + const specPath = await prompts.specPrompt(); - const specFile = await controller.getSpecFile(spec); + const specFile = await controller.getSpecFile(specPath); prompts.displaySpecValidationMessage(); @@ -30,25 +30,16 @@ export default class PortalQuickstart extends Command { specFile: SpecFile, apiValidationController: APIValidationExternalApisController ): Promise { - try { - const apiValidationSummary = await controller.getSpecValidationSummary(specFile, apiValidationController); - - if (!apiValidationSummary.success) { - prompts.displaySpecValidationFailureMessage(); - await prompts.specValidationFailurePrompt(); - } else { - prompts.displaySpecValidationSuccessMessage(); - } + const apiValidationSummary = await controller.getSpecValidationSummary(prompts, specFile, apiValidationController); - return apiValidationSummary; - } catch (error) { - prompts.displaySpecValidationErrorMessage(); - this.error( - getMessageInRedColor( - "The specified path/URL does not point to a valid API Definition file. Please provide a valid API Definition file and try again." - ) - ); + if (!apiValidationSummary.success) { + prompts.displaySpecValidationFailureMessage(); + await prompts.specValidationFailurePrompt(); + } else { + prompts.displaySpecValidationSuccessMessage(); } + + return apiValidationSummary; } private async getBuildDirectory( @@ -58,17 +49,17 @@ export default class PortalQuickstart extends Command { apiValidationSummary: ApiValidationSummary, languages: string[] ): Promise { - const directory = await prompts.buildDirectoryPrompt(); + const buildDirectoryPath = await prompts.buildDirectoryPrompt(); prompts.displayBuildDirectoryGenerationMessage(); - await controller.setupBuildDirectory(prompts, directory, specFile, apiValidationSummary, languages); + await controller.setupBuildDirectory(prompts, buildDirectoryPath, specFile, apiValidationSummary, languages); - prompts.displayBuildDirectoryGenerationSuccessMessage(directory); + prompts.displayBuildDirectoryGenerationSuccessMessage(buildDirectoryPath); - prompts.displayBuildDirectoryAsTree(directory); + prompts.displayBuildDirectoryAsTree(buildDirectoryPath); - return directory; + return buildDirectoryPath; } private async getGeneratedPortalPath( diff --git a/src/commands/portal/serve.ts b/src/commands/portal/serve.ts index 4f874e20..a5ef76ea 100644 --- a/src/commands/portal/serve.ts +++ b/src/commands/portal/serve.ts @@ -4,7 +4,7 @@ import { Command, Flags } from "@oclif/core"; import { generatePortal } from "../../controllers/portal/serve"; import { PortalServerService } from "../../services/portal/server"; import { PortalServePrompts } from "../../prompts/portal/serve"; -import { cleanUpGeneratedPortalFiles, getMessageInRedColor } from "../../utils/utils"; +import { cleanUpGeneratedPortalFiles, getGeneratedFilesPaths, getMessageInRedColor } from "../../utils/utils"; import { PortalServeValidator } from "../../validators/portal/serveValidator"; export default class PortalServe extends Command { @@ -19,7 +19,7 @@ export default class PortalServe extends Command { destination: Flags.string({ char: "d", description: "Directory to store and serve the generated portal.", - default: "./api-portal", + default: "./generated_portal", parse: async (input) => path.resolve(input) }), source: Flags.string({ @@ -49,17 +49,9 @@ export default class PortalServe extends Command { }; static examples = [ - '$ apimatic portal:serve --source="./" --destination="./api-portal" --port=3000 --open --no-reload' + '$ apimatic portal:serve --source="./" --destination="./generated_portal" --port=3000 --open --no-reload' ]; - private getGeneratedFilesPaths(sourceDir: string, portalDir: string): string[] { - const generatedZipPath = path.join(sourceDir, "portal_source.zip"); - const generatedPortalZipPath = path.join(sourceDir, "generated_portal.zip"); - const generatedPortalPath = path.join(path.dirname(portalDir), "api-portal"); - - return [generatedZipPath, generatedPortalPath, generatedPortalZipPath]; - } - async run() { const { flags } = await this.parse(PortalServe); const ignoredPaths = flags.ignore.split(",").map((path) => path.trim()); @@ -70,7 +62,7 @@ export default class PortalServe extends Command { const serverService = new PortalServerService(); const prompts = new PortalServePrompts(); const validator = new PortalServeValidator(this.error); - const allIgnoredPaths = [...ignoredPaths, ...this.getGeneratedFilesPaths(sourceDir, portalDir)]; + const allIgnoredPaths = [...ignoredPaths, ...getGeneratedFilesPaths(sourceDir, portalDir)]; await validator.validate(port, flags.destination, sourceDir, portalDir); diff --git a/src/controllers/api/validate.ts b/src/controllers/api/validate.ts index e718aa19..92779ec8 100644 --- a/src/controllers/api/validate.ts +++ b/src/controllers/api/validate.ts @@ -12,24 +12,16 @@ export const getValidationSummary = async ( if (file) { const fileStatus = fs.statSync(file); - if (fileStatus.isDirectory()){ + if (fileStatus.isDirectory()) { const tempDir = await createTempDirectory(); + const zipPath = await zipDirectory(file, tempDir); + const zipFile = new FileWrapper(fs.createReadStream(zipPath)); + validation = await apiValidationController.validateAPIViaFile(ContentType.EnumMultipartformdata, zipFile); - try { - const zipPath = await zipDirectory(file, tempDir); - const zipFile = new FileWrapper(fs.createReadStream(zipPath)); - validation = await apiValidationController.validateAPIViaFile(ContentType.EnumMultipartformdata, zipFile); - - await deleteFile(zipPath); - } - catch (error) { - throw new Error("There was an error validating your spec file."); - } - finally { - await fs.remove(tempDir); - } - } - else { + await deleteFile(zipPath); + + await fs.remove(tempDir); + } else { const fileDescriptor = new FileWrapper(fs.createReadStream(file)); validation = await apiValidationController.validateAPIViaFile(ContentType.EnumMultipartformdata, fileDescriptor); } @@ -38,6 +30,6 @@ export const getValidationSummary = async ( } else { throw new Error("Please provide a specification file"); } - + return validation.result; }; diff --git a/src/controllers/portal/quickstart.ts b/src/controllers/portal/quickstart.ts index c45d7b80..0463df24 100644 --- a/src/controllers/portal/quickstart.ts +++ b/src/controllers/portal/quickstart.ts @@ -5,7 +5,7 @@ import * as filetype from "file-type"; import * as fs from "fs"; import * as fsextra from "fs-extra"; import { getAuthInfo } from "../../client-utils/auth-manager"; -import { APIValidationExternalApisController, ApiValidationSummary } from "@apimatic/sdk"; +import { ApiError, APIValidationExternalApisController, ApiValidationSummary } from "@apimatic/sdk"; import { LoginCredentials, SpecFile } from "../../types/portal/quickstart"; import { SDKClient } from "../../client-utils/sdk-client"; import { @@ -18,11 +18,12 @@ import { cleanUpGeneratedPortalFiles } from "../../utils/utils"; import { getValidationSummary } from "../api/validate"; -import { GetValidationParams } from "../../types/api/validate"; +import { APIValidateError, AuthorizationError, GetValidationParams } from "../../types/api/validate"; import { generatePortal } from "./serve"; import { metadataFileContent, staticPortalRepoUrl } from "../../config/env"; import { PortalServerService } from "../../services/portal/server"; import { PortalQuickstartPrompts } from "../../prompts/portal/quickstart"; +import { AuthenticationError } from "../../types/utils"; export class PortalQuickstartController { private readonly specUrl = @@ -131,6 +132,7 @@ export class PortalQuickstartController { } async getSpecValidationSummary( + prompts: PortalQuickstartPrompts, specFile: SpecFile, apiValidationController: APIValidationExternalApisController ): Promise { @@ -139,9 +141,81 @@ export class PortalQuickstartController { url: specFile.url }; - const validationSummary = getValidationSummary(validationFlags, apiValidationController); - - return validationSummary; + try { + const validationSummary = await getValidationSummary(validationFlags, apiValidationController); + return validationSummary; + } + catch (error) { + prompts.displaySpecValidationErrorMessage(); + if (axios.isAxiosError(error)) { + if (error.response) { + if (error.response.status === 400) { + throw new Error( + getMessageInRedColor( + `The provided spec file is not valid. Please ensure that the spec you have provided is a valid API definition file.` + ) + ); + } else if (error.response.status === 500) { + throw new Error( + getMessageInRedColor( + `The server encountered an error while validating your spec file, please try again later. If the issue persists, contact our team at support@apimatic.io` + ) + ); + } else { + throw new Error( + getMessageInRedColor( + `Something went wrong while validating your spec file. The server returned the following error ${error.response.status} ${error.response.statusText}. Please try again later. If the issue persists, contact our team at support@apimatic.io` + ) + ); + } + } else if (error.request) { + if (error.code === "ECONNABORTED") { + throw new Error(getMessageInRedColor(`The spec validation request timed out. Please try again.`)); + } else if (error.code === "ENOTFOUND" || error.code === "ERR_NETWORK") { + throw new Error( + getMessageInRedColor( + `Network error encountered while validating the spec file. Please check your connection and try again.` + ) + ); + } else { + throw new Error( + getMessageInRedColor( + `Something went wrong while validating the spec file, please try again. If the issue persists, reach out to our support team at support@apimatic.io` + ) + ); + } + } else { + throw new Error(getMessageInRedColor(`Failed to validate spec file: ${error.message}`)); + } + } else if ((error as ApiError).result) { + const apiError = error as ApiError; + const result = apiError.result as APIValidateError; + if (result.modelState["exception Error"] && apiError.statusCode === 400) { + throw new Error( + `The provided spec file is not valid. Please ensure that the spec file you have provided is a valid API definition file.` + ); + } else if ((error as AuthorizationError).body && apiError.statusCode === 401) { + throw new Error("You are not authorized to perform this action"); + } else { + throw new Error((error as Error).message); + } + } else if ((error as AuthenticationError).statusCode === 401) { + throw new Error("You are not authorized to perform this action"); + } else if ( + (error as AuthenticationError).statusCode === 402 && + (error as AuthenticationError).body && + typeof (error as AuthenticationError).body === "string" + ) { + throw new Error((error as AuthenticationError).body); + } + else { + throw new Error( + getMessageInRedColor( + `Something went wrong while validating the spec file, please try again later. If the issue persists, contact our team at support@apimatic.io` + ) + ); + } + } } async setupBuildDirectory( @@ -212,7 +286,7 @@ export class PortalQuickstartController { } async generatePortalArtifacts(targetFolder: string, configDir: string): Promise { - const generatedPortalPath = path.join(targetFolder, "api-portal"); + const generatedPortalPath = path.join(targetFolder, "generated_portal"); try { await generatePortal(targetFolder, generatedPortalPath, configDir); diff --git a/src/controllers/portal/serve.ts b/src/controllers/portal/serve.ts index 3da8b1c4..e74276c8 100644 --- a/src/controllers/portal/serve.ts +++ b/src/controllers/portal/serve.ts @@ -4,6 +4,7 @@ import { Client, DocsPortalManagementController } from "@apimatic/sdk"; import { SDKClient } from "../../client-utils/sdk-client"; import { cleanUpGeneratedPortalFiles, + getGeneratedFilesPaths, getMessageInMagentaColor, getMessageInRedColor, validateAndZipPortalSource @@ -55,14 +56,10 @@ export const watchAndRegeneratePortal = async ( ignoredPaths: string[] = [] ) => { // Convert ignoredPaths to absolute paths for consistent comparison - const generatedZipPath = path.join(sourceDir, "portal_source.zip"); - const generatedPortalZipPath = path.join(sourceDir, "generated_portal.zip"); - const generatedPortalPath = path.join(path.dirname(portalDir), "api-portal"); + const generatedFilesPaths = getGeneratedFilesPaths(sourceDir, portalDir); const absoluteIgnoredPaths = [ ...ignoredPaths.filter((ignoredPath) => ignoredPath.trim() !== ""), - generatedZipPath, - generatedPortalZipPath, - generatedPortalPath + ...generatedFilesPaths ].map((ignoredPath) => path.resolve(sourceDir, ignoredPath)); const watcher = chokidar.watch(sourceDir, { @@ -116,14 +113,14 @@ export const generatePortal = async ( const zippedBuildFilePath = await validateAndZipPortalSource( sourceDir, - path.join(sourceDir, "portal_source.zip"), + path.join(sourceDir, ".portal_source.zip"), ignoredPaths ); const generatePortalParams: GeneratePortalParams = { zippedBuildFilePath, portalFolderPath: portalDir, - zippedPortalPath: path.join(sourceDir, "generated_portal.zip"), + zippedPortalPath: path.join(sourceDir, ".generated_portal.zip"), docsPortalController, overrideAuthKey, zip: false diff --git a/src/prompts/portal/generate.ts b/src/prompts/portal/generate.ts new file mode 100644 index 00000000..c1d27dea --- /dev/null +++ b/src/prompts/portal/generate.ts @@ -0,0 +1,67 @@ +import { cancel, outro, select, spinner } from "@clack/prompts"; +import { isCancel } from "axios"; +import { getMessageInRedColor } from "../../utils/utils"; + +export class PortalGeneratePrompts { + private readonly spin = spinner(); + + async existingDestinationPortalFolderPrompt(): Promise { + const useExistingFolder = await select({ + message: `The destination folder is not empty, do you want to overwrite the existing files?`, + options: [ + { value: "yes", label: "Yes"}, + { value: "no", label: "No"} + ] + }); + + if (isCancel(useExistingFolder)) { + cancel("Operation cancelled."); + return process.exit(1); + } + + if (useExistingFolder === "no") { + outro( + "Please enter a different destination folder or remove the existing files and try again." + ); + process.exit(0); + } + } + + async existingDestinationPortalZipPrompt(): Promise { + const useExistingZip = await select({ + message: `A zip file already exists at the specified destination path, do you want to overwrite it?`, + options: [ + { value: "yes", label: "Yes"}, + { value: "no", label: "No"} + ] + }); + + if (isCancel(useExistingZip)) { + cancel("Operation cancelled."); + return process.exit(1); + } + + if (useExistingZip === "no") { + outro( + "Please enter a different destination path or delete the existing zip file and try again." + ); + process.exit(0); + } + } + + displayPortalGenerationMessage(): void { + this.spin.start("Generating portal..."); + } + + displayPortalGenerationSuccessMessage(): void { + this.spin.stop("✅ Portal generated successfully."); + } + + displayPortalGenerationErrorMessage(): void { + this.spin.stop(getMessageInRedColor(`Something went wrong while generating your portal.`)); + } + + displayOutroMessage(generatedPortalPath: string): void { + outro(`The generated portal can be found at ${generatedPortalPath}`); + } +} \ No newline at end of file diff --git a/src/prompts/portal/quickstart.ts b/src/prompts/portal/quickstart.ts index 116e2218..8a552205 100644 --- a/src/prompts/portal/quickstart.ts +++ b/src/prompts/portal/quickstart.ts @@ -17,12 +17,11 @@ export class PortalQuickstartPrompts { private readonly vscodeExtensionUrl = "\u001b[4mhttps://marketplace.visualstudio.com/items?itemName=apimatic-developers.apimatic-for-vscode\u001b[0m"; private readonly serverUrl = "\u001b[4mhttp://localhost:3000\u001b[0m"; - private readonly referenceDocumentation = + private readonly referenceDocumentationUrl = "\u001b[4mhttps://docs.apimatic.io/platform-api/#/http/guides/generating-on-prem-api-portal/overview-generating-api-portal\u001b[0m"; - private readonly customizeTheSdks = + private readonly customizeTheSdksUrl = "\u001b[4mhttps://docs.apimatic.io/generate-sdks/codegen-settings/codegen-settings-overview\u001b[0m"; - private readonly portalDirectory = "apimatic-quickstart-portal"; - private readonly defaultPortalDirectory = path.join(process.cwd(), this.portalDirectory); + private readonly defaultPortalDirectoryPath = process.cwd(); displayWelcomeMessage(): void { intro(`Hello there 👋`); @@ -162,7 +161,7 @@ export class PortalQuickstartPrompts { "Good luck fixing your API definition! 🛠️ Feel free to run this command again once you're done." ) ); - process.exit(0); + return process.exit(0); } } @@ -201,13 +200,13 @@ export class PortalQuickstartPrompts { placeholder: "Enter absolute path to the directory or leave it empty to use the current directory.", defaultValue: "./", validate: (input) => { - const dirPath = path.resolve(input.trim() || this.portalDirectory); + const dirPath = path.resolve(input.trim()); - if (!fs.existsSync(dirPath) && dirPath != this.defaultPortalDirectory) { + if (!fs.existsSync(dirPath) && dirPath != this.defaultPortalDirectoryPath) { return getMessageInRedColor("Error: The specified directory path does not exist. Please try again."); } - if (dirPath !== this.defaultPortalDirectory) { + if (dirPath !== this.defaultPortalDirectoryPath) { const files = fs.readdirSync(dirPath).filter(item => !item.startsWith('.'));; if (files.length > 0) { return getMessageInRedColor("Error: The target directory is not empty. Please provide a path to an empty directory or clear its contents."); @@ -224,9 +223,9 @@ export class PortalQuickstartPrompts { } if (directory === "./") { - return this.defaultPortalDirectory; + return this.defaultPortalDirectoryPath; } else { - return path.join(String(directory), this.portalDirectory); + return String(directory); } } @@ -272,12 +271,12 @@ export class PortalQuickstartPrompts { getMessageInCyanColor(`Press CTRL+C to stop the server.\n\n`) + getMessageInCyanColor(`What's next?\n`) + getMessageInCyanColor(`- Check out the Interactive Playground in your API Portal.\n`) + - getMessageInCyanColor(`- Read the reference documentation to learn more about how you can customize this API Portal: ${this.referenceDocumentation}`) + + getMessageInCyanColor(`- Read the reference documentation to learn more about how you can customize this API Portal: ${this.referenceDocumentationUrl}`) + getMessageInCyanColor(` \n`) + getMessageInCyanColor( `- Review the SDK Documentation for your favourite programming language and download an SDK from the API Portal.\n` ) + - getMessageInCyanColor(`- Check out how you can customize the SDKs using Code Generation settings: ${this.customizeTheSdks}`) + + getMessageInCyanColor(`- Check out how you can customize the SDKs using Code Generation settings: ${this.customizeTheSdksUrl}`) + getMessageInCyanColor(` \n`) ); } diff --git a/src/utils/utils.ts b/src/utils/utils.ts index 808848ef..74264f9e 100644 --- a/src/utils/utils.ts +++ b/src/utils/utils.ts @@ -191,7 +191,7 @@ export const zipDirectory = async (sourcePath: string, destinationPath: string): // Check if the directory exists for the user or not await fs.ensureDir(sourcePath); - const zipPath = path.join(destinationPath, "target.zip"); + const zipPath = path.join(destinationPath, ".target.zip"); const output = fs.createWriteStream(zipPath); const archive = archiver("zip"); @@ -228,6 +228,14 @@ export const isJSONParsable = (json: string) => { } }; +export const getGeneratedFilesPaths = (sourceDir: string, portalDir: string): string[] => { + const generatedZipPath = path.join(sourceDir, ".portal_source.zip"); + const generatedPortalZipPath = path.join(sourceDir, ".generated_portal.zip"); + const generatedPortalPath = path.join(path.dirname(portalDir), "generated_portal"); + + return [generatedZipPath, generatedPortalPath, generatedPortalZipPath]; +}; + export const getFileNameFromPath = (filePath: string) => { return path.basename(filePath).split(".")[0]; }; @@ -369,8 +377,8 @@ export async function validateAndZipPortalSource( } export async function cleanUpGeneratedPortalFiles(sourceDir: string) { - const generatedPortalZipFilePath = path.join(sourceDir, "generated_portal.zip"); - const generatedPortalSourceZipFilePath = path.join(sourceDir, "portal_source.zip"); + const generatedPortalZipFilePath = path.join(sourceDir, ".generated_portal.zip"); + const generatedPortalSourceZipFilePath = path.join(sourceDir, ".portal_source.zip"); if (fs.existsSync(generatedPortalZipFilePath)) { await deleteFile(generatedPortalZipFilePath); } diff --git a/src/validators/common/directoryValidator.ts b/src/validators/common/directoryValidator.ts index 32b5d0d3..0c974dc7 100644 --- a/src/validators/common/directoryValidator.ts +++ b/src/validators/common/directoryValidator.ts @@ -12,11 +12,11 @@ export class DirectoryValidator { } async validateGeneratedPortalDestinationDirectory(destinationDir: string, portalDir: string) { - if (!fs.pathExistsSync(destinationDir) && destinationDir != "./api-portal") { + if (!fs.pathExistsSync(destinationDir) && destinationDir != "./generated_portal") { this.error(getMessageInRedColor(`The specified destination directory does not exist: ${destinationDir}. Please provide a valid destination directory to continue.`)); } - if (destinationDir == "./api-portal") { + if (destinationDir == "./generated_portal") { await fs.ensureDir(portalDir); } } @@ -58,7 +58,7 @@ export class DirectoryValidator { validateGeneratedPortalDestinationDirectoryIsEmpty(destinationDir: string) { const portalDirItems = getNonHiddenItemsFromDirectory(destinationDir); - if (portalDirItems.length > 0 && destinationDir != "./api-portal") { + if (portalDirItems.length > 0 && destinationDir != "./generated_portal") { this.error( getMessageInRedColor( "The destination directory is not empty. Please specify an empty destination directory or empty the provided directory." From 20a85c0b3df9bf41fbc795724e33abf18bbe5863 Mon Sep 17 00:00:00 2001 From: Ali Asghar <75574550+aliasghar98@users.noreply.github.com> Date: Mon, 19 May 2025 11:26:52 +0500 Subject: [PATCH 2/7] fix: added trimming for quickstart prompt inputs --- src/commands/portal/generate.ts | 2 +- src/prompts/portal/quickstart.ts | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/commands/portal/generate.ts b/src/commands/portal/generate.ts index 3bf110c2..cd0a45d2 100644 --- a/src/commands/portal/generate.ts +++ b/src/commands/portal/generate.ts @@ -47,7 +47,7 @@ Your portal has been generated at D:/ const sourceFolderPath: string = flags.folder; const portalFolderPath: string = path.join(flags.destination, "generated_portal"); const zippedPortalPath: string = path.join(flags.destination, ".generated_portal.zip"); - const overrideAuthKey: string | null = flags["auth-key"] ? flags["auth-key"] : null; + const overrideAuthKey: string | null = flags["auth-key"] ?? null; const prompts = new PortalGeneratePrompts(); // Check if at destination, portal already exists and throw error if force flag is not set for both zip and extracted diff --git a/src/prompts/portal/quickstart.ts b/src/prompts/portal/quickstart.ts index 8a552205..a90b011c 100644 --- a/src/prompts/portal/quickstart.ts +++ b/src/prompts/portal/quickstart.ts @@ -69,7 +69,7 @@ export class PortalQuickstartPrompts { return process.exit(0); } - return { email: String(email), password: String(pass) }; + return { email: String(email).trim(), password: String(pass).trim() }; } displayLoggingInMessage(): void { @@ -117,7 +117,7 @@ export class PortalQuickstartPrompts { return process.exit(0); } - return String(spec); + return String(spec).trim(); } displaySpecValidationMessage(): void { @@ -225,7 +225,7 @@ export class PortalQuickstartPrompts { if (directory === "./") { return this.defaultPortalDirectoryPath; } else { - return String(directory); + return String(directory).trim(); } } From 2da2789c94af458fb31b046b6374c06b48a3c402 Mon Sep 17 00:00:00 2001 From: Ali Asghar <75574550+aliasghar98@users.noreply.github.com> Date: Mon, 19 May 2025 12:08:15 +0500 Subject: [PATCH 3/7] fix: invalid directory location printed in outro message in the case of user provided portal directory --- src/commands/portal/quickstart.ts | 2 +- src/prompts/portal/quickstart.ts | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/commands/portal/quickstart.ts b/src/commands/portal/quickstart.ts index 10cafeb4..ed478a20 100644 --- a/src/commands/portal/quickstart.ts +++ b/src/commands/portal/quickstart.ts @@ -121,7 +121,7 @@ export default class PortalQuickstart extends Command { controller.servePortal(generatedPortalPath, directory, this.config.configDir); - prompts.displayOutroMessage(); + prompts.displayOutroMessage(directory); } catch (error) { this.error(getMessageInRedColor(error instanceof Error ? error.message : String(error))); } diff --git a/src/prompts/portal/quickstart.ts b/src/prompts/portal/quickstart.ts index a90b011c..68bbe06b 100644 --- a/src/prompts/portal/quickstart.ts +++ b/src/prompts/portal/quickstart.ts @@ -262,11 +262,11 @@ export class PortalQuickstartPrompts { this.spin.stop(getMessageInCyanColor("✅ Portal setup complete!")); } - displayOutroMessage(): void { + displayOutroMessage(directory: string): void { log.step( getMessageInCyanColor(`📢 Your API Portal is live at: ${this.serverUrl}\n`) + getMessageInCyanColor( - `Hot reload enabled! Edit files in ./apimatic-quickstart-portal to see changes instantly reflected in your API Portal.\n` + `Hot reload enabled! Edit files in ${directory} to see changes instantly reflected in your API Portal.\n` ) + getMessageInCyanColor(`Press CTRL+C to stop the server.\n\n`) + getMessageInCyanColor(`What's next?\n`) + From eb0499c6cf9562e82e085226bf0dc7a42ebc0ca9 Mon Sep 17 00:00:00 2001 From: Ali Asghar <75574550+aliasghar98@users.noreply.github.com> Date: Mon, 19 May 2025 17:27:29 +0500 Subject: [PATCH 4/7] fix: multiple served portals crash due to port being already used --- src/commands/portal/quickstart.ts | 10 ++-- src/controllers/portal/quickstart.ts | 10 ++-- src/services/portal/server.ts | 87 +++++++++++++++++++--------- 3 files changed, 72 insertions(+), 35 deletions(-) diff --git a/src/commands/portal/quickstart.ts b/src/commands/portal/quickstart.ts index ed478a20..e0423fbc 100644 --- a/src/commands/portal/quickstart.ts +++ b/src/commands/portal/quickstart.ts @@ -7,7 +7,7 @@ import { SpecFile } from "../../types/portal/quickstart"; import { getMessageInRedColor } from "../../utils/utils"; export default class PortalQuickstart extends Command { - static description = "Create your first API Portal using APIMatic’s Docs as Code offering."; + static description = "Create your first API Portal using APIMatic's Docs as Code offering."; static examples = ["$ apimatic portal:quickstart"]; @@ -119,9 +119,11 @@ export default class PortalQuickstart extends Command { const generatedPortalPath = await this.getGeneratedPortalPath(prompts, controller, directory); - controller.servePortal(generatedPortalPath, directory, this.config.configDir); - - prompts.displayOutroMessage(directory); + const serverStarted = await controller.servePortal(generatedPortalPath, directory, this.config.configDir); + + if (serverStarted) { + prompts.displayOutroMessage(directory); + } } catch (error) { this.error(getMessageInRedColor(error instanceof Error ? error.message : String(error))); } diff --git a/src/controllers/portal/quickstart.ts b/src/controllers/portal/quickstart.ts index 0463df24..bb18b1b8 100644 --- a/src/controllers/portal/quickstart.ts +++ b/src/controllers/portal/quickstart.ts @@ -301,7 +301,7 @@ export class PortalQuickstartController { ) ); } else if (error.response.status === 403) { - throw new Error(getMessageInRedColor(`Access denied. It looks like you don’t have access to APIMatic’s Docs as Code offering. Check your subscription details and contact our team at support@apimatic.io if you believe this is a mistake.`)); + throw new Error(getMessageInRedColor(`Access denied. It looks like you don't have access to APIMatic's Docs as Code offering. Check your subscription details and contact our team at support@apimatic.io if you believe this is a mistake.`)); } else if (error.response.status === 422) { throw new Error( getMessageInRedColor( @@ -342,12 +342,14 @@ export class PortalQuickstartController { } } - async servePortal(generatedPortalPath: string, targetFolder: string, configDir: string): Promise { + async servePortal(generatedPortalPath: string, targetFolder: string, configDir: string): Promise { const server = new PortalServerService(); server.setupServer(generatedPortalPath); - await server.startServer( + await cleanUpGeneratedPortalFiles(targetFolder); + + return await server.startServer( { generatedPortalPath, targetFolder, @@ -357,7 +359,5 @@ export class PortalQuickstartController { false, false ); - - await cleanUpGeneratedPortalFiles(targetFolder); } } diff --git a/src/services/portal/server.ts b/src/services/portal/server.ts index 02d4bd45..15c44e3d 100644 --- a/src/services/portal/server.ts +++ b/src/services/portal/server.ts @@ -5,51 +5,86 @@ import * as open from "open"; import { watchAndRegeneratePortal } from "../../controllers/portal/serve"; import { PortalServerConfig } from "../../types/portal/quickstart"; import { Server } from "http"; -import { getMessageInRedColor } from "../../utils/utils"; +import { getMessageInRedColor, isPortInUse } from "../../utils/utils"; export class PortalServerService { private server!: Server; private liveReloadServer!: livereload.LiveReloadServer; private readonly app: express.Application; private readonly port = 3000; + private readonly liveReloadPort = 35729; constructor() { this.app = express(); } - setupServer(generatedPortalPath: string): void { - this.liveReloadServer = livereload.createServer(); - this.liveReloadServer.watch(generatedPortalPath); + private async findAvailablePort(startPort: number): Promise { + let port = startPort; + const maxPort = startPort + 10; // Limit the port search range + + while (port < maxPort) { + if (!isPortInUse(port)) { + return port; + } + port++; + } + + // If no port is found in the range, return the original port + return startPort; + } - this.app.use(connectLivereload()); + private async createLiveReloadServer(generatedPortalPath: string): Promise { + try { + const availablePort = await this.findAvailablePort(this.liveReloadPort); + + this.liveReloadServer = livereload.createServer({ + port: availablePort + }); + + this.liveReloadServer.watch(generatedPortalPath); + } catch (error) { + console.log(getMessageInRedColor(`Unable to serve the portal: ${(error as Error).message}`)); + } + } + + async setupServer(generatedPortalPath: string): Promise { + await this.createLiveReloadServer(generatedPortalPath); + + if (this.liveReloadServer) { + this.app.use(connectLivereload()); + } this.app.use(express.static(generatedPortalPath)); } - startServer(config: PortalServerConfig, noReload = false, displayShutdownMessages = true): Promise { + async startServer(config: PortalServerConfig, noReload = false, displayShutdownMessages = true): Promise { const { generatedPortalPath, targetFolder, configDir, authKey, ignoredPaths, port, openInBrowser } = config; - const serverPort = port ?? this.port; + const requestedPort = port ?? this.port; - return new Promise((resolve) => { - try { - this.server = this.app.listen(serverPort, () => { - if (openInBrowser) { - open(`http://localhost:${serverPort}`); - } + return new Promise((resolve, reject) => { + this.server = this.app.listen(requestedPort, () => { + if (openInBrowser) { + open(`http://localhost:${requestedPort}`); + } - if (!noReload) { - watchAndRegeneratePortal(targetFolder, generatedPortalPath, configDir, authKey, ignoredPaths); - } + if (!noReload) { + watchAndRegeneratePortal(targetFolder, generatedPortalPath, configDir, authKey, ignoredPaths); + } - if (process.platform !== "darwin") { - //For non-macOS users. - if (process.stdin.setRawMode) { - process.stdin.setRawMode(false); - } + if (process.platform !== "darwin") { + //For non-macOS users. + if (process.stdin.setRawMode) { + process.stdin.setRawMode(false); } - }); - } catch (error) { - throw new Error(getMessageInRedColor(`There was an error starting the server: ${error}`)); - } + } + resolve(true); + }).on('error', (err: NodeJS.ErrnoException) => { + if (err.code === 'EADDRINUSE') { + console.error(getMessageInRedColor(`Port ${requestedPort} is not available. Unable to serve your portal.`)); + } else { + console.error(getMessageInRedColor(`Unable to serve the portal: ${err.message}`)); + } + reject(err); + }); const shutdown = async () => { if (displayShutdownMessages) { @@ -59,7 +94,7 @@ export class PortalServerService { if (displayShutdownMessages) { console.log("Server shut down successfully."); } - resolve(); + resolve(true); process.exit(0); }; From 28272c0330af3765c6ae54d6992a88f5e06f84b3 Mon Sep 17 00:00:00 2001 From: Ali Asghar <75574550+aliasghar98@users.noreply.github.com> Date: Mon, 19 May 2025 17:38:31 +0500 Subject: [PATCH 5/7] fix: missing await for isPortInUse --- src/services/portal/server.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/services/portal/server.ts b/src/services/portal/server.ts index 15c44e3d..0d392ee0 100644 --- a/src/services/portal/server.ts +++ b/src/services/portal/server.ts @@ -23,7 +23,7 @@ export class PortalServerService { const maxPort = startPort + 10; // Limit the port search range while (port < maxPort) { - if (!isPortInUse(port)) { + if (!(await isPortInUse(port))) { return port; } port++; From f492311ab61f94d4d02b483213d2bf1898574348 Mon Sep 17 00:00:00 2001 From: Ali Asghar <75574550+aliasghar98@users.noreply.github.com> Date: Mon, 19 May 2025 17:44:53 +0500 Subject: [PATCH 6/7] chore: added launch.json to git ignore --- .gitignore | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 9a12e18c..37a3356b 100644 --- a/.gitignore +++ b/.gitignore @@ -174,4 +174,5 @@ $RECYCLE.BIN/ # Custom rules (everything added below won't be overriden by 'Generate .gitignore File' if you use 'Update' option) lib/ -tmp/ \ No newline at end of file +tmp/ +.vscode/launch.json From 3104fd65e75225a2cd40d270920193221a7ec5b7 Mon Sep 17 00:00:00 2001 From: Ali Asghar <75574550+aliasghar98@users.noreply.github.com> Date: Fri, 23 May 2025 12:08:43 +0500 Subject: [PATCH 7/7] fix: updated clack prompts version --- package-lock.json | 16 ++++++++-------- package.json | 2 +- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/package-lock.json b/package-lock.json index 932079e2..49891a62 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,7 +10,7 @@ "license": "MIT", "dependencies": { "@apimatic/sdk": "^0.1.0-alpha.2", - "@clack/prompts": "^0.10.0", + "@clack/prompts": "^0.11.0", "@oclif/core": "^4.2.8", "@oclif/plugin-autocomplete": "^3.2.24", "@oclif/plugin-help": "^6.2.26", @@ -1516,9 +1516,9 @@ } }, "node_modules/@clack/core": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/@clack/core/-/core-0.4.1.tgz", - "integrity": "sha512-Pxhij4UXg8KSr7rPek6Zowm+5M22rbd2g1nfojHJkxp5YkFqiZ2+YLEM/XGVIzvGOcM0nqjIFxrpDwWRZYWYjA==", + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/@clack/core/-/core-0.5.0.tgz", + "integrity": "sha512-p3y0FIOwaYRUPRcMO7+dlmLh8PSRcrjuTndsiA0WAFbWES0mLZlrjVoBRZ9DzkPFJZG6KGkJmoEAY0ZcVWTkow==", "license": "MIT", "dependencies": { "picocolors": "^1.0.0", @@ -1526,12 +1526,12 @@ } }, "node_modules/@clack/prompts": { - "version": "0.10.0", - "resolved": "https://registry.npmjs.org/@clack/prompts/-/prompts-0.10.0.tgz", - "integrity": "sha512-H3rCl6CwW1NdQt9rE3n373t7o5cthPv7yUoxF2ytZvyvlJv89C5RYMJu83Hed8ODgys5vpBU0GKxIRG83jd8NQ==", + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@clack/prompts/-/prompts-0.11.0.tgz", + "integrity": "sha512-pMN5FcrEw9hUkZA4f+zLlzivQSeQf5dRGJjSUbvVYDLvpKCdQx5OaknvKzgbtXOizhP+SJJJjqEbOe55uKKfAw==", "license": "MIT", "dependencies": { - "@clack/core": "0.4.1", + "@clack/core": "0.5.0", "picocolors": "^1.0.0", "sisteransi": "^1.0.5" } diff --git a/package.json b/package.json index 1ac9066a..b00bc2a6 100644 --- a/package.json +++ b/package.json @@ -44,7 +44,7 @@ }, "dependencies": { "@apimatic/sdk": "^0.1.0-alpha.2", - "@clack/prompts": "^0.10.0", + "@clack/prompts": "^0.11.0", "@oclif/core": "^4.2.8", "@oclif/plugin-autocomplete": "^3.2.24", "@oclif/plugin-help": "^6.2.26",