diff --git a/package-lock.json b/package-lock.json index 4d16e907..efb26583 100644 --- a/package-lock.json +++ b/package-lock.json @@ -26,6 +26,7 @@ "file-type": "^15.0.0", "form-data": "^4.0.2", "fs-extra": "^11.3.0", + "get-port": "^7.1.0", "livereload": "^0.9.3", "open": "^8.4.0", "prettier": "^2.8.8", @@ -8857,6 +8858,18 @@ "node": ">=8.0.0" } }, + "node_modules/get-port": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/get-port/-/get-port-7.1.0.tgz", + "integrity": "sha512-QB9NKEeDg3xxVwCCwJQ9+xycaz6pBB6iQ76wiWMl1927n0Kir6alPiP+yuiICLLU4jpMe08dXfpebuQppFA2zw==", + "license": "MIT", + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/get-proto": { "version": "1.0.1", "license": "MIT", diff --git a/package.json b/package.json index 1fde49ad..c276f439 100644 --- a/package.json +++ b/package.json @@ -61,6 +61,7 @@ "file-type": "^15.0.0", "form-data": "^4.0.2", "fs-extra": "^11.3.0", + "get-port": "^7.1.0", "livereload": "^0.9.3", "open": "^8.4.0", "prettier": "^2.8.8", diff --git a/src/actions/portal/recipe/new-recipe.ts b/src/actions/portal/recipe/new-recipe.ts index 94287fe7..a12dccd2 100644 --- a/src/actions/portal/recipe/new-recipe.ts +++ b/src/actions/portal/recipe/new-recipe.ts @@ -234,7 +234,7 @@ export class PortalRecipeAction { // Check if the file exists if (!fs.existsSync(tocFilePath)) { return Result.failure( - `TOC file not found at ${tocFilePath}. Please run 'apimatic:toc:new' to create your TOC file first.` + `toc.yml file not found at ${tocFilePath}. Please run 'apimatic:toc:new' to create your toc.yml file first.` ); } @@ -243,7 +243,7 @@ export class PortalRecipeAction { return Result.success(parse(tocContent)); } catch { return Result.failure( - `Unable to parse the TOC file located at ${tocFilePath}. Please make sure that the TOC is a valid YAML file.` + `Unable to parse the toc.yml file located at ${tocFilePath}. Please make sure that the toc.yml is a valid YAML file.` ); } } diff --git a/src/client-utils/sdk-client.ts b/src/client-utils/sdk-client.ts index caedbcb3..21774360 100644 --- a/src/client-utils/sdk-client.ts +++ b/src/client-utils/sdk-client.ts @@ -37,17 +37,8 @@ export class SDKClient { */ public async login(email: string, password: string, configDir: string): Promise { - let storedAuthInfo: AuthInfo | null = await getAuthInfo(configDir); - - // If no config file or no credentials exist in the config file - if (!storedAuthInfo) { - storedAuthInfo = { email: "", authKey: "" }; - } - const credentials: Credentials = { email, password }; const authKey: string = await this.getAuthKey(credentials); - - if (storedAuthInfo.email !== email) { setAuthInfo( { email, @@ -56,19 +47,8 @@ export class SDKClient { configDir ); - return "Logged in"; - } else if (authKey === storedAuthInfo.authKey) { - return "Already logged in"; - } else { - setAuthInfo( - { - email, - authKey - }, - configDir - ); - return "Logged in"; - } + return "✅ Logged in successfully as " + email; + } public async logout(configDir: string): Promise { diff --git a/src/commands/auth/login.ts b/src/commands/auth/login.ts index f803606d..08b7ce64 100644 --- a/src/commands/auth/login.ts +++ b/src/commands/auth/login.ts @@ -3,6 +3,7 @@ import { Flags, Command } from "@oclif/core"; import { outro, password, text } from "@clack/prompts"; import { getMessageInRedColor, replaceHTML } from "../../utils/utils.js"; import { SDKClient } from "../../client-utils/sdk-client.js"; +import { getAuthInfo } from "../../client-utils/auth-manager.js"; export default class Login extends Command { static description = "Login using your APIMatic credentials or an API Key"; @@ -26,43 +27,55 @@ Authentication key successfully set` const { flags } = await this.parse(Login); const configDir: string = this.config.configDir; try { - const client: SDKClient = SDKClient.getInstance(); + // Check if already logged in + const storedAuthInfo = await getAuthInfo(configDir); + if (storedAuthInfo && storedAuthInfo.authKey) { + if (storedAuthInfo.email) { + return this.log( + `You are already logged in as '${storedAuthInfo.email}'. Use auth:logout to logout before logging in again.` + ); + } + return this.log( + `You are already logged in with authentication key. Use auth:logout to logout before logging in again.` + ); + } + const client: SDKClient = SDKClient.getInstance(); // If user is setting auth key if (flags["auth-key"]) { const response = client.setAuthKey(flags["auth-key"], configDir); return this.log(response); - } else { - // If user logs in with email and password - const email = await text({ - message: "Enter your registered email:", - validate: (input) => { - if (!input) { - return getMessageInRedColor("Email is required."); - } + } - const emailRegex = - /^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$/; + // If user logs in with email and password + const email = await text({ + message: "Enter your registered email:", + validate: (input) => { + if (!input) { + return getMessageInRedColor("Email is required."); + } + + const emailRegex = + /^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$/; - if (!emailRegex.test(input)) { - return getMessageInRedColor("Please enter a valid email address."); - } + if (!emailRegex.test(input)) { + return getMessageInRedColor("Please enter a valid email address."); } - }); + } + }); - const pass = await password({ - message: "Please enter your password:", - validate: (input) => { - if (!input) { - return getMessageInRedColor("Password is required."); - } + const pass = await password({ + message: "Please enter your password:", + validate: (input) => { + if (!input) { + return getMessageInRedColor("Password is required."); } - }); + } + }); - const response: string = await client.login(email as string, pass as string, configDir); + const response: string = await client.login(email as string, pass as string, configDir); - outro(response); - } + outro(response); } catch (error) { if (error && (error as AxiosError).response) { const apiError = error as AxiosError; diff --git a/src/commands/portal/serve.ts b/src/commands/portal/serve.ts index 1db49a7e..afc0b1c5 100644 --- a/src/commands/portal/serve.ts +++ b/src/commands/portal/serve.ts @@ -1,6 +1,7 @@ import * as path from "path"; import axios from "axios"; import { Command, Flags } from "@oclif/core"; +import getPort from 'get-port'; import { generatePortal } from "../../controllers/portal/serve.js"; import { PortalServerService } from "../../services/portal/server.js"; import { PortalServePrompts } from "../../prompts/portal/serve.js"; @@ -14,7 +15,6 @@ export default class PortalServe extends Command { port: Flags.integer({ char: "p", description: "Port to serve the portal.", - default: 3000 }), destination: Flags.string({ char: "d", @@ -57,12 +57,25 @@ export default class PortalServe extends Command { const ignoredPaths = flags.ignore.split(",").map((path) => path.trim()); const portalDir = path.resolve(flags.destination); const sourceDir = path.resolve(flags.source); - const port = flags.port; const overrideAuthKey = flags["auth-key"] ?? null; const serverService = new PortalServerService(); const prompts = new PortalServePrompts(); const validator = new PortalServeValidator(this.error); const allIgnoredPaths = [...ignoredPaths, ...getGeneratedFilesPaths(sourceDir, portalDir)]; + const defaultPorts = [3000, 3001, 3002]; + + const preferredPorts = typeof flags.port === 'number' + ? [flags.port, ...defaultPorts.filter(p => p !== flags.port)] + : defaultPorts; + + const availablePort = await getPort({ port: preferredPorts }); + + // Show warning only if user provided --port and it is not available + if (typeof flags.port === 'number' && availablePort !== flags.port) { + this.log(`⚠️ Port ${flags.port} is already in use. Available port ${availablePort} will be used.`); + } + const port = availablePort; + await validator.validate(port, flags.destination, sourceDir, portalDir); diff --git a/src/commands/portal/toc/new.ts b/src/commands/portal/toc/new.ts index 3a645382..f7a91983 100644 --- a/src/commands/portal/toc/new.ts +++ b/src/commands/portal/toc/new.ts @@ -18,7 +18,7 @@ https://docs.apimatic.io/platform-api/#/http/guides/generating-on-prem-api-porta static flags = { destination: Flags.string({ parse: async (input: string) => path.resolve(input), - description: "optional path where the generated TOC file will be saved. Defaults to the current working directory if not provided.", + description: "optional path where the generated toc.yml file will be saved. Defaults to the current working directory if not provided.", }), folder: Flags.string({ parse: async (input: string) => path.resolve(input), @@ -27,15 +27,15 @@ https://docs.apimatic.io/platform-api/#/http/guides/generating-on-prem-api-porta }), force: Flags.boolean({ default: false, - description: "overwrite the TOC file if one already exists at the destination.", + description: "overwrite the toc.yml file if one already exists at the destination.", }), "expand-endpoints": Flags.boolean({ default: false, - description: "include individual entries for each endpoint in the generated TOC. Requires a valid API specification in the working directory." + description: "include individual entries for each endpoint in the generated toc.yml. Requires a valid API specification in the working directory." }), "expand-models": Flags.boolean({ default: false, - description: "include individual entries for each model in the generated TOC. Requires a valid API specification in the working directory." + description: "include individual entries for each model in the generated toc.yml. Requires a valid API specification in the working directory." }) }; diff --git a/src/prompts/portal/quickstart.ts b/src/prompts/portal/quickstart.ts index 2fc8203c..7b76dd3e 100644 --- a/src/prompts/portal/quickstart.ts +++ b/src/prompts/portal/quickstart.ts @@ -86,19 +86,31 @@ export class PortalQuickstartPrompts { this.spin.stop(getMessageInCyanColor("✅ Login successful!")); } + removeQuotes(str: string) { + const quotes = ['"', "'"]; + + for (const quote of quotes) { + if (str.startsWith(quote) && str.endsWith(quote) && str.length > 1) { + return str.slice(1, -1); + } + } + return str; +} + async specPrompt(): Promise { log.step(getMessageInOrangeColor(`Step 1 of 4: Import your OpenAPI Definition`)); const spec = await text({ - message: `Provide a local path or a public URL for your OpenAPI Definition file:`, - placeholder: "Press Enter to use a sample OpenAPI file for APIMatic", + message: `Provide a local path or a public URL for your OpenAPI definition file:`, + placeholder: "Provide absolute URL/local path or press Enter to use sample OpenAPI file from APIMatic.", defaultValue: "https://raw.githubusercontent.com/apimatic/static-portal-workflow/refs/heads/master/spec/Apimatic-Calculator.json", validate: (input) => { if (!input) return; if (isValidUrl(input)) return; - const dirPath = path.resolve((input ?? "").trim()); + const cleanedPath = this.removeQuotes(input.trim() ?? ""); + const dirPath = path.resolve(cleanedPath); if (fs.existsSync(dirPath)) { if (fs.statSync(dirPath).isFile()) { @@ -201,11 +213,12 @@ export class PortalQuickstartPrompts { log.step(getMessageInOrangeColor(`Step 4 of 4: Generate source files for Docs as Code`)); const directory = await text({ - message: "Enter the directory path where you would like to setup the API Portal :", - placeholder: "Enter absolute path to the directory or leave it empty to use the current directory.", + message: "Enter the directory path where you would like to setup the API Portal (Requires an empty directory):", + placeholder: "Provide absolute path to the directory or press Enter to use the current directory.", defaultValue: "./", validate: (input) => { - const dirPath = path.resolve((input ?? "").trim()); + const cleanedPath = this.removeQuotes(input?.trim() ?? ""); + const dirPath = path.resolve(cleanedPath); if (!fs.existsSync(dirPath) && dirPath != this.defaultPortalDirectoryPath) { return getMessageInRedColor("Error: The specified directory path does not exist. Please try again."); diff --git a/src/prompts/portal/recipe/new-recipe.ts b/src/prompts/portal/recipe/new-recipe.ts index fd025a97..ec8703ac 100644 --- a/src/prompts/portal/recipe/new-recipe.ts +++ b/src/prompts/portal/recipe/new-recipe.ts @@ -208,8 +208,7 @@ export class PortalRecipePrompts { } public displayRecipeGenerationSuccessMessage(buildDirectoryPath: string) { - log.step(`🎉 Recipe has been added successfully!`); - log.message(`📦 Generated recipe has been added to build directory at: ${buildDirectoryPath}`); + log.message(`🎉 Generated recipe has been added to build directory at: ${buildDirectoryPath}`); outro( `▶ Run the command 'apimatic portal:serve' to preview your documentation portal.` ); @@ -232,7 +231,6 @@ export class PortalRecipePrompts { .join("\n"); log.step(`🛠️ You can edit the following files to customize your API Recipe :\n\n` + coloredLogString); - log.message(`💡 Modify the TOC file to change the position of the API Recipes section in the navbar.`); } public logError(error: string): void { diff --git a/src/prompts/portal/toc/new-toc.ts b/src/prompts/portal/toc/new-toc.ts index fc40f0eb..6650525c 100644 --- a/src/prompts/portal/toc/new-toc.ts +++ b/src/prompts/portal/toc/new-toc.ts @@ -5,7 +5,7 @@ export class PortalNewTocPrompts { async overwriteExistingTocPrompt(): Promise { const useExistingFile = await select({ - message: `A toc file already exists at the specified destination path, do you want to overwrite it?`, + message: `A toc.yml file already exists at the specified destination path, do you want to overwrite it?`, options: [ { value: "yes", label: "Yes" }, { value: "no", label: "No" } @@ -18,7 +18,7 @@ export class PortalNewTocPrompts { } if (useExistingFile === "no") { - outro("Please enter a different destination path or delete the existing toc file and try again."); + outro("Please enter a different destination path or delete the existing toc.yml file and try again."); } return useExistingFile === "yes"; @@ -33,7 +33,7 @@ export class PortalNewTocPrompts { } displayOutroMessage(tocPath: string): void { - outro(`✅ TOC file successfully created at: ${tocPath}`); + outro(`✅ toc.yml file successfully created at: ${tocPath}`); } logError(error: string): void { diff --git a/src/utils/utils.ts b/src/utils/utils.ts index 79235c80..310fc165 100644 --- a/src/utils/utils.ts +++ b/src/utils/utils.ts @@ -319,6 +319,11 @@ export async function validateAndZipPortalSource( outputPath: string, ignoredPaths: string[] = [] ): Promise { + + if (await fs.pathExists(outputPath)){ + await deleteFile(outputPath); + } + const output = fs.createWriteStream(outputPath); const archive = archiver("zip", { zlib: { level: 9 }