From db884cb536bc4c9d1f7bf517bf39e2cf4cfa7a9d Mon Sep 17 00:00:00 2001 From: Ali Asghar <75574550+aliasghar98@users.noreply.github.com> Date: Mon, 11 Aug 2025 14:10:06 +0500 Subject: [PATCH 01/77] fix: remove simple-git dependency (#150) --- package-lock.json | 25 --------------- package.json | 1 - src/config/env.ts | 1 - src/controllers/portal/quickstart.ts | 47 +++++++++++++++++++--------- src/infrastructure/file-service.ts | 20 ++++++++++++ 5 files changed, 52 insertions(+), 42 deletions(-) diff --git a/package-lock.json b/package-lock.json index 8c60af436..36989c000 100644 --- a/package-lock.json +++ b/package-lock.json @@ -32,7 +32,6 @@ "neverthrow": "^8.2.0", "open": "^8.4.0", "prettier": "^2.8.8", - "simple-git": "^3.27.0", "striptags": "^3.2.0", "tmp-promise": "^3.0.3", "treeify": "^1.1.0", @@ -4126,17 +4125,6 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, - "node_modules/@kwsites/file-exists": { - "version": "1.1.1", - "license": "MIT", - "dependencies": { - "debug": "^4.1.1" - } - }, - "node_modules/@kwsites/promise-deferred": { - "version": "1.1.1", - "license": "MIT" - }, "node_modules/@mswjs/interceptors": { "version": "0.38.6", "resolved": "https://registry.npmjs.org/@mswjs/interceptors/-/interceptors-0.38.6.tgz", @@ -12765,19 +12753,6 @@ "dev": true, "license": "ISC" }, - "node_modules/simple-git": { - "version": "3.27.0", - "license": "MIT", - "dependencies": { - "@kwsites/file-exists": "^1.1.1", - "@kwsites/promise-deferred": "^1.1.1", - "debug": "^4.4.0" - }, - "funding": { - "type": "github", - "url": "https://github.com/steveukx/git-js?sponsor=1" - } - }, "node_modules/sinon": { "version": "21.0.0", "resolved": "https://registry.npmjs.org/sinon/-/sinon-21.0.0.tgz", diff --git a/package.json b/package.json index bfca1b02e..74c23eaee 100644 --- a/package.json +++ b/package.json @@ -70,7 +70,6 @@ "neverthrow": "^8.2.0", "open": "^8.4.0", "prettier": "^2.8.8", - "simple-git": "^3.27.0", "striptags": "^3.2.0", "tmp-promise": "^3.0.3", "treeify": "^1.1.0", diff --git a/src/config/env.ts b/src/config/env.ts index f186b9da8..2cd2be06e 100644 --- a/src/config/env.ts +++ b/src/config/env.ts @@ -1,5 +1,4 @@ export const baseURL = "https://api.apimatic.io"; -export const staticPortalRepoUrl = "https://github.com/apimatic/static-portal-workflow.git"; export const metadataFileContent = { ImportSettings: { AutoGenerateTestCases: false, diff --git a/src/controllers/portal/quickstart.ts b/src/controllers/portal/quickstart.ts index ffac3dff4..1202cb8f9 100644 --- a/src/controllers/portal/quickstart.ts +++ b/src/controllers/portal/quickstart.ts @@ -1,4 +1,3 @@ -import { simpleGit } from "simple-git"; import axios from "axios"; import * as path from "path"; import filetype from "file-type"; @@ -18,18 +17,20 @@ import { } from "../../utils/utils.js"; import { getValidationSummary } from "../api/validate.js"; import { AuthorizationError, GetValidationParams } from "../../types/api/validate.js"; -import { metadataFileContent, staticPortalRepoUrl } from "../../config/env.js"; +import { metadataFileContent } from "../../config/env.js"; import { PortalQuickstartPrompts } from "../../prompts/portal/quickstart.js"; import { AuthenticationError } from "../../types/utils.js"; import { ZipService } from "../../infrastructure/zip-service.js"; import { FilePath } from "../../types/file/filePath.js"; import { DirectoryPath } from "../../types/file/directoryPath.js"; import { FileName } from "../../types/file/fileName.js"; +import { withDirPath } from "../../infrastructure/tmp-extensions.js"; +import { FileService } from "../../infrastructure/file-service.js"; export class PortalQuickstartController { private readonly zipService = new ZipService(); - private readonly specUrl = - "https://github.com/apimatic/static-portal-workflow/blob/master/spec/openapi.json"; + private readonly fileService = new FileService(); + private readonly specUrl = "https://github.com/apimatic/static-portal-workflow/blob/master/spec/openapi.json"; async isUserAuthenticated(configDir: string): Promise { const storedAuth = await getAuthInfo(configDir); @@ -114,10 +115,13 @@ export class PortalQuickstartController { } } else { specPath = path.normalize(specPath); - const fileType = await filetype.fromFile(specPath); + const fileType = await filetype.fromFile(specPath); if (fileType?.ext === "zip") { - await this.zipService.unArchive(new FilePath(new DirectoryPath(path.dirname(specPath)), new FileName(path.basename(specPath))), new DirectoryPath(tempSpecDir)); + await this.zipService.unArchive( + new FilePath(new DirectoryPath(path.dirname(specPath)), new FileName(path.basename(specPath))), + new DirectoryPath(tempSpecDir) + ); } else { const destinationPath = path.join(tempSpecDir, path.basename(specPath)); await fsExtra.copy(specPath, destinationPath); @@ -208,6 +212,26 @@ export class PortalQuickstartController { } } + private async downloadRepositoryFromGitHub(targetFolder: string): Promise { + return await withDirPath(async (tempDirectory) => { + const zipUrl = `https://github.com/apimatic/static-portal-workflow/archive/refs/heads/master.zip`; + const response = await fetch(zipUrl); + const repositoryFolderName = "static-portal-workflow-master"; + + if (!response.ok) { + throw new Error(`Unable to setup your portal, please try again later.`); + } + const arrayBuffer = await response.arrayBuffer(); + const tempZipPath = new FilePath(tempDirectory, new FileName("static-repo.zip")); + await this.fileService.writeBuffer(tempZipPath, Buffer.from(arrayBuffer)); + + await this.zipService.unArchive(tempZipPath, tempDirectory); + + const extractedFolderPath = new DirectoryPath(tempDirectory.toString(), repositoryFolderName); + await this.fileService.copyDirectoryContents(extractedFolderPath, new DirectoryPath(targetFolder)); + }); + } + async setupBuildDirectory( prompts: PortalQuickstartPrompts, targetFolder: string, @@ -215,20 +239,14 @@ export class PortalQuickstartController { validationSummary: ApiValidationSummary, languages: string[] ): Promise { - const git = simpleGit({ - timeout: { - block: 60 * 1000 // 1 minute timeout. - } - }); - fsExtra.emptyDirSync(targetFolder); try { - await git.clone(staticPortalRepoUrl, targetFolder); + await this.downloadRepositoryFromGitHub(targetFolder); } catch (error) { prompts.displayBuildDirectoryGenerationErrorMessage(); if (error instanceof Error) { - if (error.message.includes("timed out")) { + if (error.message.includes("timed out") || error.message.includes("timeout")) { throw new Error( getMessageInRedColor( "The operation timed out while setting up the build directory. Please check your internet connection and try again." @@ -246,7 +264,6 @@ export class PortalQuickstartController { } } - await clearDirectory(path.join(targetFolder, ".git")); await clearDirectory(path.join(targetFolder, ".github")); if (specFile.localPath && validationSummary.success) { diff --git a/src/infrastructure/file-service.ts b/src/infrastructure/file-service.ts index c8fa88c35..b7bb2c82b 100644 --- a/src/infrastructure/file-service.ts +++ b/src/infrastructure/file-service.ts @@ -1,5 +1,6 @@ import fs from "fs"; import fsExtra from "fs-extra"; +import * as path from "path"; import { FilePath } from "../types/file/filePath.js"; import { DirectoryPath } from "../types/file/directoryPath.js"; import { pipeline } from "stream"; @@ -38,6 +39,21 @@ export class FileService { await fsExtra.emptyDir(dir.toString()); // removes everything inside, keeps the dir } + public async copyDirectory(source: DirectoryPath, destination: DirectoryPath) { + await fsExtra.copy(source.toString(), destination.toString()); + } + + public async copyDirectoryContents(source: DirectoryPath, destination: DirectoryPath) { + const entries = await fsExtra.readdir(source.toString()); + await Promise.all( + entries.map(async (entry) => { + const srcEntry = path.join(source.toString(), entry); + const destEntry = path.join(destination.toString(), entry); + await fsExtra.copy(srcEntry, destEntry); + }) + ); + } + public async deleteFile(filePath: FilePath): Promise { const exists = await this.fileExists(filePath); if (exists) { @@ -62,6 +78,10 @@ export class FileService { await fsExtra.writeFile(filePath.toString(), contents, 'utf-8'); } + public async writeBuffer(filePath: FilePath, buffer: Buffer) { + await fsExtra.writeFile(filePath.toString(), buffer); + } + public async copy(source: FilePath, destination: FilePath) { await fsExtra.copyFile(source.toString(), destination.toString()); } From c25717ba58e60e74c9b617f39b7f5a721bcb9b41 Mon Sep 17 00:00:00 2001 From: Muhammad Sohail <62895181+sohail2721@users.noreply.github.com> Date: Tue, 12 Aug 2025 11:36:07 +0500 Subject: [PATCH 02/77] feat: Added openfile method in file-service (#145) --- src/actions/portal/generate.ts | 25 +++++++++++++++------- src/infrastructure/file-service.ts | 34 ++++++++++++++++++++++++++++-- 2 files changed, 49 insertions(+), 10 deletions(-) diff --git a/src/actions/portal/generate.ts b/src/actions/portal/generate.ts index 42b3d8497..112b5c414 100644 --- a/src/actions/portal/generate.ts +++ b/src/actions/portal/generate.ts @@ -10,7 +10,6 @@ import { BuildContext } from "../../types/build-context.js"; import { PortalContext } from "../../types/portal-context.js"; import { withDirPath } from "../../infrastructure/tmp-extensions.js"; - export class GenerateAction { private readonly prompts: PortalGeneratePrompts = new PortalGeneratePrompts(); private readonly zipArchiver: ZipService = new ZipService(); @@ -30,13 +29,12 @@ export class GenerateAction { force: boolean, zipPortal: boolean ): Promise => { - if (buildDirectory.isEqual(portalDirectory)) { return ActionResult.error(`The 'src' and 'portal' directory cannot be the same: "${portalDirectory}"`); } const buildContext = new BuildContext(buildDirectory); - if (!await buildContext.validate()) { + if (!(await buildContext.validate())) { return ActionResult.error(`The 'src' directory is either empty or invalid: "${buildDirectory}"`); } @@ -68,10 +66,14 @@ export class GenerateAction { return ActionResult.success(); }); - } + }; - private async parseError(error: string | NodeJS.ReadableStream, portalDirectory: DirectoryPath, tempDirectory: DirectoryPath): Promise { - if (typeof error === 'string') { + private async parseError( + error: string | NodeJS.ReadableStream, + portalDirectory: DirectoryPath, + tempDirectory: DirectoryPath + ): Promise { + if (typeof error === "string") { return error; } @@ -82,7 +84,14 @@ export class GenerateAction { await this.zipArchiver.unArchive(tempErrorFilePath, portalDirectory); const errorReportPath = portalDirectory.join("apimatic-debug"); - return "An error occurred during portal generation due to an issue with the input. An error report has been written at the destination path: " + - errorReportPath; + + const htmlFilePath = new FilePath(errorReportPath, new FileName("apimatic-report.html")); + await this.fileService.openFile(htmlFilePath); // Open the error report in the default browser + + return ( + "An error occurred during portal generation due to an issue with the input. " + + "An error report has been written at the destination path: " + + errorReportPath + ); } } diff --git a/src/infrastructure/file-service.ts b/src/infrastructure/file-service.ts index b7bb2c82b..2335f6335 100644 --- a/src/infrastructure/file-service.ts +++ b/src/infrastructure/file-service.ts @@ -5,6 +5,7 @@ import { FilePath } from "../types/file/filePath.js"; import { DirectoryPath } from "../types/file/directoryPath.js"; import { pipeline } from "stream"; import { promisify } from "util"; +import { spawn } from "child_process"; export class FileService { public async fileExists(file: FilePath): Promise { @@ -30,7 +31,7 @@ export class FileService { const files = await fsExtra.readdir(dir.toString()); return files.length === 0; } catch (error) { - return error instanceof Error && 'code' in error && error.code === "ENOENT"; + return error instanceof Error && "code" in error && error.code === "ENOENT"; } } @@ -66,7 +67,7 @@ export class FileService { } public async getContents(filePath: FilePath): Promise { - return await fsExtra.readFile(filePath.toString(), 'utf-8'); + return await fsExtra.readFile(filePath.toString(), "utf-8"); } public async writeFile(filePath: FilePath, stream: NodeJS.ReadableStream) { @@ -86,6 +87,35 @@ export class FileService { await fsExtra.copyFile(source.toString(), destination.toString()); } + public async openFile(filePath: FilePath): Promise { + const targetPath = filePath.toString(); + + // Determine the command and args without using the shell + let command: string; + let args: string[]; + + switch (os.platform()) { + case "win32": + command = "cmd"; + args = ["/c", "start", "", targetPath]; + break; + case "darwin": + command = "open"; + args = [targetPath]; + break; + default: + command = "xdg-open"; + args = [targetPath]; + break; + } + + try { + const child = spawn(command, args, { stdio: "ignore", detached: true }); + child.unref(); // Let it run without blocking + } catch { + // Silently ignore errors + } + } } const streamPipeline = promisify(pipeline); From 98f0c467016cfad38ca2876df4d990c34d004290 Mon Sep 17 00:00:00 2001 From: Ali Asghar <75574550+aliasghar98@users.noreply.github.com> Date: Tue, 12 Aug 2025 12:48:56 +0500 Subject: [PATCH 03/77] fix: missing import for os and update node versions in gh action (#158) - Added missing import for os in file service. - Updated node versions in check build github action to include versions 22 and 24. --- .github/workflows/check_build.yml | 2 +- src/infrastructure/file-service.ts | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/check_build.yml b/.github/workflows/check_build.yml index bd9b6b1ad..f8c921e1f 100644 --- a/.github/workflows/check_build.yml +++ b/.github/workflows/check_build.yml @@ -18,7 +18,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - node: [ '14', '18', '20' ] + node: [ '18', '20', '22', '24' ] name: Node ${{ matrix.node }} sample steps: - name: checkout cli diff --git a/src/infrastructure/file-service.ts b/src/infrastructure/file-service.ts index 2335f6335..6e0de34d2 100644 --- a/src/infrastructure/file-service.ts +++ b/src/infrastructure/file-service.ts @@ -1,5 +1,6 @@ import fs from "fs"; import fsExtra from "fs-extra"; +import os from "os"; import * as path from "path"; import { FilePath } from "../types/file/filePath.js"; import { DirectoryPath } from "../types/file/directoryPath.js"; From 75232ea51a6caeab61dc7d888ec791c72ff9c4b0 Mon Sep 17 00:00:00 2001 From: Ali Asghar <75574550+aliasghar98@users.noreply.github.com> Date: Tue, 12 Aug 2025 12:58:49 +0500 Subject: [PATCH 04/77] fix: run build action on pull requests to dev branch (#159) - Added dev branch to check build github action. - Removed on push event check for build github action trigger. --- .github/workflows/check_build.yml | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/.github/workflows/check_build.yml b/.github/workflows/check_build.yml index f8c921e1f..64fb5cb32 100644 --- a/.github/workflows/check_build.yml +++ b/.github/workflows/check_build.yml @@ -4,11 +4,9 @@ name: Run and check build for current commit # Controls when the workflow will run on: - # Triggers the workflow on push or pull request events but only for the main branch - push: - branches: [ alpha ] + # Triggers the workflow on pull request events but only for the alpha and dev branches pull_request: - branches: [ alpha ] + branches: [ alpha, dev ] # Allows you to run this workflow manually from the Actions tab workflow_dispatch: From f9f55347af5aa9c85fb44cb6a3cf1ed77ec16e36 Mon Sep 17 00:00:00 2001 From: Mujahid Daud Khan <37825767+Shield-Jaguar@users.noreply.github.com> Date: Tue, 12 Aug 2025 18:27:29 +0500 Subject: [PATCH 05/77] feat: update copilot messages (#154) --- src/actions/portal/copilot.ts | 62 ++++++++++++++++++------ src/actions/portal/generate.ts | 4 +- src/commands/portal/copilot.ts | 5 +- src/infrastructure/file-service.ts | 34 +------------- src/infrastructure/launcher-service.ts | 50 ++++++++++++++++++++ src/prompts/portal/copilot.ts | 65 ++++++++++++++++++-------- src/types/file/fileName.ts | 4 +- 7 files changed, 154 insertions(+), 70 deletions(-) create mode 100644 src/infrastructure/launcher-service.ts diff --git a/src/actions/portal/copilot.ts b/src/actions/portal/copilot.ts index cb126ff50..4e8fc8109 100644 --- a/src/actions/portal/copilot.ts +++ b/src/actions/portal/copilot.ts @@ -4,9 +4,16 @@ import { DirectoryPath } from "../../types/file/directoryPath.js"; import { ActionResult } from "../action-result.js"; import { SubscriptionInfo } from "../../types/api/account.js"; import { BuildContext } from "../../types/build-context.js"; +import { withDirPath } from "../../infrastructure/tmp-extensions.js"; +import { FilePath } from "../../types/file/filePath.js"; +import { FileName } from "../../types/file/fileName.js"; +import { FileService } from "../../infrastructure/file-service.js"; +import { LauncherService } from "../../infrastructure/launcher-service.js"; export class CopilotAction { private readonly apiService = new ApiService(); + private readonly fileService = new FileService(); + private readonly launcherService = new LauncherService(); private readonly prompts = new PortalCopilotPrompts(); private readonly configDir: DirectoryPath; private readonly authKey: string | null; @@ -16,7 +23,7 @@ export class CopilotAction { this.authKey = authKey; } - public async execute(buildDirectory: DirectoryPath, enable: boolean): Promise { + public async execute(buildDirectory: DirectoryPath, force: boolean, enable: boolean): Promise { const buildContext = new BuildContext(buildDirectory); if (!(await buildContext.validate())) { @@ -25,21 +32,22 @@ export class CopilotAction { const buildJson = await buildContext.getBuildFileContents(); - if (buildJson.apiCopilotConfig != null && !(await this.prompts.confirmOverwrite())) + if (!force && buildJson.apiCopilotConfig != null && !(await this.prompts.confirmOverwrite())) return ActionResult.error("Exiting without making any change."); - const response = await this.apiService.getAccountInfo(this.configDir, this.authKey); + const response = await this.prompts.spinnerAccountInfo( + () => this.apiService.getAccountInfo(this.configDir, this.authKey)); + if (response.isErr()) { return ActionResult.error(response._unsafeUnwrapErr()); } - const apiCopilotKey = await this.selectCopilotKey(response._unsafeUnwrap()); - if (apiCopilotKey === null) { - return ActionResult.error("No copilot key found for the current subscription. Please contact support at support@apimatic.io."); + + const apiCopilotKey = await this.selectCopilotKey(response._unsafeUnwrap(), force); + if (apiCopilotKey instanceof Error) { + return ActionResult.error(apiCopilotKey.message); } - const welcomeMessage = await this.prompts.getWelcomeMessage(); - if (welcomeMessage === undefined) - return ActionResult.error("Exiting without making any change."); + const welcomeMessage = await this.getWelcomeMessage(); buildJson.apiCopilotConfig = { isEnabled: enable, @@ -49,19 +57,47 @@ export class CopilotAction { await buildContext.updateBuildFileContents(buildJson); - this.prompts.copilotConfigured(buildJson.apiCopilotConfig); + this.prompts.copilotConfigured(enable, apiCopilotKey); + + return ActionResult.success(); } - private async selectCopilotKey(subscription: SubscriptionInfo | undefined): Promise { + private async selectCopilotKey(subscription: SubscriptionInfo | undefined, force: boolean): Promise { if ( subscription === undefined || subscription.ApiCopilotKeys === undefined || subscription.ApiCopilotKeys.length === 0 ) { - return null; + return new Error("No copilot key found for the current subscription. Please contact support at support@apimatic.io."); } - return await this.prompts.selectCopilotKey(subscription.ApiCopilotKeys); + if (subscription.ApiCopilotKeys.length === 1) { + if (force || (await this.prompts.confirmSingleKeyUsage(subscription.ApiCopilotKeys[0]))) + return subscription.ApiCopilotKeys[0]; + return new Error("Operation cancelled. No API Copilot key was selected."); + } + + const key = await this.prompts.selectCopilotKey(subscription.ApiCopilotKeys); + if (key === null) return new Error("Operation cancelled. No API Copilot key was selected."); + await this.prompts.displayApiCopilotKeyUsageWarning(); + + return key; + } + + private async getWelcomeMessage(): Promise { + return await withDirPath(async (tempDir) => { + const tempFile = new FilePath(tempDir, new FileName("welcome-message.md")); + const defaultContent = "Hi there! I'm your API Integration Assistant, here to help you learn and integrate with this API.\n" + + "\n" + + "Ask me anything about this API or try one of these example prompts:\n" + + "\n" + + "`- What authentication methods does this API support?`\n" + + "`- [Enter another prompt here]`"; + await this.fileService.writeContents(tempFile, defaultContent); + await this.launcherService.openInEditor(tempFile); + const welcomeMessage = await this.fileService.getContents(tempFile); + return welcomeMessage.replace(/\r\n|\r/g, "\n"); + }); } } diff --git a/src/actions/portal/generate.ts b/src/actions/portal/generate.ts index 112b5c414..60f39e343 100644 --- a/src/actions/portal/generate.ts +++ b/src/actions/portal/generate.ts @@ -9,11 +9,13 @@ import { ActionResult } from "../action-result.js"; import { BuildContext } from "../../types/build-context.js"; import { PortalContext } from "../../types/portal-context.js"; import { withDirPath } from "../../infrastructure/tmp-extensions.js"; +import { LauncherService } from "../../infrastructure/launcher-service.js"; export class GenerateAction { private readonly prompts: PortalGeneratePrompts = new PortalGeneratePrompts(); private readonly zipArchiver: ZipService = new ZipService(); private readonly fileService: FileService = new FileService(); + private readonly launcherService: LauncherService = new LauncherService(); private readonly portalService: PortalService = new PortalService(); private readonly configDir: DirectoryPath; private readonly authKey: string | null; @@ -86,7 +88,7 @@ export class GenerateAction { const errorReportPath = portalDirectory.join("apimatic-debug"); const htmlFilePath = new FilePath(errorReportPath, new FileName("apimatic-report.html")); - await this.fileService.openFile(htmlFilePath); // Open the error report in the default browser + await this.launcherService.openFile(htmlFilePath); // Open the error report in the default browser return ( "An error occurred during portal generation due to an issue with the input. " + diff --git a/src/commands/portal/copilot.ts b/src/commands/portal/copilot.ts index 5083b35c7..e907d12f0 100644 --- a/src/commands/portal/copilot.ts +++ b/src/commands/portal/copilot.ts @@ -18,6 +18,7 @@ export default class PortalCopilotEnable extends Command { default: false, description: "marks the API Copilot as disabled in the configuration" }), + ...FlagsProvider.force, ...FlagsProvider.authKey }; @@ -30,13 +31,13 @@ export default class PortalCopilotEnable extends Command { async run(): Promise { const { - flags: { input, "auth-key": authKey, disable} + flags: { input, "auth-key": authKey, disable, force} } = await this.parse(PortalCopilotEnable); const workingDirectory = new DirectoryPath(input ?? DEFAULT_WORKING_DIRECTORY); const buildDirectory = input ? new DirectoryPath(input, "src") : workingDirectory.join("src"); const copilotConfigAction = new CopilotAction(new DirectoryPath(this.config.configDir), authKey); - const result = await copilotConfigAction.execute(buildDirectory, !disable); + const result = await copilotConfigAction.execute(buildDirectory, force, !disable); result.map((message) => this.prompts.logError(message)); } } diff --git a/src/infrastructure/file-service.ts b/src/infrastructure/file-service.ts index 6e0de34d2..a32beb545 100644 --- a/src/infrastructure/file-service.ts +++ b/src/infrastructure/file-service.ts @@ -1,12 +1,10 @@ import fs from "fs"; import fsExtra from "fs-extra"; -import os from "os"; import * as path from "path"; import { FilePath } from "../types/file/filePath.js"; import { DirectoryPath } from "../types/file/directoryPath.js"; import { pipeline } from "stream"; import { promisify } from "util"; -import { spawn } from "child_process"; export class FileService { public async fileExists(file: FilePath): Promise { @@ -77,7 +75,7 @@ export class FileService { } public async writeContents(filePath: FilePath, contents: string) { - await fsExtra.writeFile(filePath.toString(), contents, 'utf-8'); + await fsExtra.writeFile(filePath.toString(), contents, "utf-8"); } public async writeBuffer(filePath: FilePath, buffer: Buffer) { @@ -87,36 +85,6 @@ export class FileService { public async copy(source: FilePath, destination: FilePath) { await fsExtra.copyFile(source.toString(), destination.toString()); } - - public async openFile(filePath: FilePath): Promise { - const targetPath = filePath.toString(); - - // Determine the command and args without using the shell - let command: string; - let args: string[]; - - switch (os.platform()) { - case "win32": - command = "cmd"; - args = ["/c", "start", "", targetPath]; - break; - case "darwin": - command = "open"; - args = [targetPath]; - break; - default: - command = "xdg-open"; - args = [targetPath]; - break; - } - - try { - const child = spawn(command, args, { stdio: "ignore", detached: true }); - child.unref(); // Let it run without blocking - } catch { - // Silently ignore errors - } - } } const streamPipeline = promisify(pipeline); diff --git a/src/infrastructure/launcher-service.ts b/src/infrastructure/launcher-service.ts new file mode 100644 index 000000000..5570f8be6 --- /dev/null +++ b/src/infrastructure/launcher-service.ts @@ -0,0 +1,50 @@ +import { FilePath } from "../types/file/filePath.js"; +import { execa } from "execa"; +import os from "os"; +import { spawn } from "child_process"; + +export class LauncherService { + + public async openInEditor(filePath: FilePath): Promise { + try { + await execa("code", ["--wait", filePath.toString()]); + } catch { + // TODO: check for fallback (start) + if (process.platform === "win32") { + await execa("cmd", ["/c", "start", "/wait", "notepad", filePath.toString()], { stdio: "ignore" }); + } else if (process.platform === "darwin") { + await execa("vim", [filePath.toString()], { stdio: "inherit"}); + } + } + } + + public async openFile(filePath: FilePath): Promise { + const targetPath = filePath.toString(); + + // Determine the command and args without using the shell + let command: string; + let args: string[]; + + switch (os.platform()) { + case "win32": + command = "cmd"; + args = ["/c", "start", "", targetPath]; + break; + case "darwin": + command = "open"; + args = [targetPath]; + break; + default: + command = "xdg-open"; + args = [targetPath]; + break; + } + + try { + const child = spawn(command, args, { stdio: "ignore", detached: true }); + child.unref(); // Let it run without blocking + } catch { + // Silently ignore errors + } + } +} diff --git a/src/prompts/portal/copilot.ts b/src/prompts/portal/copilot.ts index e4e5d4f32..f303c3e83 100644 --- a/src/prompts/portal/copilot.ts +++ b/src/prompts/portal/copilot.ts @@ -1,9 +1,14 @@ -import { select, cancel, isCancel, outro, confirm, log, text, } from "@clack/prompts"; -import { CopilotConfig } from "../../types/build/build.js"; +import { confirm, isCancel, log, outro, select, spinner } from "@clack/prompts"; import { getMessageInCyanColor } from "../../utils/utils.js"; +import { Result } from "neverthrow"; +import { SubscriptionInfo } from "../../types/api/account.js"; +import { ServiceError } from "../../infrastructure/api-utils.js"; export class PortalCopilotPrompts { - public async selectCopilotKey(keys: string[]): Promise { + public async displayApiCopilotKeyUsageWarning() { + log.warn('API Copilot can only be active on one Portal at a time. Configuring it on this Portal will disable it on any previously configured Portal.') + } + public async selectCopilotKey(keys: string[]): Promise { const selectedKey = await select({ message: "Select the ID for the API Copilot you would like to add to this API Portal:", maxItems: 10, @@ -14,30 +19,30 @@ export class PortalCopilotPrompts { }); if (isCancel(selectedKey)) { - cancel("Operation cancelled."); - return process.exit(0); + return null; } return selectedKey; } - public copilotConfigured(apiCopilotConfig: CopilotConfig) { + public copilotConfigured(status: boolean, copilotId: string): void { outro( `API Copilot configured successfully! - Copilot ID: ${getMessageInCyanColor(apiCopilotConfig.key)} - Welcome Message: ${getMessageInCyanColor(apiCopilotConfig.welcomeMessage)} - Status: ${getMessageInCyanColor(apiCopilotConfig.isEnabled ? "Enabled" : "Disabled")} + Copilot ID: ${getMessageInCyanColor(copilotId)} + Status: ${getMessageInCyanColor(status ? "Enabled" : "Disabled")} Configuration saved to: APIMATIC-BUILD.json -Run 'apimatic portal:serve' to preview your API Portal and try out the API Copilot.` +API Copilot will index your content the next time you run \`apimatic portal:generate\` or \`apimatic portal:serve\`. This process can take up to 10 minutes, depending on your API’s size. + +To see your copilot: If your portal is already running, refresh the page. Otherwise, run \`apimatic portal:serve\`, select any programming language in the Portal and look for the chat icon in the bottom-right corner.` ); } public async confirmOverwrite(): Promise { const shouldOverwrite = await confirm({ - message: "API Copilot configuration already exists. Do you want to overwrite?", + message: "API Copilot is already configured for this Portal, do you want to overwrite it?", initialValue: false }); @@ -48,20 +53,42 @@ Run 'apimatic portal:serve' to preview your API Portal and try out the API Copil return shouldOverwrite; } - logError(error: string): void { + public logError(error: string): void { log.error(error); } - async getWelcomeMessage(): Promise { - const welcomeMessage = await text({ - message: "Enter a welcome message for your API Copilot:", - placeholder: "Enter your welcome message here..." + public async spinnerAccountInfo(fn: () => Promise>) { + return this.withSpinner( + "Retrieving your subscription info", + "Retrieved subscription info", + "Error retrieving subscription info", + fn + ); + } + + private async withSpinner(intro: string, success: string, failure: string, fn: () => Promise>) { + const s = spinner(); + s.start(intro); + const result = await fn(); + result.match( + () => s.stop(success, 0), + () => s.stop(failure, 1) + ); + return result; + } + + public async confirmSingleKeyUsage(apiCopilotKey: string) { + const confirmKeyUsage = await confirm({ + message: + "API Copilot can only be active on one Portal at a time. Configuring it on this Portal will disable it on any previously configured Portal.\n" + + `Do you want to use this key: '${apiCopilotKey}'?`, + initialValue: true }); - if (isCancel(welcomeMessage)) { - return undefined; + if (isCancel(confirmKeyUsage)) { + return false; } - return welcomeMessage; + return confirmKeyUsage; } } diff --git a/src/types/file/fileName.ts b/src/types/file/fileName.ts index 6f86d4577..53b291291 100644 --- a/src/types/file/fileName.ts +++ b/src/types/file/fileName.ts @@ -1,8 +1,8 @@ export class FileName { private readonly name: string; - constructor(path: string) { - this.name = path; + constructor(name: string) { + this.name = name; } public toString(): string { From 6d77cbaa0465607533927ed1dbe0b0eee7b67f01 Mon Sep 17 00:00:00 2001 From: Shield-Jaguar Date: Fri, 15 Aug 2025 17:46:33 +0500 Subject: [PATCH 06/77] feat: refactor prompting service --- src/actions/action-result.ts | 43 +++++++--- src/actions/portal/generate.ts | 85 +++++++------------ src/actions/portal/serve.ts | 1 + src/commands/portal/generate.ts | 41 ++++----- src/commands/sdk/generate.ts | 3 +- src/infrastructure/services/portal-service.ts | 31 +++---- src/prompts/format.ts | 44 ++++++++++ src/prompts/portal/generate.ts | 47 +++++----- src/types/build-context.ts | 2 +- src/types/portal-context.ts | 14 ++- src/types/temp-context.ts | 31 +++++++ 11 files changed, 215 insertions(+), 127 deletions(-) create mode 100644 src/prompts/format.ts create mode 100644 src/types/temp-context.ts diff --git a/src/actions/action-result.ts b/src/actions/action-result.ts index c3a567cb7..a9ccd4079 100644 --- a/src/actions/action-result.ts +++ b/src/actions/action-result.ts @@ -1,29 +1,50 @@ +enum ResultType { + Success, + Warning, + Error +} + export class ActionResult { private readonly message: string | undefined; + private readonly resultType: ResultType; - private constructor(message?: string) { + private constructor(resultType: ResultType, message?: string) { + this.resultType = resultType; this.message = message; } static error(message: string) { - return new ActionResult(message); + return new ActionResult(ResultType.Error, message); + } + + static error2() { + return new ActionResult(ResultType.Error); + } + + static warning(message: string) { + return new ActionResult(ResultType.Warning, message); } static success() { - return new ActionResult(); + return new ActionResult(ResultType.Success); } - map(onError: (message: string) => void) { - if (this.message) { - onError(this.message); + map(onError: (message: string) => void, onWarning?: (message: string) => void) { + if (this.resultType === ResultType.Error) { + onError(this.message!); + } else if (this.resultType === ResultType.Warning && onWarning && this.message) { + onWarning(this.message); } } - mapAll(onSuccess:() => T, onError: (message: string) => T) { - if (this.message) { - return onError(this.message); - }else { - return onSuccess(); + mapAll(onSuccess: () => T, onWarning: (message: string) => T, onError: (message: string) => T) { + switch (this.resultType) { + case ResultType.Error: + return onError(this.message!); + case ResultType.Warning: + return onWarning(this.message!); + default: + return onSuccess(); } } } diff --git a/src/actions/portal/generate.ts b/src/actions/portal/generate.ts index 60f39e343..8517d3c2a 100644 --- a/src/actions/portal/generate.ts +++ b/src/actions/portal/generate.ts @@ -1,20 +1,15 @@ import { PortalGeneratePrompts } from "../../prompts/portal/generate.js"; -import { ZipService } from "../../infrastructure/zip-service.js"; -import { FileService } from "../../infrastructure/file-service.js"; import { PortalService } from "../../infrastructure/services/portal-service.js"; import { DirectoryPath } from "../../types/file/directoryPath.js"; -import { FilePath } from "../../types/file/filePath.js"; -import { FileName } from "../../types/file/fileName.js"; import { ActionResult } from "../action-result.js"; import { BuildContext } from "../../types/build-context.js"; import { PortalContext } from "../../types/portal-context.js"; import { withDirPath } from "../../infrastructure/tmp-extensions.js"; import { LauncherService } from "../../infrastructure/launcher-service.js"; +import { TempContext } from "../../types/temp-context.js"; export class GenerateAction { private readonly prompts: PortalGeneratePrompts = new PortalGeneratePrompts(); - private readonly zipArchiver: ZipService = new ZipService(); - private readonly fileService: FileService = new FileService(); private readonly launcherService: LauncherService = new LauncherService(); private readonly portalService: PortalService = new PortalService(); private readonly configDir: DirectoryPath; @@ -32,68 +27,48 @@ export class GenerateAction { zipPortal: boolean ): Promise => { if (buildDirectory.isEqual(portalDirectory)) { - return ActionResult.error(`The 'src' and 'portal' directory cannot be the same: "${portalDirectory}"`); + this.prompts.directoryCannotBeSame(portalDirectory); + return ActionResult.error2(); } const buildContext = new BuildContext(buildDirectory); if (!(await buildContext.validate())) { - return ActionResult.error(`The 'src' directory is either empty or invalid: "${buildDirectory}"`); + this.prompts.srcDirectoryEmpty(buildDirectory); + return ActionResult.error2(); } const portalContext = new PortalContext(portalDirectory); if (!force && (await portalContext.exists()) && !(await this.prompts.overwritePortal(portalDirectory))) { - return ActionResult.error( - "Please enter a different destination folder or remove the existing files and try again." - ); + this.prompts.portalDirectoryNotEmpty(); + return ActionResult.error2(); } return await withDirPath(async (tempDirectory) => { - this.prompts.displayPortalGenerationMessage(); - - const buildZipPath = new FilePath(tempDirectory, new FileName("build.zip")); - await this.zipArchiver.archive(buildDirectory, buildZipPath); - - const response = await this.portalService.generatePortal(buildZipPath, this.configDir, this.authKey); - - if (!response.isSuccess()) { - this.prompts.displayPortalGenerationErrorMessage(); - return ActionResult.error(await this.parseError(response.error!, portalDirectory, tempDirectory)); - } + const tempContext = new TempContext(tempDirectory); + const buildZipPath = await tempContext.zip(buildDirectory); - const tempPortalFilePath = new FilePath(tempDirectory, new FileName("portal.zip")); - await this.fileService.writeFile(tempPortalFilePath, response.value); - - await portalContext.save(tempPortalFilePath, zipPortal); - this.prompts.displayPortalGenerationSuccessMessage(); + const response = await this.prompts.generatePortal( + this.portalService.generatePortal(buildZipPath, this.configDir, this.authKey) + ); - return ActionResult.success(); + return response.match( + async (portalStream) => { + const tempPortalZipPath = await tempContext.save(portalStream); + await portalContext.save(tempPortalZipPath, zipPortal); + return ActionResult.success(); + }, + async (error) => { + if (typeof error === "string") { + this.prompts.portalGenerationError(error); + } else { + const errorZipPath = await tempContext.save(error); + const reportPath = await portalContext.saveError(errorZipPath); + await this.launcherService.openFile(reportPath); + this.prompts.portalGenerationErrorWithReport(reportPath); + } + return ActionResult.error2(); + } + ); }); }; - - private async parseError( - error: string | NodeJS.ReadableStream, - portalDirectory: DirectoryPath, - tempDirectory: DirectoryPath - ): Promise { - if (typeof error === "string") { - return error; - } - - const tempErrorFilePath = new FilePath(tempDirectory, new FileName("error.zip")); - await this.fileService.writeFile(tempErrorFilePath, error); - - await this.fileService.cleanDirectory(portalDirectory); - await this.zipArchiver.unArchive(tempErrorFilePath, portalDirectory); - - const errorReportPath = portalDirectory.join("apimatic-debug"); - - const htmlFilePath = new FilePath(errorReportPath, new FileName("apimatic-report.html")); - await this.launcherService.openFile(htmlFilePath); // Open the error report in the default browser - - return ( - "An error occurred during portal generation due to an issue with the input. " + - "An error report has been written at the destination path: " + - errorReportPath - ); - } } diff --git a/src/actions/portal/serve.ts b/src/actions/portal/serve.ts index 2e0ef2d15..f46c81b22 100644 --- a/src/actions/portal/serve.ts +++ b/src/actions/portal/serve.ts @@ -48,6 +48,7 @@ export class PortalServeAction { return Result.success(`Portal was successfully served.`); }, + async (message) => Result.failure(message), async (message) => Result.failure(message) ); } diff --git a/src/commands/portal/generate.ts b/src/commands/portal/generate.ts index 0a8137075..8418d3416 100644 --- a/src/commands/portal/generate.ts +++ b/src/commands/portal/generate.ts @@ -1,53 +1,56 @@ -import { Command, Config, Flags } from "@oclif/core"; +import { Command, Flags } from "@oclif/core"; import { DirectoryPath } from "../../types/file/directoryPath.js"; import { GenerateAction } from "../../actions/portal/generate.js"; -import { PortalGeneratePrompts } from "../../prompts/portal/generate.js"; import { FlagsProvider } from "../../types/flags-provider.js"; +import { intro, outro } from "../../prompts/format.js"; const DEFAULT_WORKING_DIRECTORY = "./"; export class PortalGenerate extends Command { + + static summary = "Generate an API Documentation portal"; + static description = "Generate an API Documentation portal. Requires an input directory containing API specifications, a config file and optionally, markdown guides. For details, refer to the [documentation](https://docs.apimatic.io/platform-api/#/http/guides/generating-on-prem-api-portal/build-file-reference)"; + static examples = [`apimatic portal:generate`, `apimatic portal:generate --input="./" --destination="./portal"`]; + static flags = { ...FlagsProvider.input, ...FlagsProvider.destination("portal", "portal"), ...FlagsProvider.force, zip: Flags.boolean({ default: false, - description: "download the generated portal as a .zip archive" + description: "Download the generated portal as a .zip archive" }), ...FlagsProvider.authKey }; - static examples = [`apimatic portal:generate`, `apimatic portal:generate --input="./" --destination="./portal"`]; - - private readonly prompts: PortalGeneratePrompts; - - constructor(argv: string[], config: Config) { - super(argv, config); - this.prompts = new PortalGeneratePrompts(); - } - async run(): Promise { const { - flags: { input, destination, force, zip: zipPortal, "auth-key": authKey } + flags: { + input, + destination, + force, + zip: zipPortal, + "auth-key": authKey + } } = await this.parse(PortalGenerate); const workingDirectory = new DirectoryPath(input ?? DEFAULT_WORKING_DIRECTORY); const buildDirectory = input ? new DirectoryPath(input, "src") : workingDirectory.join("src"); const portalDirectory = destination ? new DirectoryPath(destination) : workingDirectory.join("portal"); + intro('Generate Portal'); const action = new GenerateAction(this.getConfigDir(), authKey); const result = await action.execute(buildDirectory, portalDirectory, force, zipPortal); - result.mapAll( - () => this.prompts.displayOutroMessage(portalDirectory.toString()), - (message) => this.prompts.logError(message) - ); + const exitCode = result.mapAll(() => 0, () => 1, ()=> 2); + //outro(exitCode); + this.error("some error" + exitCode); } - private getConfigDir = () => { + + private getConfigDir(): DirectoryPath { return new DirectoryPath(this.config.configDir); - }; + } } diff --git a/src/commands/sdk/generate.ts b/src/commands/sdk/generate.ts index c3392c840..539edb4d1 100644 --- a/src/commands/sdk/generate.ts +++ b/src/commands/sdk/generate.ts @@ -48,10 +48,11 @@ export default class SdkGenerate extends Command { const sdkDirectory = destination ? new DirectoryPath(destination) : workingDirectory.join("sdk").join(platform); - var action = new GenerateAction(this.getConfigDir(), authKey); + const action = new GenerateAction(this.getConfigDir(), authKey); const result = await action.execute(specDirectory, sdkDirectory, platform as LanguagePlatform, force, zipSdk); result.mapAll( () => this.prompts.displayOutroMessage(sdkDirectory), + (message) => this.prompts.logError(message), (message) => this.prompts.logError(message) ); } diff --git a/src/infrastructure/services/portal-service.ts b/src/infrastructure/services/portal-service.ts index 605edc5b8..c575a19a7 100644 --- a/src/infrastructure/services/portal-service.ts +++ b/src/infrastructure/services/portal-service.ts @@ -26,18 +26,19 @@ import { DirectoryPath } from "../../types/file/directoryPath.js"; import { FileService } from "../file-service.js"; import { envInfo } from "../env-info.js"; import { ReadStream } from "fs"; +import { err, ok, Result as ResultEx } from "neverthrow"; export class PortalService { private readonly CONTENT_TYPE = ContentType.EnumMultipartformdata; private readonly TIMEOUT = 0; private readonly fileService = new FileService(); - //TODO: Pass stream as parameter instead of file path. - async generatePortal( + // TODO: Pass stream as parameter instead of file path. + public async generatePortal( buildPath: FilePath, configDir: DirectoryPath, authKey: string | null - ): Promise> { + ): Promise> { const buildFileStream = await this.fileService.getStream(buildPath); const file = new FileWrapper(buildFileStream); const authInfo: AuthInfo | null = await getAuthInfo(configDir.toString()); @@ -47,15 +48,15 @@ export class PortalService { try { const response = await docsPortalManagementController.generateOnPremPortalViaBuildInput(this.CONTENT_TYPE, file); - return Result.success(response.result as NodeJS.ReadableStream); + return ok(response.result as NodeJS.ReadableStream); } catch (error) { - return Result.failure(await this.handlePortalGenerationErrors(error)); + return err(await this.handlePortalGenerationErrors(error)); } finally { buildFileStream.close(); } } - //TODO: Pass stream as parameter instead of file path. + // TODO: Pass stream as parameter instead of file path. async generateSdk( specPath: FilePath, sdkPlatform: Platforms, @@ -133,12 +134,12 @@ export class PortalService { if (error instanceof UnauthorizedResponseError) { //401 const unAuthError = error as UnauthorizedResponseError; - return getMessageInRedColor(unAuthError.result?.message ?? "Authorization has been denied for this request."); + return unAuthError.result?.message ?? "Authorization has been denied for this request."; } else if (error instanceof ProblemDetailsError) { //400 & 403 const probDetailsError = error as ProblemDetailsError; const message = (probDetailsError.result!.errors as Record)?.[""]?.[0]; - return getMessageInRedColor(probDetailsError.result!.title + "\n- " + message); + return probDetailsError.result!.title + "\n- " + message; } else if (error instanceof ApiError && error.statusCode === 422) { //422 return error.body as NodeJS.ReadableStream; @@ -146,15 +147,11 @@ export class PortalService { //500 const internalServerError = error as InternalServerErrorResponseError; const message = internalServerError.result?.message; - return getMessageInRedColor( - `${ - message ?? "An unkown error occurred." - } Please try again later or reach out to our team at support@apimatic.io for help if your problem persists.` - ); + return `${ + message ?? "An unknown error occurred." + } Please try again later or reach out to our team at support@apimatic.io for help if your problem persists.`; } else { - return getMessageInRedColor( - "An unexpected error occurred while generating the portal, please try again later. If the problem persists, please reach out to our team at support@apimatic.io" - ); + return "An unexpected error occurred while generating the portal, please try again later. If the problem persists, please reach out to our team at support@apimatic.io"; } }; @@ -171,7 +168,7 @@ export class PortalService { // Get the first error and clean it up const firstError = parsedResult.Errors[0]; // Split on
and take first part, then strip remaining HTML tags - const cleanError = firstError.split("
")[0].replace(/<[^<>]*?>/g, ""); + const cleanError = firstError.split("
")[0].replace(/<[^<>]*?>/g, ""); return cleanError; } else if (parsedResult.Success === false) { return "API definition file validation failed."; diff --git a/src/prompts/format.ts b/src/prompts/format.ts new file mode 100644 index 000000000..4190ead85 --- /dev/null +++ b/src/prompts/format.ts @@ -0,0 +1,44 @@ +import pc from "picocolors"; +import { Result } from "neverthrow"; +import { intro as i, outro as o, spinner } from '@clack/prompts'; + + +export const format = { + // Core element types + var: (text: string) => pc.magenta(`'${text}'`), + path: (text: string) => pc.cyan(`'${text}'`), + + // Common message styles + success: (text: string) => pc.green(text), + warning: (text: string) => pc.yellow(text), + error: (text: string) => pc.red(text), + info: (text: string) => pc.cyan(text), + + intro: (text: string) => pc.bgCyan(text), + outroSuccess: (text: string) => pc.bgGreen(text), + outroFailure: (text: string) => pc.bgRed(text), + outroWarning: (text: string) => pc.bgYellow(text) +}; + +export function intro(text: string) { + i(format.intro(` ${text} `)); +} + +export function outro(exitCode: number) { + const message = + exitCode === 0 ? format.outroSuccess(' success ') : exitCode === 2 ? format.outroFailure(' failure ') : format.outroWarning(' warning '); + o(message); + process.exitCode = exitCode; +} + + +export async function withSpinner(intro: string, success: string, failure: string, fn: Promise>) { + const s = spinner(); + s.start(intro); + const result = await fn; + result.match( + () => s.stop(success, 0), + () => s.stop(failure, 1) + ); + return result; +} diff --git a/src/prompts/portal/generate.ts b/src/prompts/portal/generate.ts index d7823c3c3..9876714fc 100644 --- a/src/prompts/portal/generate.ts +++ b/src/prompts/portal/generate.ts @@ -1,13 +1,13 @@ -import { outro, spinner, isCancel, confirm, log } from "@clack/prompts"; -import { getMessageInRedColor, getMessageInMagentaColor, getMessageInCyanColor } from "../../utils/utils.js"; +import { isCancel, confirm, log } from "@clack/prompts"; import { DirectoryPath } from "../../types/file/directoryPath.js"; +import { format as f, withSpinner } from "../format.js"; +import { Result } from "neverthrow"; +import { FilePath } from "../../types/file/filePath.js"; export class PortalGeneratePrompts { - private readonly spin = spinner(); - public async overwritePortal(directory: DirectoryPath): Promise { const overwrite = await confirm({ - message: `The destination '${directory}' is not empty, do you want to overwrite?`, + message: `The destination ${f.path(directory.toString())} is not empty, do you want to overwrite?`, initialValue: false }); @@ -18,33 +18,36 @@ export class PortalGeneratePrompts { return overwrite; } - displayPortalGenerationMessage(): void { - this.spin.start(getMessageInMagentaColor("Generating portal")); + public directoryCannotBeSame(directory: DirectoryPath) { + const message = `The ${f.var("src")} and ${f.var("portal")} directories must be different. Current value: ${f.path( + directory.toString() + )}`; + log.error(message); } - displayPortalGenerationSuccessMessage(): void { - this.spin.stop(getMessageInCyanColor("Portal generated successfully.")); - this.cleanUpStandardInput(); + public srcDirectoryEmpty(directory: DirectoryPath) { + const message = `The ${f.var("src")} directory is either empty or invalid: ${f.path(directory.toString())}`; + log.error(message); } - displayPortalGenerationErrorMessage(): void { - this.spin.stop(getMessageInRedColor(`Portal Generation failed.`), 1); - this.cleanUpStandardInput(); + public portalDirectoryNotEmpty() { + const message = `Please enter a different destination folder or remove the existing files and try again.`; + log.error(message); } - displayOutroMessage(generatedPortalPath: string): void { - outro(`The generated portal can be found at ${generatedPortalPath}`); + public generatePortal(fn: Promise>) { + return withSpinner("Generating portal", "Portal generated successfully.", "Portal Generation failed.", fn); } - logError(error: string): void { + public portalGenerationError(error: string) { log.error(error); } - //This clears the standard input to allow interrupts like CTRL+C to work properly. - private cleanUpStandardInput(): void { - if (process.stdin.isTTY) { - process.stdin.setRawMode(false); - process.stdin.pause(); - } + portalGenerationErrorWithReport(reportPath: FilePath) { + const message = `An error occurred during portal generation due to an issue with the input. +An error report has been written at the destination path: ${f.path(reportPath.toString())}`; + log.error(message); } + + } diff --git a/src/types/build-context.ts b/src/types/build-context.ts index 5746cdbcb..0a79d266f 100644 --- a/src/types/build-context.ts +++ b/src/types/build-context.ts @@ -12,7 +12,7 @@ export class BuildContext { this.buildDirectory = buildDirectory; } - public get BuildFile(): FilePath { + private get BuildFile(): FilePath { // TODO: add checks for build file path return new FilePath(this.buildDirectory, new FileName("APIMATIC-BUILD.json")); } diff --git a/src/types/portal-context.ts b/src/types/portal-context.ts index 5fe65d3f7..4744773ad 100644 --- a/src/types/portal-context.ts +++ b/src/types/portal-context.ts @@ -12,11 +12,17 @@ export class PortalContext { constructor(private readonly portalDirectory: DirectoryPath) { } - public get ZipPath(): FilePath { + private get ZipPath(): FilePath { // TODO: add checks for build file path return new FilePath(this.portalDirectory, new FileName("portal.zip")); } + private get reportPath(): FilePath { + // TODO: add checks for build file path + const debugPath = this.portalDirectory.join('apimatic-debug'); + return new FilePath(debugPath, new FileName("apimatic-report.html")) + } + public async exists() { return !await this.fileService.directoryEmpty(this.portalDirectory); } @@ -29,4 +35,10 @@ export class PortalContext { await this.zipService.unArchive(tempPortalFilePath, this.portalDirectory); } } + + public async saveError(tempErrorFilePath: FilePath) { + await this.fileService.cleanDirectory(this.portalDirectory); + await this.zipService.unArchive(tempErrorFilePath, this.portalDirectory); + return this.reportPath; + } } diff --git a/src/types/temp-context.ts b/src/types/temp-context.ts new file mode 100644 index 000000000..5d1aa889e --- /dev/null +++ b/src/types/temp-context.ts @@ -0,0 +1,31 @@ +import { FileService } from "../infrastructure/file-service.js"; +import { ZipService } from "../infrastructure/zip-service.js"; +import { DirectoryPath } from "./file/directoryPath.js"; +import { FilePath } from "./file/filePath.js"; +import { FileName } from "./file/fileName.js"; + +export class TempContext { + private readonly fileService = new FileService(); + private readonly zipService = new ZipService(); + + constructor(private readonly tempDirectory: DirectoryPath) { + } + + private get buildZipPath(): FilePath { + return new FilePath(this.tempDirectory, new FileName(`build.zip`)); + } + + private get portalZipPath(): FilePath { + return new FilePath(this.tempDirectory, new FileName(`portal.zip`)); + } + + async zip(buildDirectory: DirectoryPath) { + await this.zipService.archive(buildDirectory, this.buildZipPath); + return this.buildZipPath; + } + + async save(portalStream: NodeJS.ReadableStream) { + await this.fileService.writeFile(this.portalZipPath, portalStream); + return this.portalZipPath; + } +} From e0a5aaad064d15bbe1997d20d0b4e9a4e4a5e26d Mon Sep 17 00:00:00 2001 From: Shield-Jaguar Date: Mon, 18 Aug 2025 13:13:51 +0500 Subject: [PATCH 07/77] feat: update action result --- src/actions/action-result.ts | 53 ++++++++++--------- src/actions/portal/copilot.ts | 24 +++++---- src/actions/portal/generate.ts | 8 +-- src/actions/portal/serve.ts | 52 ++++++++++++------ src/actions/sdk/generate.ts | 16 +++--- .../portal/serve/portal-watcher.ts | 3 +- src/commands/portal/copilot.ts | 4 +- src/commands/portal/generate.ts | 4 +- src/commands/sdk/generate.ts | 7 +-- src/prompts/format.ts | 14 +++-- src/prompts/portal/copilot.ts | 38 ++++++------- 11 files changed, 127 insertions(+), 96 deletions(-) diff --git a/src/actions/action-result.ts b/src/actions/action-result.ts index a9ccd4079..71b058e7b 100644 --- a/src/actions/action-result.ts +++ b/src/actions/action-result.ts @@ -1,50 +1,53 @@ enum ResultType { - Success, - Warning, - Error + Success= 0, + Warning = 2, + Cancel = 130, + Failure= 1 } export class ActionResult { - private readonly message: string | undefined; + private readonly message: string; private readonly resultType: ResultType; - private constructor(resultType: ResultType, message?: string) { + private constructor(resultType: ResultType, message: string) { this.resultType = resultType; this.message = message; } - static error(message: string) { - return new ActionResult(ResultType.Error, message); + static success() { + return new ActionResult(ResultType.Success, "Succeeded"); } - static error2() { - return new ActionResult(ResultType.Error); + static failed() { + return new ActionResult(ResultType.Failure, "Failed"); } - static warning(message: string) { - return new ActionResult(ResultType.Warning, message); + static cancelled() { + return new ActionResult(ResultType.Cancel, "Cancelled"); } - static success() { - return new ActionResult(ResultType.Success); + static warning() { + return new ActionResult(ResultType.Warning, "Warning"); } - map(onError: (message: string) => void, onWarning?: (message: string) => void) { - if (this.resultType === ResultType.Error) { - onError(this.message!); - } else if (this.resultType === ResultType.Warning && onWarning && this.message) { - onWarning(this.message); - } + public getMessage() { + return this.message; } - mapAll(onSuccess: () => T, onWarning: (message: string) => T, onError: (message: string) => T) { + public getExitCode() { + return this.resultType.valueOf(); + } + + public mapAll(onSuccess: () => T, onFailure: () => T, onCancel: () => T, onWarning: () => T): T { switch (this.resultType) { - case ResultType.Error: - return onError(this.message!); - case ResultType.Warning: - return onWarning(this.message!); - default: + case ResultType.Success: return onSuccess(); + case ResultType.Failure: + return onFailure(); + case ResultType.Cancel: + return onCancel(); + case ResultType.Warning: + return onWarning(); } } } diff --git a/src/actions/portal/copilot.ts b/src/actions/portal/copilot.ts index 4e8fc8109..2d881fc79 100644 --- a/src/actions/portal/copilot.ts +++ b/src/actions/portal/copilot.ts @@ -9,6 +9,7 @@ import { FilePath } from "../../types/file/filePath.js"; import { FileName } from "../../types/file/fileName.js"; import { FileService } from "../../infrastructure/file-service.js"; import { LauncherService } from "../../infrastructure/launcher-service.js"; +import { format } from "../../prompts/format.js"; export class CopilotAction { private readonly apiService = new ApiService(); @@ -26,25 +27,31 @@ export class CopilotAction { public async execute(buildDirectory: DirectoryPath, force: boolean, enable: boolean): Promise { const buildContext = new BuildContext(buildDirectory); - if (!(await buildContext.validate())) { - return ActionResult.error("'src' directory is empty or not valid."); + if (!await buildContext.validate()) { + this.prompts.srcDirectoryEmpty(buildDirectory); + return ActionResult.failed(); } const buildJson = await buildContext.getBuildFileContents(); - if (!force && buildJson.apiCopilotConfig != null && !(await this.prompts.confirmOverwrite())) - return ActionResult.error("Exiting without making any change."); + if (!force && buildJson.apiCopilotConfig != null && !(await this.prompts.confirmOverwrite())) { + this.prompts.cancelled(); + return ActionResult.cancelled(); + } const response = await this.prompts.spinnerAccountInfo( - () => this.apiService.getAccountInfo(this.configDir, this.authKey)); + this.apiService.getAccountInfo(this.configDir, this.authKey)); if (response.isErr()) { - return ActionResult.error(response._unsafeUnwrapErr()); + // TODO: add error prompt response._unsafeUnwrapErr() + return ActionResult.failed(); } const apiCopilotKey = await this.selectCopilotKey(response._unsafeUnwrap(), force); if (apiCopilotKey instanceof Error) { - return ActionResult.error(apiCopilotKey.message); + + // return ActionResult.error(apiCopilotKey.message); + return ActionResult.failed(); } const welcomeMessage = await this.getWelcomeMessage(); @@ -59,7 +66,6 @@ export class CopilotAction { this.prompts.copilotConfigured(enable, apiCopilotKey); - return ActionResult.success(); } @@ -69,7 +75,7 @@ export class CopilotAction { subscription.ApiCopilotKeys === undefined || subscription.ApiCopilotKeys.length === 0 ) { - return new Error("No copilot key found for the current subscription. Please contact support at support@apimatic.io."); + return new Error(`No copilot key found for the current subscription. Please contact support at ${format.var('support@apimatic.io')}.`); } if (subscription.ApiCopilotKeys.length === 1) { diff --git a/src/actions/portal/generate.ts b/src/actions/portal/generate.ts index 8517d3c2a..cc8c1cc9d 100644 --- a/src/actions/portal/generate.ts +++ b/src/actions/portal/generate.ts @@ -28,19 +28,19 @@ export class GenerateAction { ): Promise => { if (buildDirectory.isEqual(portalDirectory)) { this.prompts.directoryCannotBeSame(portalDirectory); - return ActionResult.error2(); + return ActionResult.failed(); } const buildContext = new BuildContext(buildDirectory); if (!(await buildContext.validate())) { this.prompts.srcDirectoryEmpty(buildDirectory); - return ActionResult.error2(); + return ActionResult.failed(); } const portalContext = new PortalContext(portalDirectory); if (!force && (await portalContext.exists()) && !(await this.prompts.overwritePortal(portalDirectory))) { this.prompts.portalDirectoryNotEmpty(); - return ActionResult.error2(); + return ActionResult.failed(); } return await withDirPath(async (tempDirectory) => { @@ -66,7 +66,7 @@ export class GenerateAction { await this.launcherService.openFile(reportPath); this.prompts.portalGenerationErrorWithReport(reportPath); } - return ActionResult.error2(); + return ActionResult.failed(); } ); }); diff --git a/src/actions/portal/serve.ts b/src/actions/portal/serve.ts index f46c81b22..fe2367090 100644 --- a/src/actions/portal/serve.ts +++ b/src/actions/portal/serve.ts @@ -34,22 +34,42 @@ export class PortalServeAction { false ); - return result.mapAll>>( - async () => { + if (result.) + + return await result.mapAll(async () => { const setupServerResult = await this.serveHandler.setupServer(paths.destinationDirectoryPath); - if (setupServerResult.isFailed()) { - return Result.failure(setupServerResult.error!); - } - - const startServerResult = await this.serveHandler.startServer(paths, flags, generatePortal); - if (startServerResult.isFailed()) { - return Result.failure(startServerResult.error!); - } - - return Result.success(`Portal was successfully served.`); - }, - async (message) => Result.failure(message), - async (message) => Result.failure(message) - ); + if (setupServerResult.isFailed()) { + return Result.failure(setupServerResult.error!); + } + + const startServerResult = await this.serveHandler.startServer(paths, flags, generatePortal); + if (startServerResult.isFailed()) { + return Result.failure(startServerResult.error!); + } + + return Result.success(`Portal was successfully served.`); + }, + async () => Result.failure("Failed to generate portal."), + async () => Result.failure("Failed to start server."), + async () => Result.failure("Failed to serve portal.")); + + + // return result.mapAll>>( + // async () => { + // const setupServerResult = await this.serveHandler.setupServer(paths.destinationDirectoryPath); + // if (setupServerResult.isFailed()) { + // return Result.failure(setupServerResult.error!); + // } + // + // const startServerResult = await this.serveHandler.startServer(paths, flags, generatePortal); + // if (startServerResult.isFailed()) { + // return Result.failure(startServerResult.error!); + // } + // + // return Result.success(`Portal was successfully served.`); + // }, + // async (message) => Result.failure(message), + // async (message) => Result.failure(message) + // ); } } diff --git a/src/actions/sdk/generate.ts b/src/actions/sdk/generate.ts index 49b695a49..4a06e76fe 100644 --- a/src/actions/sdk/generate.ts +++ b/src/actions/sdk/generate.ts @@ -33,19 +33,22 @@ export class GenerateAction { zipSdk: boolean ): Promise => { if (specDirectory.isEqual(sdkDirectory)) { - return ActionResult.error(`The spec directory and sdk directory cannot be the same: "${specDirectory}"`); + // return ActionResult.error(`The spec directory and sdk directory cannot be the same: "${specDirectory}"`); + return ActionResult.failed(); } const specContext = new SpecContext(specDirectory); if (!(await specContext.validate())) { - return ActionResult.error(`The spec directory is either empty or invalid: "${specDirectory}"`); + // return ActionResult.error(`The spec directory is either empty or invalid: "${specDirectory}"`); + return ActionResult.failed(); } const sdkContext = new SdkContext(sdkDirectory, platform); if (!force && (await sdkContext.exists()) && !(await this.prompts.overwriteSdk(sdkDirectory))) { - return ActionResult.error( - "Please enter a different destination folder or remove the existing files and try again." - ); + // return ActionResult.error( + // "Please enter a different destination folder or remove the existing files and try again." + // ); + return ActionResult.failed(); } return await withDirPath(async (tempDirectory) => { @@ -59,7 +62,8 @@ export class GenerateAction { if (!response.isSuccess()) { this.prompts.displaySdkGenerationErrorMessage(); - return ActionResult.error(response.error!); + // return ActionResult.error(response.error!); + return ActionResult.failed(); } const tempSdkFilePath = new FilePath(tempDirectory, new FileName("sdk.zip")); diff --git a/src/application/portal/serve/portal-watcher.ts b/src/application/portal/serve/portal-watcher.ts index 1250c7cba..225be11ce 100644 --- a/src/application/portal/serve/portal-watcher.ts +++ b/src/application/portal/serve/portal-watcher.ts @@ -98,6 +98,7 @@ export class PortalWatcher { true, false ); - result.map((error) => console.log(error)); + + //result.map((error) => console.log(error)); } } diff --git a/src/commands/portal/copilot.ts b/src/commands/portal/copilot.ts index e907d12f0..371276dc9 100644 --- a/src/commands/portal/copilot.ts +++ b/src/commands/portal/copilot.ts @@ -3,6 +3,7 @@ import { DirectoryPath } from "../../types/file/directoryPath.js"; import { PortalCopilotPrompts } from "../../prompts/portal/copilot.js"; import { FlagsProvider } from "../../types/flags-provider.js"; import { CopilotAction } from "../../actions/portal/copilot.js"; +import { outro } from "../../prompts/format.js"; const DEFAULT_WORKING_DIRECTORY = "./"; @@ -38,6 +39,5 @@ export default class PortalCopilotEnable extends Command { const buildDirectory = input ? new DirectoryPath(input, "src") : workingDirectory.join("src"); const copilotConfigAction = new CopilotAction(new DirectoryPath(this.config.configDir), authKey); const result = await copilotConfigAction.execute(buildDirectory, force, !disable); - result.map((message) => this.prompts.logError(message)); - } + outro(result);} } diff --git a/src/commands/portal/generate.ts b/src/commands/portal/generate.ts index 8418d3416..6482747af 100644 --- a/src/commands/portal/generate.ts +++ b/src/commands/portal/generate.ts @@ -44,9 +44,7 @@ export class PortalGenerate extends Command { intro('Generate Portal'); const action = new GenerateAction(this.getConfigDir(), authKey); const result = await action.execute(buildDirectory, portalDirectory, force, zipPortal); - const exitCode = result.mapAll(() => 0, () => 1, ()=> 2); - //outro(exitCode); - this.error("some error" + exitCode); + outro(result); } diff --git a/src/commands/sdk/generate.ts b/src/commands/sdk/generate.ts index 539edb4d1..d87ac6342 100644 --- a/src/commands/sdk/generate.ts +++ b/src/commands/sdk/generate.ts @@ -4,6 +4,7 @@ import { FlagsProvider } from "../../types/flags-provider.js"; import { SdkGeneratePrompts } from "../../prompts/sdk/generate.js"; import { GenerateAction } from "../../actions/sdk/generate.js"; import { LanguagePlatform } from "../../types/sdk/generate.js"; +import { outro } from "../../prompts/format.js"; const DEFAULT_WORKING_DIRECTORY = "./"; @@ -50,11 +51,7 @@ export default class SdkGenerate extends Command { const action = new GenerateAction(this.getConfigDir(), authKey); const result = await action.execute(specDirectory, sdkDirectory, platform as LanguagePlatform, force, zipSdk); - result.mapAll( - () => this.prompts.displayOutroMessage(sdkDirectory), - (message) => this.prompts.logError(message), - (message) => this.prompts.logError(message) - ); + outro(result); } private getConfigDir = () => { diff --git a/src/prompts/format.ts b/src/prompts/format.ts index 4190ead85..d51a7cc5f 100644 --- a/src/prompts/format.ts +++ b/src/prompts/format.ts @@ -1,6 +1,7 @@ import pc from "picocolors"; import { Result } from "neverthrow"; import { intro as i, outro as o, spinner } from '@clack/prompts'; +import { ActionResult } from "../actions/action-result.js"; export const format = { @@ -24,10 +25,15 @@ export function intro(text: string) { i(format.intro(` ${text} `)); } -export function outro(exitCode: number) { - const message = - exitCode === 0 ? format.outroSuccess(' success ') : exitCode === 2 ? format.outroFailure(' failure ') : format.outroWarning(' warning '); - o(message); +export function outro(result: ActionResult) { + + const exitCode = result.getExitCode(); + const message = result.getMessage(); + const outroMessage = result.mapAll(() => format.outroSuccess(message), + () => format.outroFailure(message), + () => format.outroFailure(message), + () => format.outroWarning(message)); + o(outroMessage); process.exitCode = exitCode; } diff --git a/src/prompts/portal/copilot.ts b/src/prompts/portal/copilot.ts index f303c3e83..75e9bf489 100644 --- a/src/prompts/portal/copilot.ts +++ b/src/prompts/portal/copilot.ts @@ -1,8 +1,9 @@ -import { confirm, isCancel, log, outro, select, spinner } from "@clack/prompts"; -import { getMessageInCyanColor } from "../../utils/utils.js"; +import { confirm, isCancel, log, outro, select } from "@clack/prompts"; import { Result } from "neverthrow"; import { SubscriptionInfo } from "../../types/api/account.js"; import { ServiceError } from "../../infrastructure/api-utils.js"; +import { DirectoryPath } from "../../types/file/directoryPath.js"; +import { format as f, withSpinner } from "../format.js"; export class PortalCopilotPrompts { public async displayApiCopilotKeyUsageWarning() { @@ -29,8 +30,8 @@ export class PortalCopilotPrompts { outro( `API Copilot configured successfully! - Copilot ID: ${getMessageInCyanColor(copilotId)} - Status: ${getMessageInCyanColor(status ? "Enabled" : "Disabled")} + Copilot ID: ${f.var(copilotId)} + Status: ${f.var(status ? "Enabled" : "Disabled")} Configuration saved to: APIMATIC-BUILD.json @@ -53,12 +54,9 @@ To see your copilot: If your portal is already running, refresh the page. Otherw return shouldOverwrite; } - public logError(error: string): void { - log.error(error); - } - public async spinnerAccountInfo(fn: () => Promise>) { - return this.withSpinner( + public async spinnerAccountInfo(fn: Promise>) { + return withSpinner( "Retrieving your subscription info", "Retrieved subscription info", "Error retrieving subscription info", @@ -66,22 +64,11 @@ To see your copilot: If your portal is already running, refresh the page. Otherw ); } - private async withSpinner(intro: string, success: string, failure: string, fn: () => Promise>) { - const s = spinner(); - s.start(intro); - const result = await fn(); - result.match( - () => s.stop(success, 0), - () => s.stop(failure, 1) - ); - return result; - } - public async confirmSingleKeyUsage(apiCopilotKey: string) { const confirmKeyUsage = await confirm({ message: "API Copilot can only be active on one Portal at a time. Configuring it on this Portal will disable it on any previously configured Portal.\n" + - `Do you want to use this key: '${apiCopilotKey}'?`, + `Do you want to use this key: '${f.var(apiCopilotKey)}'?`, initialValue: true }); @@ -91,4 +78,13 @@ To see your copilot: If your portal is already running, refresh the page. Otherw return confirmKeyUsage; } + + public srcDirectoryEmpty(directory: DirectoryPath) { + const message = `The ${f.var("src")} directory is either empty or invalid: ${f.path(directory.toString())}`; + log.error(message); + } + + public cancelled() { + log.warning('Exiting without making any change.'); + } } From b41533326a1222c534cca1a2e738468f50f215f4 Mon Sep 17 00:00:00 2001 From: Shield-Jaguar Date: Mon, 18 Aug 2025 14:42:16 +0500 Subject: [PATCH 08/77] feat: fix calls --- src/actions/portal/serve.ts | 30 ++++++++++++++++-------------- src/commands/portal/quickstart.ts | 21 ++++++++++++--------- src/commands/portal/serve.ts | 24 ++++++++++++------------ 3 files changed, 40 insertions(+), 35 deletions(-) diff --git a/src/actions/portal/serve.ts b/src/actions/portal/serve.ts index fe2367090..8161737d8 100644 --- a/src/actions/portal/serve.ts +++ b/src/actions/portal/serve.ts @@ -26,7 +26,7 @@ export class PortalServeAction { force: boolean, zipPortal: boolean ) => Promise - ): Promise> { + ): Promise { const result = await generatePortal( new DirectoryPath(paths.sourceDirectoryPath), new DirectoryPath(paths.destinationDirectoryPath), @@ -34,24 +34,26 @@ export class PortalServeAction { false ); - if (result.) return await result.mapAll(async () => { const setupServerResult = await this.serveHandler.setupServer(paths.destinationDirectoryPath); - if (setupServerResult.isFailed()) { - return Result.failure(setupServerResult.error!); - } + if (setupServerResult.isFailed()) { + //return Result.failure(setupServerResult.error!); + return ActionResult.failed(); + } - const startServerResult = await this.serveHandler.startServer(paths, flags, generatePortal); - if (startServerResult.isFailed()) { - return Result.failure(startServerResult.error!); - } + const startServerResult = await this.serveHandler.startServer(paths, flags, generatePortal); + if (startServerResult.isFailed()) { + // return Result.failure(startServerResult.error!); + return ActionResult.failed(); + } - return Result.success(`Portal was successfully served.`); - }, - async () => Result.failure("Failed to generate portal."), - async () => Result.failure("Failed to start server."), - async () => Result.failure("Failed to serve portal.")); + return ActionResult.success(); + // return Result.success(`Portal was successfully served.`); + }, + async () => result, + async () => result, + async () => result); // return result.mapAll>>( diff --git a/src/commands/portal/quickstart.ts b/src/commands/portal/quickstart.ts index 2ffe25071..f8fa7dfee 100644 --- a/src/commands/portal/quickstart.ts +++ b/src/commands/portal/quickstart.ts @@ -151,15 +151,18 @@ export default class PortalQuickstart extends Command { generatePortalAction.execute ); - if (servePortalResult.isFailed()) { - portalServePrompts.logError(getMessageInRedColor(servePortalResult.error!)); - return; - } - - if (servePortalResult.isCancelled()) { - portalServePrompts.logError(getMessageInRedColor(servePortalResult.value!)); - return; - } + // TODO: Shield fix errors; + + + // if (servePortalResult.isFailed()) { + // portalServePrompts.logError(getMessageInRedColor(servePortalResult.error!)); + // return; + // } + // + // if (servePortalResult.isCancelled()) { + // portalServePrompts.logError(getMessageInRedColor(servePortalResult.value!)); + // return; + // } prompts.displayOutroMessage(buildDirectory.toString()); } catch (error) { diff --git a/src/commands/portal/serve.ts b/src/commands/portal/serve.ts index 7c4bc6427..c186f9407 100644 --- a/src/commands/portal/serve.ts +++ b/src/commands/portal/serve.ts @@ -75,18 +75,18 @@ export default class PortalServe extends Command { }; const servePortalResult = await portalServeAction.servePortal(serveFlags, servePaths, generatePortalAction.execute); - //TODO: Convert below statements to result.mapAll after changing servePortalResult to ActionResult. - if (servePortalResult.isFailed()) { - portalServePrompts.logError(getMessageInRedColor(servePortalResult.error!)); - } - - if (servePortalResult.isCancelled()) { - portalServePrompts.logError(getMessageInRedColor(servePortalResult.value!)); - } - - if (servePortalResult.isSuccess()) { - this.prompts.displayOutroMessage(buildDirectory.toString(), portalDirectory.toString(), port, flags["no-reload"]); - } + //TODO: Shield Convert below statements to result.mapAll after changing servePortalResult to ActionResult. + // if (servePortalResult.isFailed()) { + // portalServePrompts.logError(getMessageInRedColor(servePortalResult.error!)); + // } + // + // if (servePortalResult.isCancelled()) { + // portalServePrompts.logError(getMessageInRedColor(servePortalResult.value!)); + // } + // + // if (servePortalResult.isSuccess()) { + // this.prompts.displayOutroMessage(buildDirectory.toString(), portalDirectory.toString(), port, flags["no-reload"]); + // } } private async getServerPort(port: number | undefined): Promise { From 302769eb955aaebe928eb3c10d1362b568a26a14 Mon Sep 17 00:00:00 2001 From: Shield-Jaguar Date: Tue, 19 Aug 2025 16:16:54 +0500 Subject: [PATCH 09/77] refactor: update prompts --- src/actions/action-result.ts | 8 ++--- src/actions/portal/copilot.ts | 62 +++++++++++++++++++-------------- src/actions/portal/generate.ts | 34 +++++++++--------- src/commands/portal/copilot.ts | 31 ++++++++--------- src/commands/portal/generate.ts | 28 ++++++--------- src/infrastructure/api-utils.ts | 2 +- src/prompts/format.ts | 16 +++++++-- src/prompts/portal/copilot.ts | 42 ++++++++++++++++------ 8 files changed, 126 insertions(+), 97 deletions(-) diff --git a/src/actions/action-result.ts b/src/actions/action-result.ts index 71b058e7b..23e671055 100644 --- a/src/actions/action-result.ts +++ b/src/actions/action-result.ts @@ -15,19 +15,19 @@ export class ActionResult { } static success() { - return new ActionResult(ResultType.Success, "Succeeded"); + return new ActionResult(ResultType.Success, " Succeeded "); } static failed() { - return new ActionResult(ResultType.Failure, "Failed"); + return new ActionResult(ResultType.Failure, " Failed "); } static cancelled() { - return new ActionResult(ResultType.Cancel, "Cancelled"); + return new ActionResult(ResultType.Cancel, " Cancelled "); } static warning() { - return new ActionResult(ResultType.Warning, "Warning"); + return new ActionResult(ResultType.Warning, " Warning "); } public getMessage() { diff --git a/src/actions/portal/copilot.ts b/src/actions/portal/copilot.ts index 2d881fc79..9da383002 100644 --- a/src/actions/portal/copilot.ts +++ b/src/actions/portal/copilot.ts @@ -9,7 +9,10 @@ import { FilePath } from "../../types/file/filePath.js"; import { FileName } from "../../types/file/fileName.js"; import { FileService } from "../../infrastructure/file-service.js"; import { LauncherService } from "../../infrastructure/launcher-service.js"; -import { format } from "../../prompts/format.js"; +import { err, ok, Result } from "neverthrow"; + +type SelectKeyFailure = "failed" | "cancelled"; +type SelectKeyResult = Result; export class CopilotAction { private readonly apiService = new ApiService(); @@ -24,10 +27,14 @@ export class CopilotAction { this.authKey = authKey; } - public async execute(buildDirectory: DirectoryPath, force: boolean, enable: boolean): Promise { + public readonly execute = async ( + buildDirectory: DirectoryPath, + force: boolean, + enable: boolean + ): Promise => { const buildContext = new BuildContext(buildDirectory); - if (!await buildContext.validate()) { + if (!(await buildContext.validate())) { this.prompts.srcDirectoryEmpty(buildDirectory); return ActionResult.failed(); } @@ -40,61 +47,62 @@ export class CopilotAction { } const response = await this.prompts.spinnerAccountInfo( - this.apiService.getAccountInfo(this.configDir, this.authKey)); + this.apiService.getAccountInfo(this.configDir, this.authKey) + ); if (response.isErr()) { - // TODO: add error prompt response._unsafeUnwrapErr() + this.prompts.networkError(response.error); return ActionResult.failed(); } - const apiCopilotKey = await this.selectCopilotKey(response._unsafeUnwrap(), force); - if (apiCopilotKey instanceof Error) { - - // return ActionResult.error(apiCopilotKey.message); + const apiCopilotKeyResult = await this.selectCopilotKey(response.value, force); + if (apiCopilotKeyResult.isErr()) { + if (apiCopilotKeyResult.error === "cancelled") return ActionResult.cancelled(); return ActionResult.failed(); } - const welcomeMessage = await this.getWelcomeMessage(); + const welcomeMessage = await this.prepareWelcomeMessage(); buildJson.apiCopilotConfig = { isEnabled: enable, - key: apiCopilotKey, + key: apiCopilotKeyResult.value, welcomeMessage: welcomeMessage }; await buildContext.updateBuildFileContents(buildJson); - this.prompts.copilotConfigured(enable, apiCopilotKey); + this.prompts.copilotConfigured(enable, apiCopilotKeyResult.value); return ActionResult.success(); - } + }; - private async selectCopilotKey(subscription: SubscriptionInfo | undefined, force: boolean): Promise { - if ( - subscription === undefined || - subscription.ApiCopilotKeys === undefined || - subscription.ApiCopilotKeys.length === 0 - ) { - return new Error(`No copilot key found for the current subscription. Please contact support at ${format.var('support@apimatic.io')}.`); + private async selectCopilotKey(subscription: SubscriptionInfo, force: boolean): Promise { + if (subscription.ApiCopilotKeys === undefined || subscription.ApiCopilotKeys.length === 0) { + this.prompts.noCopilotKeyFound(); + return err("failed"); } if (subscription.ApiCopilotKeys.length === 1) { if (force || (await this.prompts.confirmSingleKeyUsage(subscription.ApiCopilotKeys[0]))) - return subscription.ApiCopilotKeys[0]; - return new Error("Operation cancelled. No API Copilot key was selected."); + return ok(subscription.ApiCopilotKeys[0]); + this.prompts.noCopilotKeySelected(); + return err("cancelled"); } const key = await this.prompts.selectCopilotKey(subscription.ApiCopilotKeys); - if (key === null) return new Error("Operation cancelled. No API Copilot key was selected."); + if (key === null) { + this.prompts.noCopilotKeySelected(); + return err("cancelled"); + } await this.prompts.displayApiCopilotKeyUsageWarning(); - - return key; + return ok(key); } - private async getWelcomeMessage(): Promise { + private async prepareWelcomeMessage(): Promise { return await withDirPath(async (tempDir) => { const tempFile = new FilePath(tempDir, new FileName("welcome-message.md")); - const defaultContent = "Hi there! I'm your API Integration Assistant, here to help you learn and integrate with this API.\n" + + const defaultContent = + "Hi there! I'm your API Integration Assistant, here to help you learn and integrate with this API.\n" + "\n" + "Ask me anything about this API or try one of these example prompts:\n" + "\n" + diff --git a/src/actions/portal/generate.ts b/src/actions/portal/generate.ts index cc8c1cc9d..07e80acbf 100644 --- a/src/actions/portal/generate.ts +++ b/src/actions/portal/generate.ts @@ -40,7 +40,7 @@ export class GenerateAction { const portalContext = new PortalContext(portalDirectory); if (!force && (await portalContext.exists()) && !(await this.prompts.overwritePortal(portalDirectory))) { this.prompts.portalDirectoryNotEmpty(); - return ActionResult.failed(); + return ActionResult.cancelled(); } return await withDirPath(async (tempDirectory) => { @@ -51,24 +51,22 @@ export class GenerateAction { this.portalService.generatePortal(buildZipPath, this.configDir, this.authKey) ); - return response.match( - async (portalStream) => { - const tempPortalZipPath = await tempContext.save(portalStream); - await portalContext.save(tempPortalZipPath, zipPortal); - return ActionResult.success(); - }, - async (error) => { - if (typeof error === "string") { - this.prompts.portalGenerationError(error); - } else { - const errorZipPath = await tempContext.save(error); - const reportPath = await portalContext.saveError(errorZipPath); - await this.launcherService.openFile(reportPath); - this.prompts.portalGenerationErrorWithReport(reportPath); - } - return ActionResult.failed(); + if (response.isErr()) { + const error = response.error; + if (typeof error === "string") { + this.prompts.portalGenerationError(error); + } else { + const errorZipPath = await tempContext.save(error); + const reportPath = await portalContext.saveError(errorZipPath); + await this.launcherService.openFile(reportPath); + this.prompts.portalGenerationErrorWithReport(reportPath); } - ); + return ActionResult.failed(); + } + + const tempPortalZipPath = await tempContext.save(response.value); + await portalContext.save(tempPortalZipPath, zipPortal); + return ActionResult.success(); }); }; } diff --git a/src/commands/portal/copilot.ts b/src/commands/portal/copilot.ts index 371276dc9..965910266 100644 --- a/src/commands/portal/copilot.ts +++ b/src/commands/portal/copilot.ts @@ -1,21 +1,18 @@ import { Command, Flags } from "@oclif/core"; import { DirectoryPath } from "../../types/file/directoryPath.js"; -import { PortalCopilotPrompts } from "../../prompts/portal/copilot.js"; import { FlagsProvider } from "../../types/flags-provider.js"; import { CopilotAction } from "../../actions/portal/copilot.js"; -import { outro } from "../../prompts/format.js"; - -const DEFAULT_WORKING_DIRECTORY = "./"; - -export default class PortalCopilotEnable extends Command { +import { format, intro, outro } from "../../prompts/format.js"; +export default class PortalCopilot extends Command { static summary = "Configure API Copilot for your API Documentation portal"; - static description = "Displays available API Copilots associated with your account and allows you to select which one to integrate with your portal. Each APIMatic account includes one Copilot by default. The selected Copilot will be added to your APIMATIC-BUILD.json file"; + static description = + "Displays available API Copilots associated with your account and allows you to select which one to integrate with your portal. Each APIMatic account includes one Copilot by default. The selected Copilot will be added to your APIMATIC-BUILD.json file"; static flags = { ...FlagsProvider.input, - disable : Flags.boolean({ + disable: Flags.boolean({ default: false, description: "marks the API Copilot as disabled in the configuration" }), @@ -23,21 +20,21 @@ export default class PortalCopilotEnable extends Command { ...FlagsProvider.authKey }; + static cmdTxt = format.cmd("apimatic", "portal", "copilot"); static examples = [ - `apimatic portal:copilot --input="./"`, - `apimatic portal:copilot --input="./" --disable`, + `${this.cmdTxt} ${format.flag("input", '"./"')}`, + `${this.cmdTxt} ${format.flag("input", '"./"')} ${format.flag("disable")}` ]; - private readonly prompts = new PortalCopilotPrompts(); - async run(): Promise { const { - flags: { input, "auth-key": authKey, disable, force} - } = await this.parse(PortalCopilotEnable); + flags: { input, "auth-key": authKey, disable, force } + } = await this.parse(PortalCopilot); - const workingDirectory = new DirectoryPath(input ?? DEFAULT_WORKING_DIRECTORY); - const buildDirectory = input ? new DirectoryPath(input, "src") : workingDirectory.join("src"); + intro("Configure API Copilot"); + const buildDirectory = new DirectoryPath(input ?? "./", "src"); const copilotConfigAction = new CopilotAction(new DirectoryPath(this.config.configDir), authKey); const result = await copilotConfigAction.execute(buildDirectory, force, !disable); - outro(result);} + outro(result); + } } diff --git a/src/commands/portal/generate.ts b/src/commands/portal/generate.ts index 6482747af..f426d2424 100644 --- a/src/commands/portal/generate.ts +++ b/src/commands/portal/generate.ts @@ -2,19 +2,16 @@ import { Command, Flags } from "@oclif/core"; import { DirectoryPath } from "../../types/file/directoryPath.js"; import { GenerateAction } from "../../actions/portal/generate.js"; import { FlagsProvider } from "../../types/flags-provider.js"; -import { intro, outro } from "../../prompts/format.js"; +import { format, intro, outro } from "../../prompts/format.js"; const DEFAULT_WORKING_DIRECTORY = "./"; export class PortalGenerate extends Command { - static summary = "Generate an API Documentation portal"; static description = "Generate an API Documentation portal. Requires an input directory containing API specifications, a config file and optionally, markdown guides. For details, refer to the [documentation](https://docs.apimatic.io/platform-api/#/http/guides/generating-on-prem-api-portal/build-file-reference)"; - static examples = [`apimatic portal:generate`, `apimatic portal:generate --input="./" --destination="./portal"`]; - static flags = { ...FlagsProvider.input, ...FlagsProvider.destination("portal", "portal"), @@ -26,29 +23,24 @@ export class PortalGenerate extends Command { ...FlagsProvider.authKey }; + static cmdTxt = format.cmd("apimatic", "portal", "generate"); + static examples = [ + this.cmdTxt, + `${this.cmdTxt} ${format.flag("input", '"./"')} ${format.flag("destination", '"./portal"')}` + ]; + async run(): Promise { const { - flags: { - input, - destination, - force, - zip: zipPortal, - "auth-key": authKey - } + flags: { input, destination, force, zip: zipPortal, "auth-key": authKey } } = await this.parse(PortalGenerate); const workingDirectory = new DirectoryPath(input ?? DEFAULT_WORKING_DIRECTORY); const buildDirectory = input ? new DirectoryPath(input, "src") : workingDirectory.join("src"); const portalDirectory = destination ? new DirectoryPath(destination) : workingDirectory.join("portal"); - intro('Generate Portal'); - const action = new GenerateAction(this.getConfigDir(), authKey); + intro("Generate Portal"); + const action = new GenerateAction(new DirectoryPath(this.config.configDir), authKey); const result = await action.execute(buildDirectory, portalDirectory, force, zipPortal); outro(result); } - - - private getConfigDir(): DirectoryPath { - return new DirectoryPath(this.config.configDir); - } } diff --git a/src/infrastructure/api-utils.ts b/src/infrastructure/api-utils.ts index 55d9ab5d2..c72b7bf54 100644 --- a/src/infrastructure/api-utils.ts +++ b/src/infrastructure/api-utils.ts @@ -33,7 +33,7 @@ export function handleServiceError(error: unknown): ServiceError { if (error.response?.status === 500) { return ServiceError.ServerError; } - if (error.code === "ECONNABORTED" || error.code === "ECONNREFUSED") { + if (error.code === "ECONNABORTED" || error.code === "ECONNREFUSED" || error.code === "ENOTFOUND") { return ServiceError.NetworkError; } } diff --git a/src/prompts/format.ts b/src/prompts/format.ts index d51a7cc5f..eda9618ea 100644 --- a/src/prompts/format.ts +++ b/src/prompts/format.ts @@ -8,6 +8,13 @@ export const format = { // Core element types var: (text: string) => pc.magenta(`'${text}'`), path: (text: string) => pc.cyan(`'${text}'`), + cmd: (cmd: string, topic: string, action: string) => `${pc.blue(cmd)} ${pc.dim(topic)} ${pc.dim(action)}`, + flag: (name: string, value: string | undefined = undefined) => { + if (value) { + return `${pc.green(`--${name}`)}=${pc.dim(value)}`; + } + return `${pc.green(`--${name}`)}`; + }, // Common message styles success: (text: string) => pc.green(text), @@ -29,7 +36,8 @@ export function outro(result: ActionResult) { const exitCode = result.getExitCode(); const message = result.getMessage(); - const outroMessage = result.mapAll(() => format.outroSuccess(message), + const outroMessage = result.mapAll( + () => format.outroSuccess(message), () => format.outroFailure(message), () => format.outroFailure(message), () => format.outroWarning(message)); @@ -39,7 +47,11 @@ export function outro(result: ActionResult) { export async function withSpinner(intro: string, success: string, failure: string, fn: Promise>) { - const s = spinner(); + const s = spinner({ + cancelMessage: "cancelled", + errorMessage: "failed", + frames: ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'], + }); s.start(intro); const result = await fn; result.match( diff --git a/src/prompts/portal/copilot.ts b/src/prompts/portal/copilot.ts index 75e9bf489..a276a4f17 100644 --- a/src/prompts/portal/copilot.ts +++ b/src/prompts/portal/copilot.ts @@ -1,13 +1,15 @@ -import { confirm, isCancel, log, outro, select } from "@clack/prompts"; +import { confirm, isCancel, log, note, outro, select } from "@clack/prompts"; import { Result } from "neverthrow"; import { SubscriptionInfo } from "../../types/api/account.js"; -import { ServiceError } from "../../infrastructure/api-utils.js"; +import { getErrorMessage, ServiceError } from "../../infrastructure/api-utils.js"; import { DirectoryPath } from "../../types/file/directoryPath.js"; -import { format as f, withSpinner } from "../format.js"; +import { format, format as f, withSpinner } from "../format.js"; export class PortalCopilotPrompts { public async displayApiCopilotKeyUsageWarning() { - log.warn('API Copilot can only be active on one Portal at a time. Configuring it on this Portal will disable it on any previously configured Portal.') + log.warn( + "API Copilot can only be active on one Portal at a time. Configuring it on this Portal will disable it on any previously configured Portal." + ); } public async selectCopilotKey(keys: string[]): Promise { const selectedKey = await select({ @@ -27,17 +29,22 @@ export class PortalCopilotPrompts { } public copilotConfigured(status: boolean, copilotId: string): void { - outro( + log.info( `API Copilot configured successfully! Copilot ID: ${f.var(copilotId)} Status: ${f.var(status ? "Enabled" : "Disabled")} - Configuration saved to: APIMATIC-BUILD.json + Configuration saved to: ${f.path('APIMATIC-BUILD.json')}`); + + note( + `API Copilot will index your content the next time you run ${f.cmd('apimatic', 'portal', 'generate')} or ${f.cmd('apimatic', 'portal', 'serve')}. +This process can take up to 10 minutes, depending on your API’s size. -API Copilot will index your content the next time you run \`apimatic portal:generate\` or \`apimatic portal:serve\`. This process can take up to 10 minutes, depending on your API’s size. +To see your copilot: If your portal is already running, refresh the page. Otherwise, run ${f.cmd('apimatic', 'portal', 'serve')}, +select any programming language in the Portal and look for the chat icon in the bottom-right corner.`, -To see your copilot: If your portal is already running, refresh the page. Otherwise, run \`apimatic portal:serve\`, select any programming language in the Portal and look for the chat icon in the bottom-right corner.` + `Next steps` ); } @@ -54,7 +61,6 @@ To see your copilot: If your portal is already running, refresh the page. Otherw return shouldOverwrite; } - public async spinnerAccountInfo(fn: Promise>) { return withSpinner( "Retrieving your subscription info", @@ -85,6 +91,22 @@ To see your copilot: If your portal is already running, refresh the page. Otherw } public cancelled() { - log.warning('Exiting without making any change.'); + log.warning("Exiting without making any change."); + } + + public networkError(serviceError: ServiceError) { + log.error(getErrorMessage(serviceError)); + } + + public noCopilotKeyFound() { + log.error( + `No copilot key found for the current subscription. Please contact support at ${format.var( + "support@apimatic.io" + )}.` + ); + } + + public noCopilotKeySelected() { + log.error("Operation cancelled. No API Copilot key was selected."); } } From 9d92f58600baf006214008a1981a78fba2e1f3bd Mon Sep 17 00:00:00 2001 From: Shield-Jaguar Date: Thu, 21 Aug 2025 11:43:14 +0500 Subject: [PATCH 10/77] feat: update text --- src/actions/portal/generate.ts | 1 + src/infrastructure/services/portal-service.ts | 40 +++++++++---------- src/prompts/portal/generate.ts | 2 - 3 files changed, 19 insertions(+), 24 deletions(-) diff --git a/src/actions/portal/generate.ts b/src/actions/portal/generate.ts index 07e80acbf..bd0fcb45d 100644 --- a/src/actions/portal/generate.ts +++ b/src/actions/portal/generate.ts @@ -43,6 +43,7 @@ export class GenerateAction { return ActionResult.cancelled(); } + return await withDirPath(async (tempDirectory) => { const tempContext = new TempContext(tempDirectory); const buildZipPath = await tempContext.zip(buildDirectory); diff --git a/src/infrastructure/services/portal-service.ts b/src/infrastructure/services/portal-service.ts index c575a19a7..e4d33c4b4 100644 --- a/src/infrastructure/services/portal-service.ts +++ b/src/infrastructure/services/portal-service.ts @@ -1,20 +1,20 @@ import { + Accept, + ApiError, + ApiResponse, + BadRequestResponseSdkError, + Client, + CodeGenerationExternalApisController, ContentType, DocsPortalManagementController, - Client, - UnauthorizedResponseError, - ProblemDetailsError, - FileWrapper, - ApiResponse, - ApiError, - TransformationController, - Transformation, ExportFormats, + FileWrapper, InternalServerErrorResponseError, - CodeGenerationExternalApisController, Platforms, - BadRequestResponseSdkError, - Accept + ProblemDetailsError, + Transformation, + TransformationController, + UnauthorizedResponseError } from "@apimatic/sdk"; import { AuthInfo, getAuthInfo } from "../../client-utils/auth-manager.js"; import { Result } from "../../types/common/result.js"; @@ -50,7 +50,7 @@ export class PortalService { const response = await docsPortalManagementController.generateOnPremPortalViaBuildInput(this.CONTENT_TYPE, file); return ok(response.result as NodeJS.ReadableStream); } catch (error) { - return err(await this.handlePortalGenerationErrors(error)); + return err(await PortalService.handlePortalGenerationErrors(error)); } finally { buildFileStream.close(); } @@ -130,23 +130,20 @@ export class PortalService { }); }; - private handlePortalGenerationErrors = async (error: unknown): Promise => { + private static handlePortalGenerationErrors = async (error: unknown): Promise => { if (error instanceof UnauthorizedResponseError) { //401 - const unAuthError = error as UnauthorizedResponseError; - return unAuthError.result?.message ?? "Authorization has been denied for this request."; + return error.result?.message ?? "Authorization has been denied for this request."; } else if (error instanceof ProblemDetailsError) { //400 & 403 - const probDetailsError = error as ProblemDetailsError; - const message = (probDetailsError.result!.errors as Record)?.[""]?.[0]; - return probDetailsError.result!.title + "\n- " + message; + const message = (error.result!.errors as Record)?.[""]?.[0]; + return error.result!.title + "\n- " + message; } else if (error instanceof ApiError && error.statusCode === 422) { //422 return error.body as NodeJS.ReadableStream; } else if (error instanceof InternalServerErrorResponseError) { //500 - const internalServerError = error as InternalServerErrorResponseError; - const message = internalServerError.result?.message; + const message = error.result?.message; return `${ message ?? "An unknown error occurred." } Please try again later or reach out to our team at support@apimatic.io for help if your problem persists.`; @@ -168,8 +165,7 @@ export class PortalService { // Get the first error and clean it up const firstError = parsedResult.Errors[0]; // Split on
and take first part, then strip remaining HTML tags - const cleanError = firstError.split("
")[0].replace(/<[^<>]*?>/g, ""); - return cleanError; + return firstError.split("
")[0].replace(/<[^<>]*?>/g, ""); } else if (parsedResult.Success === false) { return "API definition file validation failed."; } diff --git a/src/prompts/portal/generate.ts b/src/prompts/portal/generate.ts index 9876714fc..fe2d65c47 100644 --- a/src/prompts/portal/generate.ts +++ b/src/prompts/portal/generate.ts @@ -48,6 +48,4 @@ export class PortalGeneratePrompts { An error report has been written at the destination path: ${f.path(reportPath.toString())}`; log.error(message); } - - } From 3de01b4d84b06c77a12553cded158c41e1812d85 Mon Sep 17 00:00:00 2001 From: Shield-Jaguar Date: Thu, 21 Aug 2025 15:42:11 +0500 Subject: [PATCH 11/77] feat: refactor --- src/actions/action-result.ts | 9 +-------- src/actions/portal/copilot.ts | 2 +- src/actions/portal/serve.ts | 2 -- src/commands/portal/copilot.ts | 2 +- src/prompts/format.ts | 5 ++--- src/prompts/portal/copilot.ts | 2 +- src/types/file/filePath.ts | 2 +- 7 files changed, 7 insertions(+), 17 deletions(-) diff --git a/src/actions/action-result.ts b/src/actions/action-result.ts index 23e671055..03f97b3f9 100644 --- a/src/actions/action-result.ts +++ b/src/actions/action-result.ts @@ -1,6 +1,5 @@ enum ResultType { Success= 0, - Warning = 2, Cancel = 130, Failure= 1 } @@ -26,10 +25,6 @@ export class ActionResult { return new ActionResult(ResultType.Cancel, " Cancelled "); } - static warning() { - return new ActionResult(ResultType.Warning, " Warning "); - } - public getMessage() { return this.message; } @@ -38,7 +33,7 @@ export class ActionResult { return this.resultType.valueOf(); } - public mapAll(onSuccess: () => T, onFailure: () => T, onCancel: () => T, onWarning: () => T): T { + public mapAll(onSuccess: () => T, onFailure: () => T, onCancel: () => T): T { switch (this.resultType) { case ResultType.Success: return onSuccess(); @@ -46,8 +41,6 @@ export class ActionResult { return onFailure(); case ResultType.Cancel: return onCancel(); - case ResultType.Warning: - return onWarning(); } } } diff --git a/src/actions/portal/copilot.ts b/src/actions/portal/copilot.ts index 9da383002..636b79be1 100644 --- a/src/actions/portal/copilot.ts +++ b/src/actions/portal/copilot.ts @@ -51,7 +51,7 @@ export class CopilotAction { ); if (response.isErr()) { - this.prompts.networkError(response.error); + this.prompts.serviceError(response.error); return ActionResult.failed(); } diff --git a/src/actions/portal/serve.ts b/src/actions/portal/serve.ts index 8161737d8..a0eccdf8c 100644 --- a/src/actions/portal/serve.ts +++ b/src/actions/portal/serve.ts @@ -1,7 +1,6 @@ import { PortalServePrompts } from "../../prompts/portal/serve.js"; import { ServeFlags, ServePaths } from "../../types/portal/serve.js"; import { ServeHandler } from "../../application/portal/serve/serve-handler.js"; -import { Result } from "../../types/common/result.js"; import { PortalService } from "../../infrastructure/services/portal-service.js"; import { DirectoryPath } from "../../types/file/directoryPath.js"; import { ActionResult } from "../action-result.js"; @@ -52,7 +51,6 @@ export class PortalServeAction { // return Result.success(`Portal was successfully served.`); }, async () => result, - async () => result, async () => result); diff --git a/src/commands/portal/copilot.ts b/src/commands/portal/copilot.ts index 965910266..15b189dd2 100644 --- a/src/commands/portal/copilot.ts +++ b/src/commands/portal/copilot.ts @@ -8,7 +8,7 @@ export default class PortalCopilot extends Command { static summary = "Configure API Copilot for your API Documentation portal"; static description = - "Displays available API Copilots associated with your account and allows you to select which one to integrate with your portal. Each APIMatic account includes one Copilot by default. The selected Copilot will be added to your APIMATIC-BUILD.json file"; + `Displays available API Copilots associated with your account and allows you to select which one to integrate with your portal. Each APIMatic account includes one Copilot by default. The selected Copilot will be added to your ${format.var("APIMATIC-BUILD.json")} file`; static flags = { ...FlagsProvider.input, diff --git a/src/prompts/format.ts b/src/prompts/format.ts index eda9618ea..385b38650 100644 --- a/src/prompts/format.ts +++ b/src/prompts/format.ts @@ -25,7 +25,7 @@ export const format = { intro: (text: string) => pc.bgCyan(text), outroSuccess: (text: string) => pc.bgGreen(text), outroFailure: (text: string) => pc.bgRed(text), - outroWarning: (text: string) => pc.bgYellow(text) + outroCancelled: (text: string) => pc.bgWhite(text) }; export function intro(text: string) { @@ -39,8 +39,7 @@ export function outro(result: ActionResult) { const outroMessage = result.mapAll( () => format.outroSuccess(message), () => format.outroFailure(message), - () => format.outroFailure(message), - () => format.outroWarning(message)); + () => format.outroCancelled(message)); o(outroMessage); process.exitCode = exitCode; } diff --git a/src/prompts/portal/copilot.ts b/src/prompts/portal/copilot.ts index a276a4f17..46fa0e4b6 100644 --- a/src/prompts/portal/copilot.ts +++ b/src/prompts/portal/copilot.ts @@ -94,7 +94,7 @@ select any programming language in the Portal and look for the chat icon in the log.warning("Exiting without making any change."); } - public networkError(serviceError: ServiceError) { + public serviceError(serviceError: ServiceError) { log.error(getErrorMessage(serviceError)); } diff --git a/src/types/file/filePath.ts b/src/types/file/filePath.ts index edd2a7a5b..a193890fa 100644 --- a/src/types/file/filePath.ts +++ b/src/types/file/filePath.ts @@ -10,7 +10,7 @@ export class FilePath { this.fileName = name; this.directoryPath = path; } - + public toString(): string { return path.join(this.directoryPath.toString() , this.fileName.toString()); } From 9de5e7e7c6d130a3171d7eb16454c5c4d74a2b43 Mon Sep 17 00:00:00 2001 From: Shield-Jaguar Date: Fri, 22 Aug 2025 15:18:04 +0500 Subject: [PATCH 12/77] feat: made code compileable --- src/actions/portal/quickstart.ts | 26 ++++++++++++++++---------- src/actions/portal/serve.ts | 5 +++-- src/commands/portal/quickstart.ts | 14 ++++++-------- 3 files changed, 25 insertions(+), 20 deletions(-) diff --git a/src/actions/portal/quickstart.ts b/src/actions/portal/quickstart.ts index a27ec9ca1..b5a6bf3ec 100644 --- a/src/actions/portal/quickstart.ts +++ b/src/actions/portal/quickstart.ts @@ -47,25 +47,27 @@ export class PortalQuickstartAction { const authenticateUserResult = await this.authenticateUser(); if (authenticateUserResult.isFailed()) { - return ActionResult.error(authenticateUserResult.error!); + // TODO: authenticateUserResult.error! + return ActionResult.failed(); } return await withDirPath(async (tempDirectory: DirectoryPath) => { // Step 1/4 const specDirectory = await this.importSpec(tempDirectory); if (specDirectory.isErr()) { - return ActionResult.error(specDirectory.error); + // TODO: specDirectory.error + return ActionResult.failed(); } // Step 2/4 const validatedSpecDirectory = await this.validateSpec(tempDirectory, specDirectory.value); if (validatedSpecDirectory.isFailed()) { - return ActionResult.error(validatedSpecDirectory.error!); + // TODO: validatedSpecDirectory.error + return ActionResult.failed(); } if (validatedSpecDirectory.isCancelled()) { - return ActionResult.cancelled( - "Good luck fixing your API definition! Feel free to run this command again once you're done." - ); + // TODO: "Good luck fixing your API definition! Feel free to run this command again once you're done." + return ActionResult.cancelled(); } // Step 3/4 @@ -81,20 +83,24 @@ export class PortalQuickstartAction { selectedLanguages ); if (buildDirectoryResult.isFailed()) { - return ActionResult.error(buildDirectoryResult.error!); + // TODO: buildDirectoryResult.error + return ActionResult.failed(); } const generateAndServePortalResult = await this.generateAndServePortal(sourceDirectory, portalDirectory); if (generateAndServePortalResult.isFailed()) { - return ActionResult.error(generateAndServePortalResult.error!); + // TODO: generateAndServePortalResult.error! + return ActionResult.failed(); } if (generateAndServePortalResult.isCancelled()) { - return ActionResult.cancelled(generateAndServePortalResult.value!); + // TODO: generateAndServePortalResult.value! + return ActionResult.cancelled(); } - return ActionResult.success(buildDirectoryResult.value!.toString()); + // TODO: buildDirectoryResult.value!.toString() + return ActionResult.success(); }); }; diff --git a/src/actions/portal/serve.ts b/src/actions/portal/serve.ts index f1fdc6378..3c1a2f700 100644 --- a/src/actions/portal/serve.ts +++ b/src/actions/portal/serve.ts @@ -52,8 +52,9 @@ export class PortalServeAction { //TODO: Figure out a better way for this. return Result.success(serverPort.toString()); }, - async (message) => Result.failure(message), - async (message) => Result.cancelled(message) + // TODO: return action result + async () => Result.failure('failure'), + async () => Result.cancelled('cancelled') ); } diff --git a/src/commands/portal/quickstart.ts b/src/commands/portal/quickstart.ts index e4b890dbb..ffd4a695a 100644 --- a/src/commands/portal/quickstart.ts +++ b/src/commands/portal/quickstart.ts @@ -1,11 +1,11 @@ import { Command } from "@oclif/core"; -import { PortalQuickstartPrompts } from "../../prompts/portal/quickstart.js"; import { PortalQuickstartAction } from "../../actions/portal/quickstart.js"; import { DirectoryPath } from "../../types/file/directoryPath.js"; import { TelemetryService } from "../../infrastructure/services/telemetry-service.js"; import { QuickstartInitiatedEvent } from "../../types/events/quickstart-initiated.js"; import { QuickstartCompletedEvent } from "../../types/events/quickstart-completed.js"; import { CommandMetadata } from "../../types/common/command-metadata.js"; +import { outro } from "../../prompts/format.js"; export default class PortalQuickstart extends Command { static description = "Create your first API Portal using APIMatic's Docs as Code offering."; @@ -14,7 +14,6 @@ export default class PortalQuickstart extends Command { async run() { const telemetryService = new TelemetryService(this.getConfigDir()); - const prompts = new PortalQuickstartPrompts(); const commandMetadata: CommandMetadata = { commandName: PortalQuickstart.id, shell: this.config.shell @@ -24,13 +23,12 @@ export default class PortalQuickstart extends Command { await telemetryService.trackEvent(new QuickstartInitiatedEvent(), commandMetadata.shell); const result = await action.execute(); + outro(result); + await result.mapAll( - async (buildDirectoryPath) => { - await telemetryService.trackEvent(new QuickstartCompletedEvent(), commandMetadata.shell); - prompts.displayOutroMessage(buildDirectoryPath!); - }, - async (message) => prompts.logError(message), - async (message) => prompts.logError(message) + async () => await telemetryService.trackEvent(new QuickstartCompletedEvent(), commandMetadata.shell), + () => new Promise(()=> { }), + () => new Promise(()=> { }), ); } From 165c64557b0cadf88b6556840a49d600dee601f0 Mon Sep 17 00:00:00 2001 From: Ali Asghar <75574550+aliasghar98@users.noreply.github.com> Date: Mon, 25 Aug 2025 14:56:34 +0500 Subject: [PATCH 13/77] feat: integrated new prompt framework in quickstart and serve commands --- src/actions/portal/quickstart.ts | 138 +++++++----------- src/actions/portal/serve.ts | 102 +++++++------ .../portal/serve/portal-watcher.ts | 20 +-- src/application/portal/serve/serve-handler.ts | 51 +++---- src/commands/portal/quickstart.ts | 14 +- src/commands/portal/serve.ts | 67 ++------- .../services/portal-scaffold-service.ts | 4 +- src/prompts/portal/common/base-prompts.ts | 25 ---- src/prompts/portal/generate.ts | 14 +- src/prompts/portal/quickstart.ts | 134 ++++++++++++++--- src/prompts/portal/serve.ts | 34 +++-- src/types/portal/serve.ts | 13 -- 12 files changed, 312 insertions(+), 304 deletions(-) delete mode 100644 src/prompts/portal/common/base-prompts.ts delete mode 100644 src/types/portal/serve.ts diff --git a/src/actions/portal/quickstart.ts b/src/actions/portal/quickstart.ts index b5a6bf3ec..d19999670 100644 --- a/src/actions/portal/quickstart.ts +++ b/src/actions/portal/quickstart.ts @@ -5,19 +5,13 @@ import { ZipService } from "../../infrastructure/zip-service.js"; import { PortalQuickstartPrompts } from "../../prompts/portal/quickstart.js"; import { DirectoryPath } from "../../types/file/directoryPath.js"; import { FilePath } from "../../types/file/filePath.js"; -import { getMessageInCyanColor, getMessageInMagentaColor, getMessageInOrangeColor } from "../../utils/utils.js"; import { UrlPath } from "../../types/file/urlPath.js"; import { PortalScaffoldService } from "../../infrastructure/services/portal-scaffold-service.js"; import { LoginAction } from "../auth/login.js"; import { Result } from "../../types/common/result.js"; import { err, ok, Result as ResultEx } from "neverthrow"; -import { PortalService } from "../../infrastructure/services/portal-service.js"; import { ActionResult } from "../action-result.js"; -import { GenerateAction } from "./generate.js"; -import { ServeFlags, ServePaths } from "../../types/portal/serve.js"; import { PortalServeAction } from "./serve.js"; -import { PortalServePrompts } from "../../prompts/portal/serve.js"; -import { ServeHandler } from "../../application/portal/serve/serve-handler.js"; import { ValidationService } from "../../infrastructure/services/validation-service.js"; import { ResourceContext } from "../../types/resource-context.js"; import { FileName } from "../../types/file/fileName.js"; @@ -43,11 +37,11 @@ export class PortalQuickstartAction { } public readonly execute = async (): Promise => { - this.prompts.displayWelcomeMessage(); + this.prompts.welcomeMessage(); const authenticateUserResult = await this.authenticateUser(); - if (authenticateUserResult.isFailed()) { - // TODO: authenticateUserResult.error! + if (authenticateUserResult.isErr()) { + this.prompts.loginFailed(); return ActionResult.failed(); } @@ -55,18 +49,18 @@ export class PortalQuickstartAction { // Step 1/4 const specDirectory = await this.importSpec(tempDirectory); if (specDirectory.isErr()) { - // TODO: specDirectory.error + this.prompts.specImportError(specDirectory.error); return ActionResult.failed(); } // Step 2/4 const validatedSpecDirectory = await this.validateSpec(tempDirectory, specDirectory.value); if (validatedSpecDirectory.isFailed()) { - // TODO: validatedSpecDirectory.error + this.prompts.specValidationError(validatedSpecDirectory.error!); return ActionResult.failed(); } if (validatedSpecDirectory.isCancelled()) { - // TODO: "Good luck fixing your API definition! Feel free to run this command again once you're done." + this.prompts.fixYourSpec(); return ActionResult.cancelled(); } @@ -82,48 +76,42 @@ export class PortalQuickstartAction { validatedSpecDirectory.value!, selectedLanguages ); - if (buildDirectoryResult.isFailed()) { - // TODO: buildDirectoryResult.error + if (buildDirectoryResult.isErr()) { + this.prompts.buildSetupError(buildDirectoryResult.error); return ActionResult.failed(); } - const generateAndServePortalResult = await this.generateAndServePortal(sourceDirectory, portalDirectory); - - if (generateAndServePortalResult.isFailed()) { - // TODO: generateAndServePortalResult.error! + const generateAndServePortalResult = await this.servePortal(sourceDirectory, portalDirectory); + if (generateAndServePortalResult.isErr()) { + this.prompts.portalGenerationError(generateAndServePortalResult.error); return ActionResult.failed(); } - if (generateAndServePortalResult.isCancelled()) { - // TODO: generateAndServePortalResult.value! - return ActionResult.cancelled(); - } - - // TODO: buildDirectoryResult.value!.toString() + this.prompts.nextSteps(sourceDirectory.toString()); return ActionResult.success(); }); }; - private async authenticateUser(): Promise> { + // TODO: LoginAction needs to be fixed to use prompts framework, then we can fix this. + private async authenticateUser(): Promise> { const storedAuth = await getAuthInfo(this.configDir.toString()); if (storedAuth?.authKey) { - return Result.success("User is already authenticated."); + return ok(); } - this.prompts.displayInfo("You need to be logged in to continue."); + this.prompts.loginRequired(); const loginResult = await new LoginAction(this.configDir, this.commandMetadata).execute(); if (loginResult.isErr()) { - this.prompts.logError(loginResult.error); - return Result.failure("Unable to login, please check your credentials and try again later."); + return err(); } - this.prompts.displaySuccess(`Logged in as: ${loginResult.value}`); - return Result.success("Authentication was successful."); + this.prompts.loginSuccessful(loginResult.value); + return ok(); } // TODO: create TempSpecContext and then refactor this. private async importSpec(tempDirectory: DirectoryPath): Promise> { - this.prompts.displayInfo(getMessageInOrangeColor(`Step 1 of 4: Import your OpenAPI Definition`)); + this.prompts.importSpecStep(); const inputPath = await this.prompts.specPathPrompt(this.defaultSpecUrl); const resourceContext = new ResourceContext(tempDirectory); @@ -134,15 +122,14 @@ export class PortalQuickstartAction { return ok(result.value); } + // TODO: Needs to be refactored after refactoring validate action. private async validateSpec( tempDirectory: DirectoryPath, specDirectory: DirectoryPath ): Promise> { - this.prompts.displayInfo(getMessageInOrangeColor(`Step 2 of 4: Validate and Lint your OpenAPI file`)); + this.prompts.validateSpecStep(); this.prompts.startProgressIndicator( - getMessageInMagentaColor( - `Running your API Definition through APIMatic's 1200+ CodeGen Specific validation and linting rules 🔍 ` - ) + `Running your API Definition through APIMatic's 1200+ CodeGen Specific validation and linting rules` ); //TODO: Replace with validate action. @@ -158,11 +145,11 @@ export class PortalQuickstartAction { const validationPassed = validationResult.value!.success; if (validationPassed) { - this.prompts.stopProgressIndicator(getMessageInCyanColor(`Validation Successful.`)); + this.prompts.stopProgressIndicator(`Validation Successful.`); return Result.success(specDirectory); } - this.prompts.stopProgressIndicator(`❗ Oops, it looks like there are some errors in your API Definition.`, 1); + this.prompts.stopProgressIndicator(`Oops, it looks like there are some errors in your API Definition.`, 1); if (!(await this.prompts.useDefaultSpecPrompt())) { return Result.cancelled(specDirectory); } @@ -177,13 +164,13 @@ export class PortalQuickstartAction { } private async selectLanguages(): Promise { - this.prompts.displayInfo(getMessageInOrangeColor(`Step 3 of 4: Select programming languages`)); + this.prompts.selectLanguagesStep(); return await this.prompts.selectLanguagesPrompt(); } private async selectInputDirectory(): Promise<{ sourceDirectory: DirectoryPath; portalDirectory: DirectoryPath }> { - this.prompts.displayInfo(getMessageInOrangeColor(`Step 4 of 4: Generate source files for Docs as Code`)); + this.prompts.selectInputDirectoryStep(); const workingDirectory = new DirectoryPath(await this.prompts.inputDirectoryPathPrompt()); @@ -196,58 +183,39 @@ export class PortalQuickstartAction { sourceDirectory: DirectoryPath, specDirectory: DirectoryPath, selectedLanguages: string[] - ): Promise> { - this.prompts.startProgressIndicator(getMessageInMagentaColor("Generating build directory ⚙️")); - - //TODO: Create a separate temp directory for scaffolding, don't pass the existing temp directory. - const createBuildDirectoryResult = await this.portalScaffoldService.createBuildDirectory( - tempDirectory, - specDirectory, - selectedLanguages + ): Promise> { + const result = await this.prompts.createBuildDirectory( + sourceDirectory.toString(), + this.portalScaffoldService.createBuildDirectory(tempDirectory, specDirectory, selectedLanguages) ); - if (createBuildDirectoryResult.isErr()) { - this.prompts.stopProgressIndicator(`Something went wrong while setting up your build directory.`, 1); - return Result.failure(createBuildDirectoryResult.error); + + if (result.isErr()) { + return err(result.error); } - await this.fileService.copyDirectoryContents(createBuildDirectoryResult.value!, sourceDirectory); - this.prompts.stopProgressIndicator(getMessageInCyanColor(`📁 Directory created at ${sourceDirectory.toString()}`)); + await this.fileService.copyDirectoryContents(result.value, sourceDirectory); this.prompts.displayBuildDirectoryAsTree(sourceDirectory.toString()); - return Result.success(sourceDirectory); + return ok(result.value); } - private async generateAndServePortal( - sourceDirectory: DirectoryPath, + private async servePortal( + buildDirectory: DirectoryPath, portalDirectory: DirectoryPath - ): Promise> { - const generatePortalAction = new GenerateAction(this.configDir, this.commandMetadata); - const portalServeAction = new PortalServeAction(new PortalServePrompts(), new ServeHandler(), new PortalService()); - - const serveFlags: ServeFlags = { - input: sourceDirectory.toString(), - destination: portalDirectory.toString(), - port: this.defaultPort, - open: true, - "no-reload": false, - "auth-key": undefined - }; - - const servePaths: ServePaths = { - sourceDirectoryPath: sourceDirectory.toString(), - destinationDirectoryPath: portalDirectory.toString() - }; - - const servePortalResult = await portalServeAction.servePortal(serveFlags, servePaths, generatePortalAction.execute); - - if (servePortalResult.isFailed()) { - return Result.failure(servePortalResult.error!); - } - - if (servePortalResult.isCancelled()) { - return Result.cancelled(servePortalResult.value!); - } - - return Result.success("Generated portal and served it successfully."); + ): Promise> { + const portalServeAction = new PortalServeAction(this.configDir, this.commandMetadata, null, this.defaultPort); + const result = await portalServeAction.servePortal(buildDirectory, portalDirectory, true, false, false); + + return result.mapAll>( + () => { + return ok("Generated portal and served it successfully."); + }, + () => { + return err(result.getMessage()); + }, + () => { + return err(result.getMessage()); + } + ); } } diff --git a/src/actions/portal/serve.ts b/src/actions/portal/serve.ts index 3c1a2f700..2c37f2213 100644 --- a/src/actions/portal/serve.ts +++ b/src/actions/portal/serve.ts @@ -1,60 +1,80 @@ +import getPort from "get-port"; import { PortalServePrompts } from "../../prompts/portal/serve.js"; -import { ServeFlags, ServePaths } from "../../types/portal/serve.js"; import { ServeHandler } from "../../application/portal/serve/serve-handler.js"; -import { Result } from "../../types/common/result.js"; -import { PortalService } from "../../infrastructure/services/portal-service.js"; import { DirectoryPath } from "../../types/file/directoryPath.js"; import { ActionResult } from "../action-result.js"; -import getPort from "get-port"; +import { CommandMetadata } from "../../types/common/command-metadata.js"; +import { GenerateAction } from "./generate.js"; export class PortalServeAction { - protected readonly prompts: PortalServePrompts; - protected readonly serveHandler: ServeHandler; - protected readonly docsPortalService: PortalService; + private readonly prompts: PortalServePrompts = new PortalServePrompts(); + private readonly serveHandler: ServeHandler = new ServeHandler(); + private readonly configDir: DirectoryPath; + private readonly commandMetadata: CommandMetadata; + private readonly authKey: string | null; + private readonly port: number | undefined; - public constructor(prompts: PortalServePrompts, serveHandler: ServeHandler, docsPortalService: PortalService) { - this.prompts = prompts; - this.serveHandler = serveHandler; - this.docsPortalService = docsPortalService; + public constructor( + configDir: DirectoryPath, + commandMetadata: CommandMetadata, + authKey: string | null = null, + port: number | undefined = undefined + ) { + this.configDir = configDir; + this.commandMetadata = commandMetadata; + this.authKey = authKey; + this.port = port; } public async servePortal( - flags: ServeFlags, - paths: ServePaths, - generatePortal: ( - buildDirectory: DirectoryPath, - portalDirectory: DirectoryPath, - force: boolean, - zipPortal: boolean - ) => Promise - ): Promise> { - const serverPort: number = await this.getServerPort(flags.port); + buildDirectory: DirectoryPath, + portalDirectory: DirectoryPath, + openInBrowser: boolean, + noReload: boolean, + displayServeCommandMessages: boolean = true + ): Promise { + const serverPort: number = await this.getServerPort(this.port); + const generatePortalAction = new GenerateAction(this.configDir, this.commandMetadata, this.authKey); - const result = await generatePortal( - new DirectoryPath(paths.sourceDirectoryPath), - new DirectoryPath(paths.destinationDirectoryPath), - false, - false - ); - - return await result.mapAll>>( + const result = await generatePortalAction.execute(buildDirectory, portalDirectory, false, false); + return result.mapAll>( async () => { - const setupServerResult = await this.serveHandler.setupServer(paths.destinationDirectoryPath); - if (setupServerResult.isFailed()) { - return Result.failure(setupServerResult.error!); + const setupServerResult = await this.serveHandler.setupServer(portalDirectory); + if (setupServerResult.isErr()) { + this.prompts.setupServerError(setupServerResult.error); + return ActionResult.failed(); } - const startServerResult = await this.serveHandler.startServer(paths, flags, generatePortal, serverPort); - if (startServerResult.isFailed()) { - return Result.failure(startServerResult.error!); + const startServerResult = await this.serveHandler.startServer( + buildDirectory, + portalDirectory, + generatePortalAction.execute, + serverPort, + openInBrowser, + noReload + ); + if (startServerResult.isErr()) { + this.prompts.startServerError(startServerResult.error); + return ActionResult.failed(); } - //TODO: Figure out a better way for this. - return Result.success(serverPort.toString()); + if (displayServeCommandMessages) { + this.prompts.nextSteps( + buildDirectory.toString(), + portalDirectory.toString(), + serverPort.toString(), + noReload + ); + } + + return ActionResult.success(); + }, + async () => { + return ActionResult.failed(); }, - // TODO: return action result - async () => Result.failure('failure'), - async () => Result.cancelled('cancelled') + async () => { + return ActionResult.cancelled(); + } ); } @@ -67,7 +87,7 @@ export class PortalServeAction { // Show warning only if user provided --port and it is not available if (typeof port === "number" && availablePort !== port) { - this.prompts.displayInfo(`⚠️ Port ${port} is already in use. Available port ${availablePort} will be used.`); + this.prompts.portAlreadyInUse(port, availablePort); } return availablePort; diff --git a/src/application/portal/serve/portal-watcher.ts b/src/application/portal/serve/portal-watcher.ts index 225be11ce..393588115 100644 --- a/src/application/portal/serve/portal-watcher.ts +++ b/src/application/portal/serve/portal-watcher.ts @@ -2,7 +2,6 @@ import chokidar from "chokidar"; import crypto from "crypto"; import console from "console"; import { Mutex } from "async-mutex"; -import { ServePaths } from "../../../types/portal/serve.js"; import { WatcherHandler } from "./watcher-handler.js"; import { DirectoryPath } from "../../../types/file/directoryPath.js"; import { ActionResult } from "../../../actions/action-result.js"; @@ -15,7 +14,8 @@ export class PortalWatcher { } public async watchAndRegeneratePortalOnChange( - paths: ServePaths, + buildDirectory: DirectoryPath, + portalDirectory: DirectoryPath, generatePortal: ( buildDirectory: DirectoryPath, portalDirectory: DirectoryPath, @@ -24,7 +24,7 @@ export class PortalWatcher { ) => Promise ) { //Regex matches any hidden files and folders. - const watcher = chokidar.watch(paths.sourceDirectoryPath, { + const watcher = chokidar.watch(buildDirectory.toString(), { ignored: [/(^|[/\\])\..+/], ignoreInitial: true, persistent: true, @@ -58,7 +58,7 @@ export class PortalWatcher { }); await this.watcherHandler.execute(async () => { - await this.handleFileChange(paths, eventQueue, eventId, generatePortal); + await this.handleFileChange(buildDirectory, portalDirectory, eventQueue, eventId, generatePortal); }); }) .on("error", () => { @@ -79,7 +79,8 @@ export class PortalWatcher { } protected async handleFileChange( - paths: ServePaths, + buildDirectory: DirectoryPath, + portalDirectory: DirectoryPath, eventQueue: Map, eventId: string, generatePortal: ( @@ -92,13 +93,6 @@ export class PortalWatcher { if (!eventQueue.has(eventId)) { return; } - const result = await generatePortal( - new DirectoryPath(paths.sourceDirectoryPath), - new DirectoryPath(paths.destinationDirectoryPath), - true, - false - ); - - //result.map((error) => console.log(error)); + await generatePortal(buildDirectory, portalDirectory, true, false); } } diff --git a/src/application/portal/serve/serve-handler.ts b/src/application/portal/serve/serve-handler.ts index dcdf36755..dffab2d96 100644 --- a/src/application/portal/serve/serve-handler.ts +++ b/src/application/portal/serve/serve-handler.ts @@ -6,8 +6,7 @@ import process from "process"; import getPort from "get-port"; import console from "console"; import { Server } from "http"; -import { Result } from "../../../types/common/result.js"; -import { ServeFlags, ServePaths } from "../../../types/portal/serve.js"; +import { ok, err, Result } from "neverthrow"; import { PortalWatcher } from "./portal-watcher.js"; import { DirectoryPath } from "../../../types/file/directoryPath.js"; import { ActionResult } from "../../../actions/action-result.js"; @@ -16,29 +15,28 @@ export class ServeHandler { private server!: Server; private liveReloadServer!: livereload.LiveReloadServer; private readonly app: express.Application; - private readonly portalWatcher: PortalWatcher; + private readonly portalWatcher: PortalWatcher = new PortalWatcher(); constructor() { this.app = express(); - this.portalWatcher = new PortalWatcher(); } //TODO: Needs to be refactored after refactoring quickstart. - public async setupServer(generatedPortalArtifactsDirectoryPath: string): Promise> { - const createLiveReloadServerResult = await this.createLiveReloadServer(generatedPortalArtifactsDirectoryPath); - if (createLiveReloadServerResult.isFailed()) { - return Result.failure(createLiveReloadServerResult.error!); + public async setupServer(portalDirectory: DirectoryPath): Promise> { + const createLiveReloadServerResult = await this.createLiveReloadServer(portalDirectory); + if (createLiveReloadServerResult.isErr()) { + return err(createLiveReloadServerResult.error); } - this.app.use(express.static(generatedPortalArtifactsDirectoryPath, { extensions: ["html"] })); + this.app.use(express.static(portalDirectory.toString(), { extensions: ["html"] })); - return Result.success(`Server is set up and serving files from ${generatedPortalArtifactsDirectoryPath}`); + return ok(`Server is set up and serving files from ${portalDirectory.toString()}`); } //TODO: Needs to be refactored after refactoring quickstart. public async startServer( - paths: ServePaths, - flags: ServeFlags, + buildDirectory: DirectoryPath, + portalDirectory: DirectoryPath, generatePortal: ( buildDirectory: DirectoryPath, portalDirectory: DirectoryPath, @@ -46,17 +44,18 @@ export class ServeHandler { zipPortal: boolean ) => Promise, serverPort: number, - displayShutdownMessages = true + openInBrowser: boolean, + noReload: boolean ): Promise> { return new Promise>((resolve, reject) => { this.server = this.app .listen(serverPort, async () => { - if (flags.open) { + if (openInBrowser) { await open(`http://localhost:${serverPort}`); } - if (!flags["no-reload"]) { - await this.portalWatcher.watchAndRegeneratePortalOnChange(paths, generatePortal); + if (!noReload) { + await this.portalWatcher.watchAndRegeneratePortalOnChange(buildDirectory, portalDirectory, generatePortal); } if (process.platform !== "darwin") { @@ -65,7 +64,7 @@ export class ServeHandler { process.stdin.setRawMode(false); } } - resolve(Result.success(true)); + resolve(ok(true)); }) .on("error", () => { reject( @@ -76,14 +75,10 @@ export class ServeHandler { }); const shutdown = async () => { - if (displayShutdownMessages) { - console.log("Shutting down server..."); - } + console.log("Shutting down server..."); await this.stopServer(); - if (displayShutdownMessages) { - console.log("Server shut down successfully."); - } - resolve(Result.success(true)); + console.log("Server shut down successfully."); + resolve(ok(true)); process.exit(0); }; @@ -105,19 +100,19 @@ export class ServeHandler { } } - private async createLiveReloadServer(generatedPortalPath: string): Promise> { + private async createLiveReloadServer(portalDirectory: DirectoryPath): Promise> { try { const availablePort = await this.getPortForReloadServer(); this.liveReloadServer = livereload.createServer({ port: availablePort }); - this.liveReloadServer.watch(generatedPortalPath); + this.liveReloadServer.watch(portalDirectory.toString()); this.app.use(connectLivereload()); - return Result.success("Live Reload Server setup successfully."); + return ok("Live Reload Server setup successfully."); } catch { - return Result.failure( + return err( "An unexpected error occurred while serving your portal, please try again later. If the issue persists, contact our team at support@apimatic.io for assistance." ); } diff --git a/src/commands/portal/quickstart.ts b/src/commands/portal/quickstart.ts index ffd4a695a..5f24674bd 100644 --- a/src/commands/portal/quickstart.ts +++ b/src/commands/portal/quickstart.ts @@ -5,12 +5,14 @@ import { TelemetryService } from "../../infrastructure/services/telemetry-servic import { QuickstartInitiatedEvent } from "../../types/events/quickstart-initiated.js"; import { QuickstartCompletedEvent } from "../../types/events/quickstart-completed.js"; import { CommandMetadata } from "../../types/common/command-metadata.js"; -import { outro } from "../../prompts/format.js"; +import { format, intro, outro } from "../../prompts/format.js"; export default class PortalQuickstart extends Command { static description = "Create your first API Portal using APIMatic's Docs as Code offering."; - static examples = ["apimatic portal quickstart"]; + static cmdTxt = format.cmd("apimatic", "portal", "quickstart"); + + static examples = [this.cmdTxt]; async run() { const telemetryService = new TelemetryService(this.getConfigDir()); @@ -22,13 +24,15 @@ export default class PortalQuickstart extends Command { await telemetryService.trackEvent(new QuickstartInitiatedEvent(), commandMetadata.shell); + intro("Portal Quickstart"); const result = await action.execute(); outro(result); + // TODO: Remove this, find a solution for tracking. await result.mapAll( - async () => await telemetryService.trackEvent(new QuickstartCompletedEvent(), commandMetadata.shell), - () => new Promise(()=> { }), - () => new Promise(()=> { }), + async () => await telemetryService.trackEvent(new QuickstartCompletedEvent(), commandMetadata.shell), + () => new Promise(() => {}), + () => new Promise(() => {}) ); } diff --git a/src/commands/portal/serve.ts b/src/commands/portal/serve.ts index cb9bb8efa..3971ff160 100644 --- a/src/commands/portal/serve.ts +++ b/src/commands/portal/serve.ts @@ -1,14 +1,9 @@ -import { Command, Config, Flags } from "@oclif/core"; -import { PortalServePrompts } from "../../prompts/portal/serve.js"; -import { ServeFlags, ServePaths } from "../../types/portal/serve.js"; +import { Command, Flags } from "@oclif/core"; import { PortalServeAction } from "../../actions/portal/serve.js"; -import { getMessageInRedColor } from "../../utils/utils.js"; -import { ServeHandler } from "../../application/portal/serve/serve-handler.js"; -import { PortalService } from "../../infrastructure/services/portal-service.js"; -import { GenerateAction } from "../../actions/portal/generate.js"; import { DirectoryPath } from "../../types/file/directoryPath.js"; import { FlagsProvider } from "../../types/flags-provider.js"; import { CommandMetadata } from "../../types/common/command-metadata.js"; +import { intro, outro } from "../../prompts/format.js"; const DEFAULT_WORKING_DIRECTORY = "./"; @@ -34,66 +29,28 @@ export default class PortalServe extends Command { ...FlagsProvider.authKey }; - private readonly prompts: PortalServePrompts; - - constructor(argv: string[], config: Config) { - super(argv, config); - this.prompts = new PortalServePrompts(); - } - static examples = [ "apimatic portal serve", 'apimatic portal serve --input="./" --destination="./portal" --port=3000 --open --no-reload' ]; public async run() { - const { flags } = await this.parse(PortalServe); - const portalServePrompts = new PortalServePrompts(); - const portalServeAction = new PortalServeAction(portalServePrompts, new ServeHandler(), new PortalService()); - - const workingDirectory = new DirectoryPath(flags.input ?? DEFAULT_WORKING_DIRECTORY); - const buildDirectory = flags.input ? new DirectoryPath(flags.input, "src") : workingDirectory.join("src"); - const portalDirectory = flags.destination ? new DirectoryPath(flags.destination) : workingDirectory.join("portal"); + const { + flags: { input, destination, port, open, "no-reload": noReload, "auth-key": authKey } + } = await this.parse(PortalServe); + const workingDirectory = new DirectoryPath(input ?? DEFAULT_WORKING_DIRECTORY); + const buildDirectory = input ? new DirectoryPath(input, "src") : workingDirectory.join("src"); + const portalDirectory = destination ? new DirectoryPath(destination) : workingDirectory.join("portal"); const commandMetadata: CommandMetadata = { commandName: PortalServe.id, shell: this.config.shell }; - const generatePortalAction = new GenerateAction(this.getConfigDir(), commandMetadata, flags["auth-key"]); - - const serveFlags: ServeFlags = { - input: buildDirectory.toString(), - destination: portalDirectory.toString(), - "auth-key": flags["auth-key"], - port: flags.port, - open: flags.open, - "no-reload": flags["no-reload"] - }; - - const servePaths: ServePaths = { - sourceDirectoryPath: buildDirectory.toString(), - destinationDirectoryPath: portalDirectory.toString() - }; - - const servePortalResult = await portalServeAction.servePortal(serveFlags, servePaths, generatePortalAction.execute); - //TODO: Convert below statements to result.mapAll after changing servePortalResult to ActionResult. - if (servePortalResult.isFailed()) { - portalServePrompts.logError(getMessageInRedColor(servePortalResult.error!)); - } - - if (servePortalResult.isCancelled()) { - portalServePrompts.logError(getMessageInRedColor(servePortalResult.value!)); - } - - if (servePortalResult.isSuccess()) { - this.prompts.displayOutroMessage( - buildDirectory.toString(), - portalDirectory.toString(), - servePortalResult.value!, - flags["no-reload"] - ); - } + intro("Portal Serve"); + const portalServeAction = new PortalServeAction(this.getConfigDir(), commandMetadata, authKey, port); + const result = await portalServeAction.servePortal(buildDirectory, portalDirectory, open, noReload); + outro(result); } private getConfigDir(): DirectoryPath { diff --git a/src/infrastructure/services/portal-scaffold-service.ts b/src/infrastructure/services/portal-scaffold-service.ts index 134fc0534..237acfef9 100644 --- a/src/infrastructure/services/portal-scaffold-service.ts +++ b/src/infrastructure/services/portal-scaffold-service.ts @@ -21,7 +21,7 @@ export class PortalScaffoldService { const resourceContext = new ResourceContext(tempDirectory); const result = await resourceContext.resolveTo(this.zipUrl, this.repositoryFolderName); if (result.isErr()) { - return err("Unable to setup the portal, please try again later."); + return err("Unable to setup the build directory, please try again later."); } const extractedFolder = result.value.join(this.repositoryFolderName); await this.fileService.deleteDirectory(extractedFolder.join(".github")); @@ -43,7 +43,7 @@ export class PortalScaffoldService { return ok(extractedFolder); } catch { return err( - "There was an error setting up your portal. Please try again later. If the problem persists, please reach out to our team at support@apimatic.io " + "There was an error setting up your build directory. Please try again later. If the problem persists, please reach out to our team at support@apimatic.io " ); } } diff --git a/src/prompts/portal/common/base-prompts.ts b/src/prompts/portal/common/base-prompts.ts deleted file mode 100644 index b0e374ab5..000000000 --- a/src/prompts/portal/common/base-prompts.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { log, spinner } from "@clack/prompts"; - -export abstract class BasePrompts { - protected readonly spin = spinner(); - - public startProgressIndicator(message: string): void { - this.spin.start(message); - } - - public stopProgressIndicator(message: string, statusCode?: number): void { - this.spin.stop(message, statusCode); - } - - public displayInfo(message: string): void { - log.step(message); - } - - public displaySuccess(message: string): void { - log.success(message); - } - - public logError(errorMessage: string): void { - log.error(errorMessage); - } -} diff --git a/src/prompts/portal/generate.ts b/src/prompts/portal/generate.ts index fe2d65c47..cc0410710 100644 --- a/src/prompts/portal/generate.ts +++ b/src/prompts/portal/generate.ts @@ -36,16 +36,26 @@ export class PortalGeneratePrompts { } public generatePortal(fn: Promise>) { - return withSpinner("Generating portal", "Portal generated successfully.", "Portal Generation failed.", fn); + var result = withSpinner("Generating portal", "Portal generated successfully.", "Portal Generation failed.", fn); + this.cleanUpStandardInput(); + return result; } public portalGenerationError(error: string) { log.error(error); } - portalGenerationErrorWithReport(reportPath: FilePath) { + public portalGenerationErrorWithReport(reportPath: FilePath) { const message = `An error occurred during portal generation due to an issue with the input. An error report has been written at the destination path: ${f.path(reportPath.toString())}`; log.error(message); } + + // This clears the standard input to allow interrupts like CTRL+C to work properly. + private cleanUpStandardInput(): void { + if (process.stdin.isTTY) { + process.stdin.setRawMode(false); + process.stdin.pause(); + } + } } diff --git a/src/prompts/portal/quickstart.ts b/src/prompts/portal/quickstart.ts index 43d5d67be..ad3a0cb2d 100644 --- a/src/prompts/portal/quickstart.ts +++ b/src/prompts/portal/quickstart.ts @@ -2,13 +2,15 @@ import * as path from "path"; import fs from "fs"; import axios from "axios"; import treeify from "treeify"; -import { intro, outro, text, select, multiselect, log, isCancel, cancel } from "@clack/prompts"; -import { getMessageInCyanColor, getMessageInGreenColor, getMessageInRedColor } from "../../utils/utils.js"; -import { BasePrompts } from "./common/base-prompts.js"; +import { text, select, multiselect, log, isCancel, cancel, spinner } from "@clack/prompts"; +import { getMessageInGreenColor, getMessageInRedColor } from "../../utils/utils.js"; import { DirectoryNode } from "../../types/portal/quickstart.js"; import { UrlPath } from "../../types/file/urlPath.js"; +import { withSpinner } from "../format.js"; +import { DirectoryPath } from "../../types/file/directoryPath.js"; +import { Result } from "neverthrow"; -export class PortalQuickstartPrompts extends BasePrompts { +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"; @@ -27,12 +29,35 @@ export class PortalQuickstartPrompts extends BasePrompts { return acc; }, {} as { [key: string]: string }); - public displayWelcomeMessage(): void { - intro(`Hello there 👋`); + // TODO: Remove after refactoring validate action. + private readonly spin = spinner(); + + public welcomeMessage() { + log.message(`Hello there.`); log.message( `This wizard will help you set up an API Portal via APIMatic's Docs as Code workflow in 4 simple steps.` ); - log.message(`Let's get started! 🚀`); + log.message(`Let's get started!`); + } + + public loginRequired() { + const message = `You need to be logged in to continue.`; + log.step(message); + } + + public loginFailed() { + const message = `Unable to login, please check your credentials and try again later.`; + log.error(message); + } + + public loginSuccessful(email: string) { + const message = `Logged in as: ${email}`; + log.step(message); + } + + public importSpecStep() { + const message = `Step 1 of 4: Import your OpenAPI Definition`; + log.step(message); } //TODO: Very complex validation, needs to be improved. @@ -77,11 +102,11 @@ export class PortalQuickstartPrompts extends BasePrompts { const contentType = response.headers["content-type"]; if (contentType?.includes("text/html")) { - this.logError(`Invalid URL. Please check the URL and ensure it points to a valid OpenAPI definition.`); + log.error(`Invalid URL. Please check the URL and ensure it points to a valid OpenAPI definition.`); continue; // re-prompt } } catch { - this.logError(`Failed to reach the URL. Please check your internet connection or the URL.`); + log.error(`Failed to reach the URL. Please check your internet connection or the URL.`); continue; // re-prompt } } @@ -90,6 +115,14 @@ export class PortalQuickstartPrompts extends BasePrompts { } } + public specImportError(error: string) { + log.error(error); + } + + public specValidationError(error: string) { + log.error(error); + } + public async useDefaultSpecPrompt(): Promise { const useDefaultSpec = await select({ message: `How would you like to proceed?`, @@ -110,10 +143,45 @@ export class PortalQuickstartPrompts extends BasePrompts { return useDefaultSpec === "yes"; } - async selectLanguagesPrompt(): Promise { + public fixYourSpec() { + const message = `Good luck fixing your API definition! Feel free to run this command again once you're done.`; + log.step(message); + } + + public validateSpecStep() { + const message = `Step 2 of 4: Validate and Lint your OpenAPI file`; + log.step(message); + } + + // TODO: Add once you have refactored the validate action. + public validateSpec(fn: Promise>) { + return withSpinner( + "Running your API Definition through APIMatic's 1200+ CodeGen Specific validation and linting rules", + "Validation Successful.", + "Something went wrong while validating your API Definition.", + fn + ); + } + + // TODO: Remove after refactoring validate action. + public startProgressIndicator(message: string) { + this.spin.start(message); + } + + // TODO: Remove after refactoring validate action. + public stopProgressIndicator(message: string, statusCode?: number) { + this.spin.stop(message, statusCode); + } + + public selectLanguagesStep() { + const message = `Step 3 of 4: Select programming languages`; + log.step(message); + } + + public async selectLanguagesPrompt(): Promise { const languages = (await multiselect({ message: - "💻 Your API Portal will contain SDKs and SDK Documentation in the following Languages. Press enter to continue with all languages, or use the arrow keys and spacebar to customize your selection:", + "Your API Portal will contain SDKs and SDK Documentation in the following Languages. Press enter to continue with all languages, or use the arrow keys and spacebar to customize your selection:", options: [ { label: "Typescript", value: "typescript" }, { label: "Ruby", value: "ruby" }, @@ -134,7 +202,12 @@ export class PortalQuickstartPrompts extends BasePrompts { return ["http", ...languages]; } - async inputDirectoryPathPrompt(): Promise { + public selectInputDirectoryStep() { + const message = `Step 4 of 4: Generate source files for Docs as Code`; + log.step(message); + } + + public async inputDirectoryPathPrompt(): Promise { const directory = await text({ 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.", @@ -178,6 +251,19 @@ export class PortalQuickstartPrompts extends BasePrompts { } } + public createBuildDirectory(sourceDirectory: string, fn: Promise>) { + return withSpinner( + "Generating build directory", + `Directory created at ${sourceDirectory}`, + "Something went wrong while setting up your build directory.", + fn + ); + } + + public buildSetupError(message: string) { + log.error(message); + } + public displayBuildDirectoryAsTree(buildDirectory: string): void { const structuredBuildDirectory = this.convertDirectoryStructureToJson(buildDirectory) as treeify.TreeObject; @@ -191,20 +277,20 @@ export class PortalQuickstartPrompts extends BasePrompts { log.step(coloredLogString); } - public displayOutroMessage(buildDirectory: string): void { + public portalGenerationError(error: string) { + log.error(error); + } + + public nextSteps(buildDirectory: string): void { log.step( - getMessageInCyanColor(`📢 Your API Portal is live at: ${this.serverUrl}\n`) + - getMessageInCyanColor( - `Hot reload enabled! Edit files in ${buildDirectory} to see changes instantly reflected in your API Portal.\n` - ) + - getMessageInCyanColor(`Press CTRL+C to stop the server.`) + `Your API Portal is live at: ${this.serverUrl}\n` + + `Hot reload enabled! Edit files in ${buildDirectory} to see changes instantly reflected in your API Portal.\n` + + `Press CTRL+C to stop the server.` ); - outro( - getMessageInCyanColor(`What's next?\n`) + - getMessageInCyanColor(`- Use the API Playground or an SDK to call your API.\n`) + - getMessageInCyanColor( - `- Customize the Portal theme, add API recipes and enable AI features: ${this.referenceDocumentationUrl}` - ) + log.step( + `What's next?\n` + + `- Use the API Playground or an SDK to call your API.\n` + + `- Customize the Portal theme, add API recipes and enable AI features: ${this.referenceDocumentationUrl}` ); } diff --git a/src/prompts/portal/serve.ts b/src/prompts/portal/serve.ts index d5d6305a4..b97c102f0 100644 --- a/src/prompts/portal/serve.ts +++ b/src/prompts/portal/serve.ts @@ -1,19 +1,31 @@ -import { log, outro } from "@clack/prompts"; -import { BasePrompts } from "./common/base-prompts.js"; +import { log } from "@clack/prompts"; -export class PortalServePrompts extends BasePrompts { - public displayOutroMessage( - buildDirectory: string, - portalDirectory: string, - port: string, - hotReloadDisabled: boolean - ): void { +export class PortalServePrompts { + // TODO: Remove this method. + public logError(message: string) { + log.error(message); + } + + public startServerError(message: string) { + log.error(message); + } + + public setupServerError(message: string) { + log.error(message); + } + + public portAlreadyInUse(currentPort: number, availablePort: number) { + const message = `Port ${currentPort} is already in use. Available port ${availablePort} will be used.`; + log.step(message); + } + + public nextSteps(buildDirectory: string, portalDirectory: string, port: string, hotReloadDisabled: boolean): void { log.message(`The generated portal can be found at ${portalDirectory}`); log.message(`Server started at http://localhost:${port}`); if (!hotReloadDisabled) { - log.message(`🔍 Hot reload enabled. Watching the following build folder for any changes:`); + log.message(`Hot reload enabled. Watching the following build folder for any changes:`); log.message(`${buildDirectory}`); } - outro(`Press CTRL+C to stop the server.`); + log.message(`Press CTRL+C to stop the server.`); } } diff --git a/src/types/portal/serve.ts b/src/types/portal/serve.ts deleted file mode 100644 index 0adb0d56f..000000000 --- a/src/types/portal/serve.ts +++ /dev/null @@ -1,13 +0,0 @@ -export interface ServeFlags { - readonly port: number | undefined, - readonly input: string; - readonly destination: string; - readonly open: boolean; - readonly "auth-key": string | undefined; - readonly "no-reload": boolean; -} - -export interface ServePaths { - readonly sourceDirectoryPath: string; - readonly destinationDirectoryPath: string; -} From f73e38bb2541596f5e59cd9be1f85aa9490171c5 Mon Sep 17 00:00:00 2001 From: Ali Asghar <75574550+aliasghar98@users.noreply.github.com> Date: Mon, 25 Aug 2025 14:58:32 +0500 Subject: [PATCH 14/77] refactor: removed usage of var --- src/prompts/portal/generate.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/prompts/portal/generate.ts b/src/prompts/portal/generate.ts index cc0410710..9e15d43a6 100644 --- a/src/prompts/portal/generate.ts +++ b/src/prompts/portal/generate.ts @@ -36,7 +36,7 @@ export class PortalGeneratePrompts { } public generatePortal(fn: Promise>) { - var result = withSpinner("Generating portal", "Portal generated successfully.", "Portal Generation failed.", fn); + const result = withSpinner("Generating portal", "Portal generated successfully.", "Portal Generation failed.", fn); this.cleanUpStandardInput(); return result; } From 03c0e41ae1c107ec4dc621efcde5eae95ace5445 Mon Sep 17 00:00:00 2001 From: Muhammad Sohail <62895181+sohail2721@users.noreply.github.com> Date: Tue, 26 Aug 2025 13:55:17 +0500 Subject: [PATCH 15/77] feat: implement prompt framework validate/transform (#183) --- src/actions/api/transform.ts | 108 +++++------------- src/actions/api/validate.ts | 79 ++++++------- src/actions/portal/quickstart.ts | 14 +-- src/commands/api/transform.ts | 60 +++++++--- src/commands/api/validate.ts | 35 +++--- src/controllers/api/transform.ts | 77 ------------- src/infrastructure/api-utils.ts | 30 +---- src/infrastructure/file-service.ts | 4 + .../services/portal-scaffold-service.ts | 11 +- ...m-service.ts => transformation-service.ts} | 52 +-------- .../services/validation-service.ts | 45 ++------ src/prompts/api/transform.ts | 41 +++++-- src/prompts/api/validate.ts | 55 +++++---- src/prompts/sdk/generate.ts | 6 +- src/types/file/fileName.ts | 2 +- src/types/file/filePath.ts | 4 +- src/types/file/resource-input.ts | 31 +++++ src/types/file/urlPath.ts | 4 +- src/types/resource-context.ts | 31 ++--- src/types/transform-context.ts | 43 +++++++ 20 files changed, 309 insertions(+), 423 deletions(-) delete mode 100644 src/controllers/api/transform.ts rename src/infrastructure/services/{transform-service.ts => transformation-service.ts} (65%) create mode 100644 src/types/file/resource-input.ts create mode 100644 src/types/transform-context.ts diff --git a/src/actions/api/transform.ts b/src/actions/api/transform.ts index 074e7d316..82e68dcab 100644 --- a/src/actions/api/transform.ts +++ b/src/actions/api/transform.ts @@ -1,31 +1,20 @@ -import fsExtra from "fs-extra"; import { DirectoryPath } from "../../types/file/directoryPath.js"; import { ActionResult } from "../action-result.js"; import { ApiTransformPrompts } from "../../prompts/api/transform.js"; -import { DestinationFormats } from "../../types/api/transform.js"; -import { getFileNameFromPath } from "../../utils/utils.js"; -import { FileService } from "../../infrastructure/file-service.js"; -import { validateFileInputParams } from "../../infrastructure/api-utils.js"; import { withDirPath } from "../../infrastructure/tmp-extensions.js"; -import { TransformationService } from "../../infrastructure/services/transform-service.js"; -import { FilePath } from "../../types/file/filePath.js"; -import { FileName } from "../../types/file/fileName.js"; -import { Result } from "../../types/common/result.js"; -import { ApiValidationSummary } from "@apimatic/sdk"; +import { TransformationService } from "../../infrastructure/services/transformation-service.js"; +import { ExportFormats } from "@apimatic/sdk"; import { ApiValidatePrompts } from "../../prompts/api/validate.js"; import { CommandMetadata } from "../../types/common/command-metadata.js"; -import { getValidFormat } from "../../controllers/api/transform.js"; +import { TransformContext } from "../../types/transform-context.js"; +import { ResourceInput } from "../../types/file/resource-input.js"; +import { ResourceContext } from "../../types/resource-context.js"; -export interface TransformationResultData { - stream: NodeJS.ReadableStream; - apiValidationSummary: ApiValidationSummary; -} export class TransformAction { private readonly prompts: ApiTransformPrompts = new ApiTransformPrompts(); private readonly validatePrompts: ApiValidatePrompts = new ApiValidatePrompts(); private readonly transformationService: TransformationService = new TransformationService(); - private readonly fileService: FileService = new FileService(); private readonly configDir: DirectoryPath; private readonly commandMetadata: CommandMetadata; private readonly authKey: string | null; @@ -37,80 +26,41 @@ export class TransformAction { } public readonly execute = async ( - format: string, + resourcePath: ResourceInput, + format: ExportFormats, destination: DirectoryPath, - force: boolean, - file?: FilePath, - url?: string + force: boolean ): Promise => { - const validationResult = await validateFileInputParams(file, url); - - if (!validationResult.isSuccess()) { - // TODO: Render the message here: validationResult.error! - return ActionResult.failed(); - } - - this.prompts.displayApiTransformationMessage(); - - const parsedFormat = getValidFormat(format); - - const destinationFileExt: string = DestinationFormats[format as keyof typeof DestinationFormats]; - const destinationFilePrefix = file ? getFileNameFromPath(file.toString()) : getFileNameFromPath(url || ""); - - const destinationFileName = `${destinationFilePrefix}_${format}.${destinationFileExt}`; - const destinationFilePath = new FilePath(destination, new FileName(destinationFileName)); - - if ((await fsExtra.pathExists(destinationFilePath.toString())) && !force) { - // TODO: Render the error message here - // return ActionResult.error( - // `Can't download transformed file to path ${destinationFilePath.toString()}, because it already exists` - // ); - - return ActionResult.failed(); - } - - if (!(await fsExtra.pathExists(destination.toString()))) { - await fsExtra.ensureDir(destination.toString()); - } - return await withDirPath(async (tempDirectory) => { - let result: Result; + const resourceContext = new ResourceContext(tempDirectory); + const specFileDirResult = await resourceContext.resolveTo(resourcePath); + if (specFileDirResult.isErr()){ + this.prompts.networkError(specFileDirResult.error); + return ActionResult.failed(); + } + const transformContext = new TransformContext(specFileDirResult.value, format, destination); + if (!force && (await transformContext.exists()) && !(await this.prompts.overwriteApi(destination))) { + this.prompts.transformedApiAlreadyExists(); + return ActionResult.cancelled(); + } - if (file) { - result = await this.transformationService.transformViaFile({ - file, - format: parsedFormat, - configDir: this.configDir, - commandMetadata: this.commandMetadata, - authKey: this.authKey - }); - } else { - result = await this.transformationService.transformViaUrl({ - url: url!, - format: parsedFormat, + const result = await this.prompts.transformApi( + this.transformationService.transformViaFile({ + file: specFileDirResult.value, + format: format, configDir: this.configDir, commandMetadata: this.commandMetadata, authKey: this.authKey - }); - } - - const tempTransformedFilePath = new FilePath(tempDirectory, new FileName(`transformed_${destinationFileName}`)); - await this.fileService.writeFile(tempTransformedFilePath, result.value?.stream as NodeJS.ReadableStream); + }) + ); - if (!result.isSuccess()) { - this.validatePrompts.displayValidationMessages( - result.value?.apiValidationSummary || { warnings: [], errors: [], messages: [] } - ); - this.prompts.displayApiTransformationFailureMessage(); - - // TODO: Render the error message here - //return ActionResult.error(result.error || "An unknown error occurred"); + if (result.isErr()) { + this.prompts.logTransformationError(result.error); return ActionResult.failed(); } - this.prompts.displayApiTransformationSuccessMessage(); - this.validatePrompts.displayValidationMessages(result.value!.apiValidationSummary); - + await transformContext.save(result.value.stream); + this.validatePrompts.displayValidationMessages(result.value.apiValidationSummary); return ActionResult.success(); }); }; diff --git a/src/actions/api/validate.ts b/src/actions/api/validate.ts index c478aeb35..79f603bab 100644 --- a/src/actions/api/validate.ts +++ b/src/actions/api/validate.ts @@ -2,66 +2,55 @@ import { DirectoryPath } from "../../types/file/directoryPath.js"; import { ActionResult } from "../action-result.js"; import { ApiValidatePrompts } from "../../prompts/api/validate.js"; import { ValidationService } from "../../infrastructure/services/validation-service.js"; -import { FilePath } from "../../types/file/filePath.js"; import { ApiValidationSummary } from "@apimatic/sdk"; -import { Result } from "../../types/common/result.js"; -import { validateFileInputParams } from "../../infrastructure/api-utils.js"; +import { Result } from "neverthrow"; import { CommandMetadata } from "../../types/common/command-metadata.js"; +import { ResourceInput } from "../../types/file/resource-input.js"; +import { withDirPath } from "../../infrastructure/tmp-extensions.js"; +import { ResourceContext } from "../../types/resource-context.js"; export class ValidateAction { private readonly prompts: ApiValidatePrompts = new ApiValidatePrompts(); private readonly validationService: ValidationService; private readonly authKey: string | null; + private readonly commandMetadata: CommandMetadata; constructor(configDir: DirectoryPath, commandMetadata: CommandMetadata, authKey: string | null = null) { this.authKey = authKey; - this.validationService = new ValidationService(configDir, commandMetadata); + this.validationService = new ValidationService(configDir); + this.commandMetadata = commandMetadata; } - public readonly execute = async (file?: FilePath, url?: string): Promise => { - const validationResult = await validateFileInputParams(file, url); + public readonly execute = async (resourcePath: ResourceInput): Promise => { + return await withDirPath(async (tempDirectory) => { - if (!validationResult.isSuccess()) { - // TODO: Render the error message here - //return ActionResult.error(validationResult.error!); - return ActionResult.failed(); - } - this.prompts.displayValidationStartMessage(); - - let validationSummaryResult: Result; - - if (file) { - validationSummaryResult = await this.validationService.validateViaFile({ - file, - authKey: this.authKey - }); - } else { - validationSummaryResult = await this.validationService.validateViaUrl({ - url: url!, - authKey: this.authKey - }); - } - - if (!validationSummaryResult.isSuccess()) { - // TODO: Render the error message here - //return ActionResult.error(validationSummaryResult.error! || "Validation failed with an unknown error"); - return ActionResult.failed(); - } - - const validationSummary = validationSummaryResult.value; - if (!validationSummary?.success) { - this.prompts.displayValidationFailureMessage(); - if (validationSummary) { + const resourceContext = new ResourceContext(tempDirectory); + const specFileDirResult = await resourceContext.resolveTo(resourcePath); + if (specFileDirResult.isErr()){ + this.prompts.networkError(specFileDirResult.error); + return ActionResult.failed(); + } + const validationSummaryResult: Result= await this.prompts.validateApi( + this.validationService.validateViaFile({ + file: specFileDirResult.value, + commandMetadata: this.commandMetadata, + authKey: this.authKey + }) + ); + + if (validationSummaryResult.isErr()) { + this.prompts.logValidationError(validationSummaryResult.error); + return ActionResult.failed(); + } + const validationSummary = validationSummaryResult.value; + if (validationSummary?.success) { this.prompts.displayValidationMessages(validationSummary); + return ActionResult.success(); + } else { + this.prompts.displayValidationMessages(validationSummary); + return ActionResult.failed(); } - // TODO: Render the error message here - //return ActionResult.error("Specification file provided is invalid"); - return ActionResult.failed(); - } - - this.prompts.displayValidationSuccessMessage(); - this.prompts.displayValidationMessages(validationSummary); - return ActionResult.success(); + }); }; } diff --git a/src/actions/portal/quickstart.ts b/src/actions/portal/quickstart.ts index b5a6bf3ec..7a40212ac 100644 --- a/src/actions/portal/quickstart.ts +++ b/src/actions/portal/quickstart.ts @@ -39,7 +39,7 @@ export class PortalQuickstartAction { constructor(configDir: DirectoryPath, commandMetadata: CommandMetadata) { this.configDir = configDir; this.commandMetadata = commandMetadata; - this.validationService = new ValidationService(configDir, commandMetadata); + this.validationService = new ValidationService(configDir); } public readonly execute = async (): Promise => { @@ -127,11 +127,11 @@ export class PortalQuickstartAction { const inputPath = await this.prompts.specPathPrompt(this.defaultSpecUrl); const resourceContext = new ResourceContext(tempDirectory); - const result = await resourceContext.resolveTo(inputPath, "spec"); + const result = await resourceContext.resolveTo(new UrlPath(inputPath)); if (result.isErr()) { return err(result.error); } - return ok(result.value); + return ok(new DirectoryPath(result.value.toString(), "spec")); } private async validateSpec( @@ -149,9 +149,9 @@ export class PortalQuickstartAction { const specZipFilePath = new FilePath(tempDirectory, new FileName("spec.zip")); await this.zipService.archive(specDirectory, specZipFilePath); - const validationResult = await this.validationService.validateViaFile({ file: specZipFilePath }); + const validationResult = await this.validationService.validateViaFile({ file: specZipFilePath, commandMetadata: this.commandMetadata }); // TODO: Add spinner when refactoring - if (validationResult.isFailed()) { + if (validationResult.isErr()) { this.prompts.stopProgressIndicator(`Something went wrong while validating your API Definition.`, 1); return Result.failure(validationResult.error!); } @@ -169,11 +169,11 @@ export class PortalQuickstartAction { // Use default spec... const resourceContext = new ResourceContext(tempDirectory); - const result = await resourceContext.resolveTo(this.defaultSpecUrl.toString(), "spec"); + const result = await resourceContext.resolveTo(this.defaultSpecUrl); if (result.isErr()) { return Result.failure(result.error); } - return Result.success(result.value); + return Result.success(new DirectoryPath(result.value.toString(), "spec")); } private async selectLanguages(): Promise { diff --git a/src/commands/api/transform.ts b/src/commands/api/transform.ts index 7df1f1501..c6d769d1e 100644 --- a/src/commands/api/transform.ts +++ b/src/commands/api/transform.ts @@ -1,23 +1,33 @@ import { Command, Flags } from "@oclif/core"; import { DirectoryPath } from "../../types/file/directoryPath.js"; import { FlagsProvider } from "../../types/flags-provider.js"; -import { ApiTransformPrompts } from "../../prompts/api/transform.js"; import { TransformAction } from "../../actions/api/transform.js"; -import { FilePath } from "../../types/file/filePath.js"; -import path from "path/win32"; -import { FileName } from "../../types/file/fileName.js"; import { CommandMetadata } from "../../types/common/command-metadata.js"; -import { outro } from "../../prompts/format.js"; +import { format, intro, outro } from "../../prompts/format.js"; +import { createResourceInput } from "../../types/file/resource-input.js"; +import { TransformationFormats } from "../../types/api/transform.js"; +import { ExportFormats } from "@apimatic/sdk"; +import { err, ok, Result } from "neverthrow"; const DEFAULT_WORKING_DIRECTORY = "./"; export default class Transform extends Command { + static readonly summary = "Transform API specifications between different formats"; + static readonly description = `Transform API specifications from one format to another. Supports multiple formats including OpenAPI/Swagger, RAML, WSDL, and Postman Collections.`; + static readonly cmdTxt = format.cmd("apimatic", "api", "transform"); + static examples = [ - `apimatic api transform --format=OPENAPI3YAML --file="./specs/sample.json" --destination="D:/"`, - `apimatic api transform --format=RAML --url="https://petstore.swagger.io/v2/swagger.json" --destination="D:/"` + `${Transform.cmdTxt} ${format.flag("format", "OPENAPI3YAML")} ${format.flag( + "file", + "./specs/sample.json" + )} ${format.flag("destination", "./")}`, + `${Transform.cmdTxt} ${format.flag("format", "RAML")} ${format.flag( + "url", + '"https://petstore.swagger.io/v2/swagger.json"' + )} ${format.flag("destination", "./")}` ]; static flags = { @@ -33,39 +43,53 @@ Supports multiple formats including OpenAPI/Swagger, RAML, WSDL, and Postman Col }), destination: Flags.string({ char: "d", - description: "Directory to download the transformed file to", + description: "Directory to save the transformed file to", default: DEFAULT_WORKING_DIRECTORY }), ...FlagsProvider.force, ...FlagsProvider.authKey }; - private readonly prompts: ApiTransformPrompts = new ApiTransformPrompts(); - async run() { const { flags: { format, file, url, destination, force, "auth-key": authKey } } = await this.parse(Transform); - const destinationDir = new DirectoryPath(destination); + const workingDirectory = new DirectoryPath(destination ?? DEFAULT_WORKING_DIRECTORY); + const transformedApiDirectory = workingDirectory.join("transformations"); + const specFile = createResourceInput(file, url); + const parsedFormatResult = this.getValidFormat(format); - let filePath: FilePath | undefined = undefined; - if (file) { - filePath = new FilePath(new DirectoryPath(path.dirname(file)), new FileName(path.basename(file))); + if (parsedFormatResult.isErr()) { + this.error(parsedFormatResult.error); } + const parsedFormat = parsedFormatResult.value; + const commandMetadata: CommandMetadata = { commandName: Transform.id, shell: this.config.shell - } + }; + intro("Transform API"); const action = new TransformAction(this.getConfigDir(), commandMetadata, authKey); - - const result = await action.execute(format, destinationDir, force, filePath, url); - + const result = await action.execute(specFile, parsedFormat, transformedApiDirectory, force); outro(result); } private readonly getConfigDir = () => { return new DirectoryPath(this.config.configDir); }; + + private readonly getValidFormat = (format: string): Result => { + const key = Object.keys(TransformationFormats).find((value) => value === format) as + | keyof typeof TransformationFormats + | undefined; + if (key) { + const transformationFormat = TransformationFormats[key] as keyof typeof ExportFormats; + return ok(ExportFormats[transformationFormat]); + } else { + const formats = Object.keys(TransformationFormats).join("|"); + return err(`Please provide a valid platform, e.g. ${formats}`); + } + }; } diff --git a/src/commands/api/validate.ts b/src/commands/api/validate.ts index e567cc21b..d07a70f46 100644 --- a/src/commands/api/validate.ts +++ b/src/commands/api/validate.ts @@ -1,20 +1,21 @@ import { Command, Flags } from "@oclif/core"; import { DirectoryPath } from "../../types/file/directoryPath.js"; import { FlagsProvider } from "../../types/flags-provider.js"; -import { ApiValidatePrompts } from "../../prompts/api/validate.js"; import { ValidateAction } from "../../actions/api/validate.js"; -import { FilePath } from "../../types/file/filePath.js"; -import path from "path"; -import { FileName } from "../../types/file/fileName.js"; import { CommandMetadata } from "../../types/common/command-metadata.js"; -import { outro } from "../../prompts/format.js"; +import { format, intro, outro } from "../../prompts/format.js"; +import { createResourceInput } from "../../types/file/resource-input.js"; export default class Validate extends Command { - static description = "Validate the syntactic and semantic correctness of an API specification"; + static readonly summary = "Validate API specification for syntactic and semantic correctness"; + + static readonly description = `Validate your API specification to ensure it adheres to syntactic and semantic standards.`; + + static readonly cmdTxt = format.cmd("apimatic", "api", "validate"); static examples = [ - `apimatic api validate --file="./specs/sample.json"`, - `apimatic api validate --url=https://petstore.swagger.io/v2/swagger.json` + `${Validate.cmdTxt} ${format.flag("file", "./specs/sample.json")}`, + `${Validate.cmdTxt} ${format.flag("url", '"https://petstore.swagger.io/v2/swagger.json"')}` ]; static flags = { @@ -23,8 +24,6 @@ export default class Validate extends Command { ...FlagsProvider.authKey }; - private readonly prompts: ApiValidatePrompts = new ApiValidatePrompts(); - async run() { const { flags: { file, url, "auth-key": authKey } @@ -35,17 +34,11 @@ export default class Validate extends Command { shell: this.config.shell }; - const action = new ValidateAction( - this.getConfigDir(), - commandMetadata, - authKey - ); - let filePath: FilePath | undefined = undefined; - if (file) { - filePath = new FilePath(new DirectoryPath(path.dirname(file)), new FileName(path.basename(file))); - } - - const result = await action.execute(filePath, url); + const action = new ValidateAction(this.getConfigDir(), commandMetadata, authKey); + const resourceInput = createResourceInput(file, url); + + intro("Validate API"); + const result = await action.execute(resourceInput); outro(result); } diff --git a/src/controllers/api/transform.ts b/src/controllers/api/transform.ts deleted file mode 100644 index f0333369b..000000000 --- a/src/controllers/api/transform.ts +++ /dev/null @@ -1,77 +0,0 @@ -import { ux } from "@oclif/core"; -import fsExtra from "fs-extra"; - -import { writeFileUsingReadableStream } from "../../utils/utils.js"; -import { - DownloadTransformationParams, - TransformationData, - TransformationFormats, - TransformationIdParams -} from "../../types/api/transform.js"; -import { - ApiResponse, - ContentType, - ExportFormats, - FileWrapper, - Transformation, - TransformationController, - TransformViaUrlRequest -} from "@apimatic/sdk"; - -export const getTransformationId = async ( - { file, url, format }: TransformationIdParams, - transformationController: TransformationController -): Promise => { - ux.action.start("Transforming API specification"); - - let generation: ApiResponse; - if (file) { - const fileDescriptor = new FileWrapper(fsExtra.createReadStream(file)); - generation = await transformationController.transformViaFile( - ContentType.EnumMultipartformdata, - fileDescriptor, - format as ExportFormats - ); - } else if (url) { - const body: TransformViaUrlRequest = { - url: url, - exportFormat: format as ExportFormats - }; - generation = await transformationController.transformViaUrl(body); - } else { - throw new Error("Please provide a specification file"); - } - ux.action.stop(); - return generation.result; -}; - -export const downloadTransformationFile = async ({ - id, - destinationFilePath, - transformationController -}: DownloadTransformationParams): Promise => { - ux.action.start("Downloading Transformed file"); - - const { result }: TransformationData = await transformationController.downloadTransformedFile(id); - - if ((result as NodeJS.ReadableStream).readable) { - await writeFileUsingReadableStream(result as NodeJS.ReadableStream, destinationFilePath); - } else { - throw new Error("Couldn't save transformation file"); - } - ux.action.stop(); - return destinationFilePath; -}; -// Get valid platform from user's input, convert simple platform to valid Platforms enum value -export const getValidFormat = (format: string) => { - const key = Object.keys(TransformationFormats).find((value) => value === format) as - | keyof typeof TransformationFormats - | undefined; - if (key) { - const transformationFormat = TransformationFormats[key] as keyof typeof ExportFormats; - return ExportFormats[transformationFormat]; - } else { - const formats = Object.keys(TransformationFormats).join("|"); - throw new Error(`Please provide a valid platform, e.g. ${formats}`); - } -}; diff --git a/src/infrastructure/api-utils.ts b/src/infrastructure/api-utils.ts index 32d90af2d..0b3310a69 100644 --- a/src/infrastructure/api-utils.ts +++ b/src/infrastructure/api-utils.ts @@ -1,7 +1,4 @@ import axios from "axios"; -import fsExtra from "fs-extra"; -import { Result } from "../types/common/result.js"; -import { FilePath } from "../types/file/filePath.js"; export const enum ServiceError { NotFound = "NOT_FOUND", @@ -41,29 +38,4 @@ export function handleServiceError(error: unknown): ServiceError { } } return ServiceError.ServerError; -} - -export async function validateFileInputParams( - file: FilePath | undefined, - url: string | undefined -): Promise> { - if (!file && !url) { - return Result.failure("Please provide either a specification file or URL"); - } - - if (file && url) { - return Result.failure("Please provide either a file or URL, not both"); - } - - if (file) { - if (!(await fsExtra.pathExists(file.toString()))) { - return Result.failure(`Validation file: ${file} does not exist`); - } - const fileStatus = await fsExtra.stat(file.toString()); - if (fileStatus.isDirectory()) { - return Result.failure("The provided path is a directory. Please provide a valid specification file."); - } - } - - return Result.success(""); -} +} \ No newline at end of file diff --git a/src/infrastructure/file-service.ts b/src/infrastructure/file-service.ts index d2ad66612..0bb8f5523 100644 --- a/src/infrastructure/file-service.ts +++ b/src/infrastructure/file-service.ts @@ -39,6 +39,10 @@ export class FileService { await fsExtra.emptyDir(dir.toString()); // removes everything inside, keeps the dir } + public async createDirectoryIfNotExists(dir: DirectoryPath): Promise { + await fsExtra.ensureDir(dir.toString()); + } + public async copyDirectory(source: DirectoryPath, destination: DirectoryPath) { await fsExtra.copy(source.toString(), destination.toString()); } diff --git a/src/infrastructure/services/portal-scaffold-service.ts b/src/infrastructure/services/portal-scaffold-service.ts index 134fc0534..f6bb819a2 100644 --- a/src/infrastructure/services/portal-scaffold-service.ts +++ b/src/infrastructure/services/portal-scaffold-service.ts @@ -5,9 +5,13 @@ import { err, ok, Result } from "neverthrow"; import { ResourceContext } from "../../types/resource-context.js"; import { BuildContext } from "../../types/build-context.js"; import { FilePath } from "../../types/file/filePath.js"; +import { UrlPath } from "../../types/file/urlPath.js"; +import { ZipService } from "../zip-service.js"; export class PortalScaffoldService { private readonly fileService: FileService = new FileService(); + private readonly zipService: ZipService = new ZipService(); + private readonly zipUrl = `https://github.com/apimatic/static-portal-workflow/archive/refs/heads/master.zip`; private readonly repositoryFolderName = "static-portal-workflow-master" as const; private readonly defaultSpecFileName = new FileName("openapi.json"); @@ -19,11 +23,13 @@ export class PortalScaffoldService { ): Promise> { try { const resourceContext = new ResourceContext(tempDirectory); - const result = await resourceContext.resolveTo(this.zipUrl, this.repositoryFolderName); + // TODO: repositoryFolderName should be handled + const result = await resourceContext.resolveTo(new UrlPath(this.zipUrl)); if (result.isErr()) { return err("Unable to setup the portal, please try again later."); } - const extractedFolder = result.value.join(this.repositoryFolderName); + await this.zipService.unArchive(result.value, tempDirectory); + const extractedFolder = tempDirectory.join(this.repositoryFolderName); await this.fileService.deleteDirectory(extractedFolder.join(".github")); // Setup spec. @@ -31,7 +37,6 @@ export class PortalScaffoldService { // TODO: Replace this with SpecContext await this.fileService.deleteFile(new FilePath(tempSpecDirectory, this.defaultSpecFileName)); await this.fileService.copyDirectory(specDirectory, tempSpecDirectory); - const buildContext = new BuildContext(extractedFolder); const buildFile = await buildContext.getBuildFileContents(); buildFile.generatePortal!.languageConfig = selectedLanguages.reduce((config, lang) => { diff --git a/src/infrastructure/services/transform-service.ts b/src/infrastructure/services/transformation-service.ts similarity index 65% rename from src/infrastructure/services/transform-service.ts rename to src/infrastructure/services/transformation-service.ts index 6dac3e51d..677aa186a 100644 --- a/src/infrastructure/services/transform-service.ts +++ b/src/infrastructure/services/transformation-service.ts @@ -12,23 +12,15 @@ import { import { AuthInfo, getAuthInfo } from "../../client-utils/auth-manager.js"; import { TransformationData } from "../../types/api/transform.js"; -import { Result } from "../../types/common/result.js"; import { DirectoryPath } from "../../types/file/directoryPath.js"; import { apiClientFactory } from "./api-client-factory.js"; import { FilePath } from "../../types/file/filePath.js"; import { CommandMetadata } from "../../types/common/command-metadata.js"; - -export interface TransformViaUrlParams { - url: string; - format: string; - configDir: DirectoryPath; - commandMetadata: CommandMetadata; - authKey?: string | null; -} +import { err, ok, Result} from "neverthrow"; export interface TransformViaFileParams { file: FilePath; - format: string; + format: ExportFormats; configDir: DirectoryPath; commandMetadata: CommandMetadata; authKey?: string | null; @@ -42,39 +34,7 @@ export interface TransformationResultData { export class TransformationService { private readonly CONTENT_TYPE = ContentType.EnumMultipartformdata; - //TODO: we can remove this endpoint and use the transformViaFile in case url is given. We can download the file within the CLI from the url and pass it to the transformViaFile endpoint - async transformViaUrl({ - url, - format, - configDir, - commandMetadata, - authKey - }: TransformViaUrlParams): Promise> { - const authInfo: AuthInfo | null = await getAuthInfo(configDir.toString()); - const authorizationHeader = this.createAuthorizationHeader(authInfo, authKey ?? null); - const client = apiClientFactory.createApiClient(authorizationHeader, commandMetadata.shell); - const transformationController = new TransformationController(client); - - try { - //TODO: Update spec to include origin query parameter. - const generation: ApiResponse = await transformationController.transformViaUrl({ - url: url, - exportFormat: format as ExportFormats - }); - - const { id, apiValidationSummary } = generation.result; - const { result }: TransformationData = await transformationController.downloadTransformedFile(id); - - return Result.success({ - stream: result as NodeJS.ReadableStream, - apiValidationSummary - }); - } catch (error) { - return Result.failure(await this.handleTransformationErrors(error)); - } - } - - async transformViaFile({ + public async transformViaFile({ file, format, configDir, @@ -99,12 +59,12 @@ export class TransformationService { const { id, apiValidationSummary } = generation.result; const { result }: TransformationData = await transformationController.downloadTransformedFile(id); - return Result.success({ + return ok({ stream: result as NodeJS.ReadableStream, apiValidationSummary }); } catch (error) { - return Result.failure(await this.handleTransformationErrors(error)); + return err(await this.handleTransformationErrors(error)); } } @@ -113,7 +73,7 @@ export class TransformationService { return `X-Auth-Key ${key ?? ""}`; } - private createOriginQueryParameter = (commandName: string): Record => { + private readonly createOriginQueryParameter = (commandName: string): Record => { return { origin: `APIMATIC CLI ${commandName}` }; diff --git a/src/infrastructure/services/validation-service.ts b/src/infrastructure/services/validation-service.ts index 405a07144..e4b47a91a 100644 --- a/src/infrastructure/services/validation-service.ts +++ b/src/infrastructure/services/validation-service.ts @@ -11,33 +11,27 @@ import { import { DirectoryPath } from "../../types/file/directoryPath.js"; import { AuthInfo, getAuthInfo } from "../../client-utils/auth-manager.js"; import { apiClientFactory } from "./api-client-factory.js"; -import { Result } from "../../types/common/result.js"; +import { err, ok, Result } from "neverthrow"; import { FilePath } from "../../types/file/filePath.js"; import { CommandMetadata } from "../../types/common/command-metadata.js"; export interface ValidateViaFileParams { file: FilePath; - authKey?: string | null; -} - -export interface ValidateViaUrlParams { - url: string; + commandMetadata: CommandMetadata; authKey?: string | null; } export class ValidationService { - private readonly configDirectory: DirectoryPath; - private readonly commandMetadata: CommandMetadata; + constructor(private readonly configDir: DirectoryPath) {} - constructor(configDirectory: DirectoryPath, commandMetadata: CommandMetadata) { - this.configDirectory = configDirectory; - this.commandMetadata = commandMetadata; - } - - async validateViaFile({ file, authKey }: ValidateViaFileParams): Promise> { - const authInfo: AuthInfo | null = await getAuthInfo(this.configDirectory.toString()); + async validateViaFile({ + file, + commandMetadata, + authKey + }: ValidateViaFileParams): Promise> { + const authInfo: AuthInfo | null = await getAuthInfo(this.configDir.toString()); const authorizationHeader = this.createAuthorizationHeader(authInfo, authKey ?? null); - const client = apiClientFactory.createApiClient(authorizationHeader, this.commandMetadata.shell); + const client = apiClientFactory.createApiClient(authorizationHeader, commandMetadata.shell); const controller = new ApiValidationExternalApisController(client); try { @@ -48,24 +42,9 @@ export class ValidationService { fileDescriptor ); - return Result.success(validation.result as ApiValidationSummary); - } catch (error) { - return Result.failure(await this.handleValidationErrors(error)); - } - } - - async validateViaUrl({ url, authKey }: ValidateViaUrlParams): Promise> { - const authInfo: AuthInfo | null = await getAuthInfo(this.configDirectory.toString()); - const authorizationHeader = this.createAuthorizationHeader(authInfo, authKey ?? null); - const client = apiClientFactory.createApiClient(authorizationHeader, this.commandMetadata.shell); - const controller = new ApiValidationExternalApisController(client); - - try { - //TODO: Update spec to include origin query parameter. - const validation: ApiResponse = await controller.validateApiViaUrl(url); - return Result.success(validation.result as ApiValidationSummary); + return ok(validation.result as ApiValidationSummary); } catch (error) { - return Result.failure(await this.handleValidationErrors(error)); + return err(await this.handleValidationErrors(error)); } } diff --git a/src/prompts/api/transform.ts b/src/prompts/api/transform.ts index 984bcac50..57431bea8 100644 --- a/src/prompts/api/transform.ts +++ b/src/prompts/api/transform.ts @@ -1,27 +1,48 @@ -import { outro, spinner, log } from "@clack/prompts"; -import { getMessageInMagentaColor, getMessageInCyanColor, getMessageInRedColor } from "../../utils/utils.js"; +import { outro, log, isCancel, confirm } from "@clack/prompts"; import { DirectoryPath } from "../../types/file/directoryPath.js"; +import { format as f, withSpinner } from "../format.js"; +import { Result } from "neverthrow"; +import { TransformationResultData } from "../../infrastructure/services/transformation-service.js"; +import { getErrorMessage, ServiceError } from "../../infrastructure/api-utils.js"; export class ApiTransformPrompts { - private readonly spin = spinner(); + public async overwriteApi(directory: DirectoryPath): Promise { + const overwrite = await confirm({ + message: `A specification file already exists at ${f.path(directory.toString())}. Do you want to overwrite the existing file?`, + initialValue: false + }); - displayApiTransformationMessage(): void { - this.spin.start(getMessageInMagentaColor("Transforming API")); + if (isCancel(overwrite)) { + return false; + } + + return overwrite; + } + + public transformedApiAlreadyExists() { + const message = `Specification already exists.`; + log.error(message); } - displayApiTransformationSuccessMessage(): void { - this.spin.stop(getMessageInCyanColor(`API transformed successfully`)); + public async InvalidFilePathProvided(){ + const message = `Invalid file path or URL provided.`; + log.error(message); } - displayApiTransformationFailureMessage(): void { - this.spin.stop(getMessageInRedColor("API transformation failed")); + public async transformApi(fn: Promise>) { + return withSpinner("Transforming API", "API transformed successfully.", "API transformation failed.", fn); } displayOutroMessage(transformedApiPath: DirectoryPath): void { outro(`The transformed API specification can be found at ${transformedApiPath}`); } - logError(error: string): void { + logTransformationError(error: string): void { log.error(error); } + + public networkError(serviceError: ServiceError): void { + const message = getErrorMessage(serviceError); + log.error(message); + } } diff --git a/src/prompts/api/validate.ts b/src/prompts/api/validate.ts index 5d6840b4f..773d65d16 100644 --- a/src/prompts/api/validate.ts +++ b/src/prompts/api/validate.ts @@ -1,41 +1,50 @@ -import { outro, spinner, log } from "@clack/prompts"; -import { getMessageInCyanColor, getMessageInGreenColor, getMessageInRedColor, replaceHTML } from "../../utils/utils.js"; +import { spinner, log } from "@clack/prompts"; +import { replaceHTML } from "../../utils/utils.js"; import { ValidationMessages } from "../../types/utils.js"; +import { Result } from "neverthrow"; +import { withSpinner } from "../format.js"; +import { ApiValidationSummary } from "@apimatic/sdk"; +import { getErrorMessage, ServiceError } from "../../infrastructure/api-utils.js"; export class ApiValidatePrompts { private readonly spin = spinner(); - displayValidationStartMessage(): void { - this.spin.start(getMessageInCyanColor("🔍 Validating specification file...")); + public async validateApi(fn: Promise>) { + return withSpinner("Validating API", "API validated successfully.", "API validation failed.", fn); } - displayValidationSuccessMessage(): void { - this.spin.stop(getMessageInGreenColor("Specification file provided is valid")); - } - - displayValidationFailureMessage(): void { - this.spin.stop(getMessageInRedColor("Specification validation failed")); + public async invalidFilePathProvided() { + const message = `Invalid file path or URL provided.`; + log.error(message); } displayValidationMessages({ warnings, errors, messages }: ValidationMessages): void { - const singleError: string = errors.join("\n") || ""; - - messages.forEach((message) => { - log.message(getMessageInCyanColor(`ℹ️ ${replaceHTML(message)}`)); - }); - warnings.forEach((warning) => { - log.warn(`⚠️ ${replaceHTML(warning)}`); - }); + if (messages.length > 0) { + log.info("Messages"); + messages.forEach((msg) => { + log.message(`${replaceHTML(msg)}`); + }); + } + if (warnings.length > 0) { + log.warning("Warnings"); + warnings.forEach((war) => { + log.message(`${replaceHTML(war)}`); + }); + } if (errors.length > 0) { - log.error(getMessageInRedColor(`❌ ${replaceHTML(singleError)}`)); + log.error("Errors"); + errors.forEach((err) => { + log.message(`${replaceHTML(err)}`); + }); } } - displayOutroMessage(): void { - outro("Validation complete"); + logValidationError(error: string): void { + log.error(error); } - logError(error: string): void { - log.error(error); + public networkError(serviceError: ServiceError): void { + const message = getErrorMessage(serviceError); + log.error(message); } } diff --git a/src/prompts/sdk/generate.ts b/src/prompts/sdk/generate.ts index b5a34b531..3d84f24cd 100644 --- a/src/prompts/sdk/generate.ts +++ b/src/prompts/sdk/generate.ts @@ -1,4 +1,4 @@ -import { outro, spinner, isCancel, confirm, log } from "@clack/prompts"; +import { spinner, isCancel, confirm, log } from "@clack/prompts"; import { getMessageInRedColor, getMessageInMagentaColor, getMessageInCyanColor } from "../../utils/utils.js"; import { DirectoryPath } from "../../types/file/directoryPath.js"; @@ -32,10 +32,6 @@ export class SdkGeneratePrompts { this.cleanUpStandardInput(); } - displayOutroMessage(generatedSdkPath: DirectoryPath): void { - outro(`The generated SDK can be found at ${generatedSdkPath}`); - } - logError(error: string): void { log.error(error); } diff --git a/src/types/file/fileName.ts b/src/types/file/fileName.ts index 91f67b9b1..d16ba71f1 100644 --- a/src/types/file/fileName.ts +++ b/src/types/file/fileName.ts @@ -14,4 +14,4 @@ export class FileName { public toString(): string { return this.name; } -} +} \ No newline at end of file diff --git a/src/types/file/filePath.ts b/src/types/file/filePath.ts index 9a2ec0409..edffb7b95 100644 --- a/src/types/file/filePath.ts +++ b/src/types/file/filePath.ts @@ -16,6 +16,6 @@ export class FilePath { } public toString(): string { - return path.join(this.directoryPath.toString() , this.fileName.toString()); + return path.join(this.directoryPath.toString(), this.fileName.toString()); } -} +} \ No newline at end of file diff --git a/src/types/file/resource-input.ts b/src/types/file/resource-input.ts new file mode 100644 index 000000000..e108bb5b6 --- /dev/null +++ b/src/types/file/resource-input.ts @@ -0,0 +1,31 @@ +import path from "path"; +import { DirectoryPath } from "./directoryPath.js"; +import { FileName } from "./fileName.js"; +import { FilePath } from "./filePath.js"; +import { UrlPath } from "./urlPath.js"; + +export type ResourceInput = FilePath | UrlPath; + +// Factory function to create the discriminated union +export const createResourceInput = (file?: string, url?: string): ResourceInput => { + if (file && url) { + throw new Error("Cannot specify both file and url. Please provide only one."); + } + if (!file && !url) { + throw new Error("Must specify either file or url."); + } + + if (file) { + if (!file.trim()) { + throw new Error("Invalid file path provided."); + } + return new FilePath(new DirectoryPath(path.dirname(file)), new FileName(path.basename(file))); + } + if (url) { + if (!url.trim()) { + throw new Error("Invalid URL provided."); + } + return new UrlPath(url); + } + throw new Error("Must specify either file or url."); +}; diff --git a/src/types/file/urlPath.ts b/src/types/file/urlPath.ts index 2f681064f..242143e7c 100644 --- a/src/types/file/urlPath.ts +++ b/src/types/file/urlPath.ts @@ -1,5 +1,3 @@ -import path from "path"; -import { FileName } from "./fileName.js"; import { URL } from "url"; export class UrlPath { @@ -24,4 +22,4 @@ export class UrlPath { public toString(): string { return this.url; } -} +} \ No newline at end of file diff --git a/src/types/resource-context.ts b/src/types/resource-context.ts index 189428289..ef1ef7532 100644 --- a/src/types/resource-context.ts +++ b/src/types/resource-context.ts @@ -6,40 +6,29 @@ import { DirectoryPath } from "./file/directoryPath.js"; import { FileName } from "./file/fileName.js"; import { FileDownloadService } from "../infrastructure/services/file-download-service.js"; import { FileService } from "../infrastructure/file-service.js"; -import { ZipService } from "../infrastructure/zip-service.js"; +import { ResourceInput } from "./file/resource-input.js"; +import { ServiceError } from "../infrastructure/api-utils.js"; export class ResourceContext { private readonly fileDownloadService = new FileDownloadService(); private readonly fileService = new FileService(); - private readonly zipService = new ZipService(); constructor(private readonly tempDirectory: DirectoryPath) {} - public async resolveTo(resourcePath: string, destinationSubPath: string){ - const urlPath = UrlPath.create(resourcePath); - const fileName = new FileName(path.basename(resourcePath)) + public async resolveTo(resourcePath: ResourceInput): Promise> { + const fileName = new FileName(path.basename(resourcePath.toString())); const destinationFilePath = new FilePath(this.tempDirectory, fileName); - if (urlPath) { - const downloadFileResult = await this.fileDownloadService.downloadFile(urlPath); + if (resourcePath instanceof UrlPath) { + const downloadFileResult = await this.fileDownloadService.downloadFile(resourcePath); if (downloadFileResult.isErr()) { - // TODO: Update message here - return err("Unable to download the file. Please verify that the provided URL is correct and publicly accessible. "); + return err(downloadFileResult.error); } await this.fileService.writeFile(destinationFilePath, downloadFileResult.value); - } else { - const directory = new DirectoryPath(path.dirname(resourcePath)); - const sourceFilePath = new FilePath(directory, fileName); - await this.fileService.copy(sourceFilePath, destinationFilePath); } - const specDirectory = this.tempDirectory.join(destinationSubPath); - await this.fileService.cleanDirectory(specDirectory); - - if (fileName.isZipFile()) { - await this.zipService.unArchive(destinationFilePath, specDirectory); - } else { - await this.fileService.copy(destinationFilePath, destinationFilePath.replaceDirectory(specDirectory)); + if (resourcePath instanceof FilePath) { + await this.fileService.copy(resourcePath, destinationFilePath); } - return ok(specDirectory); + return ok(destinationFilePath); } } diff --git a/src/types/transform-context.ts b/src/types/transform-context.ts new file mode 100644 index 000000000..24ac120b7 --- /dev/null +++ b/src/types/transform-context.ts @@ -0,0 +1,43 @@ +import { ExportFormats } from "@apimatic/sdk"; +import { FileService } from "../infrastructure/file-service.js"; +import { getFileNameFromPath } from "../utils/utils.js"; +import { DestinationFormats } from "./api/transform.js"; +import { DirectoryPath } from "./file/directoryPath.js"; +import { FileName } from "./file/fileName.js"; +import { FilePath } from "./file/filePath.js"; + +export class TransformContext { + private readonly fileService = new FileService(); + + private readonly transformedApi: FileName; + + constructor(private readonly specFilePath: FilePath, + private readonly format: ExportFormats, + private readonly destinationDirectory: DirectoryPath ) { + this.transformedApi = this.parseFileName(format, specFilePath); + } + + private get specPath(): FilePath { + this.fileService.createDirectoryIfNotExists(this.destinationDirectory); + return new FilePath(this.destinationDirectory, this.transformedApi); + } + + public async exists(): Promise { + const transformedApiPath = new FilePath(this.destinationDirectory, this.transformedApi); + const fileExists = await this.fileService.fileExists(transformedApiPath); + if (fileExists) { + return true; + } + return false; + } + + public async save(stream: NodeJS.ReadableStream): Promise { + await this.fileService.writeFile(this.specPath, stream); + } + + private parseFileName(format: string, file: FilePath): FileName { + const destinationFileExt: string = DestinationFormats[format as keyof typeof DestinationFormats]; + const destinationFilePrefix = getFileNameFromPath(file.toString()); + return new FileName(`${destinationFilePrefix}_${format}.${destinationFileExt}`); + } +} From cb341037ee8c8ef5c88ce2c200979f5af52a277d Mon Sep 17 00:00:00 2001 From: Ali Asghar <75574550+aliasghar98@users.noreply.github.com> Date: Tue, 26 Aug 2025 15:29:59 +0500 Subject: [PATCH 16/77] wip: removed serve handler and incoporated some feedback for serve command --- .vscode/launch.json | 4 +- src/actions/portal/quickstart.ts | 17 +-- src/actions/portal/serve.ts | 52 ++------ src/application/portal/serve/serve-handler.ts | 125 ------------------ .../portal/serve/watcher-handler.ts | 4 +- src/commands/portal/serve.ts | 24 +++- src/infrastructure/network-service.ts | 28 ++++ .../services/portal-serve-service.ts | 106 +++++++++++++++ src/prompts/portal/serve.ts | 8 +- 9 files changed, 182 insertions(+), 186 deletions(-) delete mode 100644 src/application/portal/serve/serve-handler.ts create mode 100644 src/infrastructure/network-service.ts create mode 100644 src/infrastructure/services/portal-serve-service.ts diff --git a/.vscode/launch.json b/.vscode/launch.json index 9a03fe51b..a37162b2b 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -21,6 +21,7 @@ // "portal:quickstart", // "portal:generate", // "portal:toc:new", + "portal:generate", //"--expand-endpoints", //"--expand-models", // "--force", @@ -30,7 +31,8 @@ // "test-portal", // "portal:serve", // "api:transform", - // "--help" + // "--help", + "--input=C:\\Users\\Ali\\Desktop\\test-cli\\test-prompts-framework-integration" // "--folder=C:/Users/User/Desktop/demo/portal", // "--destination=C:\\Users\\User\\Desktop\\demo\\portal\\api-portal" // "--source=C:/Users/User/Desktop/demo/portal/", diff --git a/src/actions/portal/quickstart.ts b/src/actions/portal/quickstart.ts index d19999670..4fa1ecc27 100644 --- a/src/actions/portal/quickstart.ts +++ b/src/actions/portal/quickstart.ts @@ -17,16 +17,17 @@ import { ResourceContext } from "../../types/resource-context.js"; import { FileName } from "../../types/file/fileName.js"; import { CommandMetadata } from "../../types/common/command-metadata.js"; +const defaultSpecUrl: UrlPath = new UrlPath( + "https://raw.githubusercontent.com/apimatic/static-portal-workflow/refs/heads/master/spec/openapi.json" +); +const defaultPort: number = 3000 as const; + export class PortalQuickstartAction { private readonly prompts: PortalQuickstartPrompts = new PortalQuickstartPrompts(); private readonly zipService: ZipService = new ZipService(); private readonly fileService: FileService = new FileService(); private readonly portalScaffoldService: PortalScaffoldService = new PortalScaffoldService(); private readonly validationService: ValidationService; - private readonly defaultSpecUrl: UrlPath = new UrlPath( - "https://raw.githubusercontent.com/apimatic/static-portal-workflow/refs/heads/master/spec/openapi.json" - ); - private readonly defaultPort: number = 3000 as const; private readonly configDir: DirectoryPath; private readonly commandMetadata: CommandMetadata; @@ -112,7 +113,7 @@ export class PortalQuickstartAction { // TODO: create TempSpecContext and then refactor this. private async importSpec(tempDirectory: DirectoryPath): Promise> { this.prompts.importSpecStep(); - const inputPath = await this.prompts.specPathPrompt(this.defaultSpecUrl); + const inputPath = await this.prompts.specPathPrompt(defaultSpecUrl); const resourceContext = new ResourceContext(tempDirectory); const result = await resourceContext.resolveTo(inputPath, "spec"); @@ -156,7 +157,7 @@ export class PortalQuickstartAction { // Use default spec... const resourceContext = new ResourceContext(tempDirectory); - const result = await resourceContext.resolveTo(this.defaultSpecUrl.toString(), "spec"); + const result = await resourceContext.resolveTo(defaultSpecUrl.toString(), "spec"); if (result.isErr()) { return Result.failure(result.error); } @@ -203,8 +204,8 @@ export class PortalQuickstartAction { buildDirectory: DirectoryPath, portalDirectory: DirectoryPath ): Promise> { - const portalServeAction = new PortalServeAction(this.configDir, this.commandMetadata, null, this.defaultPort); - const result = await portalServeAction.servePortal(buildDirectory, portalDirectory, true, false, false); + const portalServeAction = new PortalServeAction(this.configDir, this.commandMetadata, null); + const result = await portalServeAction.execute(buildDirectory, portalDirectory, defaultPort, true, false, false); return result.mapAll>( () => { diff --git a/src/actions/portal/serve.ts b/src/actions/portal/serve.ts index 2c37f2213..474af39c8 100644 --- a/src/actions/portal/serve.ts +++ b/src/actions/portal/serve.ts @@ -1,51 +1,42 @@ -import getPort from "get-port"; import { PortalServePrompts } from "../../prompts/portal/serve.js"; -import { ServeHandler } from "../../application/portal/serve/serve-handler.js"; import { DirectoryPath } from "../../types/file/directoryPath.js"; import { ActionResult } from "../action-result.js"; import { CommandMetadata } from "../../types/common/command-metadata.js"; import { GenerateAction } from "./generate.js"; +import { PortalServeService } from "../../infrastructure/services/portal-serve-service.js"; export class PortalServeAction { private readonly prompts: PortalServePrompts = new PortalServePrompts(); - private readonly serveHandler: ServeHandler = new ServeHandler(); + private readonly portalServeService: PortalServeService = new PortalServeService(); private readonly configDir: DirectoryPath; private readonly commandMetadata: CommandMetadata; private readonly authKey: string | null; - private readonly port: number | undefined; - public constructor( - configDir: DirectoryPath, - commandMetadata: CommandMetadata, - authKey: string | null = null, - port: number | undefined = undefined - ) { + public constructor(configDir: DirectoryPath, commandMetadata: CommandMetadata, authKey: string | null = null) { this.configDir = configDir; this.commandMetadata = commandMetadata; this.authKey = authKey; - this.port = port; } - public async servePortal( + public async execute( buildDirectory: DirectoryPath, portalDirectory: DirectoryPath, + port: number, openInBrowser: boolean, noReload: boolean, displayServeCommandMessages: boolean = true ): Promise { - const serverPort: number = await this.getServerPort(this.port); - const generatePortalAction = new GenerateAction(this.configDir, this.commandMetadata, this.authKey); + const portAvailable = await this.portalServeService.isPortAvailable(port); + const serverPort = portAvailable ? port : await this.portalServeService.getServerPort(); + if (!portAvailable) { + this.prompts.portAlreadyInUse(port, serverPort); + } + const generatePortalAction = new GenerateAction(this.configDir, this.commandMetadata, this.authKey); const result = await generatePortalAction.execute(buildDirectory, portalDirectory, false, false); return result.mapAll>( async () => { - const setupServerResult = await this.serveHandler.setupServer(portalDirectory); - if (setupServerResult.isErr()) { - this.prompts.setupServerError(setupServerResult.error); - return ActionResult.failed(); - } - - const startServerResult = await this.serveHandler.startServer( + this.portalServeService.servePortal( buildDirectory, portalDirectory, generatePortalAction.execute, @@ -53,10 +44,6 @@ export class PortalServeAction { openInBrowser, noReload ); - if (startServerResult.isErr()) { - this.prompts.startServerError(startServerResult.error); - return ActionResult.failed(); - } if (displayServeCommandMessages) { this.prompts.nextSteps( @@ -77,19 +64,4 @@ export class PortalServeAction { } ); } - - private async getServerPort(port: number | undefined): Promise { - const defaultPorts = [3000, 3001, 3002]; - - const preferredPorts = typeof port === "number" ? [port, ...defaultPorts.filter((p) => p !== port)] : defaultPorts; - - const availablePort = await getPort({ port: preferredPorts }); - - // Show warning only if user provided --port and it is not available - if (typeof port === "number" && availablePort !== port) { - this.prompts.portAlreadyInUse(port, availablePort); - } - - return availablePort; - } } diff --git a/src/application/portal/serve/serve-handler.ts b/src/application/portal/serve/serve-handler.ts deleted file mode 100644 index dffab2d96..000000000 --- a/src/application/portal/serve/serve-handler.ts +++ /dev/null @@ -1,125 +0,0 @@ -import express from "express"; -import livereload from "livereload"; -import connectLivereload from "connect-livereload"; -import open from "open"; -import process from "process"; -import getPort from "get-port"; -import console from "console"; -import { Server } from "http"; -import { ok, err, Result } from "neverthrow"; -import { PortalWatcher } from "./portal-watcher.js"; -import { DirectoryPath } from "../../../types/file/directoryPath.js"; -import { ActionResult } from "../../../actions/action-result.js"; - -export class ServeHandler { - private server!: Server; - private liveReloadServer!: livereload.LiveReloadServer; - private readonly app: express.Application; - private readonly portalWatcher: PortalWatcher = new PortalWatcher(); - - constructor() { - this.app = express(); - } - - //TODO: Needs to be refactored after refactoring quickstart. - public async setupServer(portalDirectory: DirectoryPath): Promise> { - const createLiveReloadServerResult = await this.createLiveReloadServer(portalDirectory); - if (createLiveReloadServerResult.isErr()) { - return err(createLiveReloadServerResult.error); - } - - this.app.use(express.static(portalDirectory.toString(), { extensions: ["html"] })); - - return ok(`Server is set up and serving files from ${portalDirectory.toString()}`); - } - - //TODO: Needs to be refactored after refactoring quickstart. - public async startServer( - buildDirectory: DirectoryPath, - portalDirectory: DirectoryPath, - generatePortal: ( - buildDirectory: DirectoryPath, - portalDirectory: DirectoryPath, - force: boolean, - zipPortal: boolean - ) => Promise, - serverPort: number, - openInBrowser: boolean, - noReload: boolean - ): Promise> { - return new Promise>((resolve, reject) => { - this.server = this.app - .listen(serverPort, async () => { - if (openInBrowser) { - await open(`http://localhost:${serverPort}`); - } - - if (!noReload) { - await this.portalWatcher.watchAndRegeneratePortalOnChange(buildDirectory, portalDirectory, generatePortal); - } - - if (process.platform !== "darwin") { - //For non-macOS users. - if (process.stdin.setRawMode) { - process.stdin.setRawMode(false); - } - } - resolve(ok(true)); - }) - .on("error", () => { - reject( - new Error( - `Something went wrong while serving your portal. Please try again later. If the issue persists, contact our team at support@apimatic.io` - ) - ); - }); - - const shutdown = async () => { - console.log("Shutting down server..."); - await this.stopServer(); - console.log("Server shut down successfully."); - resolve(ok(true)); - process.exit(0); - }; - - process.on("SIGINT", shutdown); - process.on("SIGTERM", shutdown); - }); - } - - private async stopServer(): Promise { - this.portalWatcher.cancelPendingOperations(); - if (this.liveReloadServer) { - this.liveReloadServer.close(); - } - - if (this.server) { - await new Promise((resolve) => { - this.server.close(() => resolve()); - }); - } - } - - private async createLiveReloadServer(portalDirectory: DirectoryPath): Promise> { - try { - const availablePort = await this.getPortForReloadServer(); - this.liveReloadServer = livereload.createServer({ - port: availablePort - }); - - this.liveReloadServer.watch(portalDirectory.toString()); - this.app.use(connectLivereload()); - - return ok("Live Reload Server setup successfully."); - } catch { - return err( - "An unexpected error occurred while serving your portal, please try again later. If the issue persists, contact our team at support@apimatic.io for assistance." - ); - } - } - - private async getPortForReloadServer(): Promise { - const defaultPorts = [35729, 35730, 35731]; - return await getPort({ port: defaultPorts }); - } -} diff --git a/src/application/portal/serve/watcher-handler.ts b/src/application/portal/serve/watcher-handler.ts index f628764bd..3883aa0e9 100644 --- a/src/application/portal/serve/watcher-handler.ts +++ b/src/application/portal/serve/watcher-handler.ts @@ -1,3 +1,5 @@ +import { clearTimeout, setTimeout } from "timers"; + export class WatcherHandler { private isProcessing = false; private latestHandler: (() => Promise) | null = null; @@ -56,4 +58,4 @@ export class WatcherHandler { } this.latestHandler = null; } -} \ No newline at end of file +} diff --git a/src/commands/portal/serve.ts b/src/commands/portal/serve.ts index 3971ff160..a2bca4071 100644 --- a/src/commands/portal/serve.ts +++ b/src/commands/portal/serve.ts @@ -3,17 +3,22 @@ import { PortalServeAction } from "../../actions/portal/serve.js"; import { DirectoryPath } from "../../types/file/directoryPath.js"; import { FlagsProvider } from "../../types/flags-provider.js"; import { CommandMetadata } from "../../types/common/command-metadata.js"; -import { intro, outro } from "../../prompts/format.js"; +import { format, intro, outro } from "../../prompts/format.js"; const DEFAULT_WORKING_DIRECTORY = "./"; export default class PortalServe extends Command { - static description = "Generate and deploy a Docs as Code portal with hot reload."; + static summary = "Generate and serve an API Documentation Portal with hot reload."; + + static description = + "Requires an input directory with API specifications, a config file, and optionally markdown guides. Supports disabling hot reload and opening the portal in the default browser."; static flags = { port: Flags.integer({ char: "p", - description: "[default: 3000] port to serve the portal." + description: "Port to serve the portal.", + default: 3000, + helpValue: "3000" }), ...FlagsProvider.input, ...FlagsProvider.destination("portal", "portal"), @@ -29,9 +34,14 @@ export default class PortalServe extends Command { ...FlagsProvider.authKey }; + static cmdTxt = format.cmd("apimatic", "portal", "serve"); static examples = [ - "apimatic portal serve", - 'apimatic portal serve --input="./" --destination="./portal" --port=3000 --open --no-reload' + this.cmdTxt, + `${this.cmdTxt} ${format.flag("input", '"./"')} ` + + `${format.flag("destination", '"./portal"')} ` + + `${format.flag("port", "3000")} ` + + `${format.flag("open")} ` + + `${format.flag("no-reload")}` ]; public async run() { @@ -48,8 +58,8 @@ export default class PortalServe extends Command { }; intro("Portal Serve"); - const portalServeAction = new PortalServeAction(this.getConfigDir(), commandMetadata, authKey, port); - const result = await portalServeAction.servePortal(buildDirectory, portalDirectory, open, noReload); + const portalServeAction = new PortalServeAction(this.getConfigDir(), commandMetadata, authKey); + const result = await portalServeAction.execute(buildDirectory, portalDirectory, port, open, noReload); outro(result); } diff --git a/src/infrastructure/network-service.ts b/src/infrastructure/network-service.ts new file mode 100644 index 000000000..1dadbc22d --- /dev/null +++ b/src/infrastructure/network-service.ts @@ -0,0 +1,28 @@ +import getPort from "get-port"; +import net from "net"; +import open from "open"; + +export class NetworkService { + public async isPortAvailable(port: number): Promise { + return new Promise((resolve) => { + const server = net + .createServer() + .once("error", () => { + resolve(false); // Port is in use + }) + .once("listening", () => { + server.close(); + resolve(true); // Port is available + }) + .listen(port); + }); + } + + public async getServerPort(preferredPorts: number[]): Promise { + return await getPort({ port: preferredPorts }); + } + + public openUrlInBrowser(url: string): void { + open(url); + } +} diff --git a/src/infrastructure/services/portal-serve-service.ts b/src/infrastructure/services/portal-serve-service.ts new file mode 100644 index 000000000..ca4faf3c4 --- /dev/null +++ b/src/infrastructure/services/portal-serve-service.ts @@ -0,0 +1,106 @@ +import express, { Express } from "express"; +import { Server } from "http"; +import { LiveReloadServer, createServer } from "livereload"; +import connectLiveReload from "connect-livereload"; +import { ok, err, Result } from "neverthrow"; +import console from "console"; +import { DirectoryPath } from "../../types/file/directoryPath.js"; +import { NetworkService } from "../network-service.js"; +import { PortalWatcher } from "../../application/portal/serve/portal-watcher.js"; +import { ActionResult } from "../../actions/action-result.js"; + +export class PortalServeService { + private readonly application: Express = express(); + private readonly portalWatcher: PortalWatcher = new PortalWatcher(); + private readonly networkService: NetworkService = new NetworkService(); + + public async servePortal( + buildDirectory: DirectoryPath, + portalDirectory: DirectoryPath, + generatePortal: ( + buildDirectory: DirectoryPath, + portalDirectory: DirectoryPath, + force: boolean, + zipPortal: boolean + ) => Promise, + serverPort: number, + openInBrowser: boolean, + noReload: boolean + ): Promise> { + const createLiveReloadServerResult = await this.createLiveReloadServer(); + if (createLiveReloadServerResult.isErr()) { + return err("Failed to create live reload server."); + } + + createLiveReloadServerResult.value.watch(portalDirectory.toString()); + this.application.use(connectLiveReload()); + this.application.use(express.static(portalDirectory.toString(), { extensions: ["html"] })); + + const server = this.application + .listen(serverPort, async () => { + if (openInBrowser) { + this.networkService.openUrlInBrowser(`http://localhost:${serverPort}`); + } + + if (!noReload) { + await this.portalWatcher.watchAndRegeneratePortalOnChange(buildDirectory, portalDirectory, generatePortal); + } + + if (process.platform !== "darwin") { + //For non-macOS users. + if (process.stdin.setRawMode) { + process.stdin.setRawMode(false); + } + } + }) + .on("error", () => {}); + + const shutdown = async () => { + console.log("Shutting down server..."); + this.cleanUp(createLiveReloadServerResult.value, server); + console.log("Server shut down successfully."); + // TODO: Find a better way. + process.exit(0); + }; + + process.on("SIGINT", shutdown); + process.on("SIGTERM", shutdown); + + return ok(server); + } + + // TODO: Remove this and find a better way. + public async isPortAvailable(port: number) { + return this.networkService.isPortAvailable(port); + } + + // TODO: Remove this and find a better way. + public async getServerPort() { + return this.networkService.getServerPort([3000, 3001, 3002]); + } + + private async cleanUp(liveReloadServer: LiveReloadServer, server: Server): Promise { + if (liveReloadServer) { + liveReloadServer.close(); + } + if (server) { + server.close(); + } + } + + private async createLiveReloadServer(): Promise> { + try { + const preferredPorts = [35729, 35730, 35731]; + const availablePort = await this.networkService.getServerPort(preferredPorts); + const liveReloadServer = createServer({ + port: availablePort + }); + + return ok(liveReloadServer); + } catch { + return err( + "An unexpected error occurred while serving your portal, please try again later. If the issue persists, contact our team at support@apimatic.io for assistance." + ); + } + } +} diff --git a/src/prompts/portal/serve.ts b/src/prompts/portal/serve.ts index b97c102f0..a84f520b9 100644 --- a/src/prompts/portal/serve.ts +++ b/src/prompts/portal/serve.ts @@ -10,7 +10,7 @@ export class PortalServePrompts { log.error(message); } - public setupServerError(message: string) { + public createLiveReloadServerError(message: string) { log.error(message); } @@ -20,10 +20,10 @@ export class PortalServePrompts { } public nextSteps(buildDirectory: string, portalDirectory: string, port: string, hotReloadDisabled: boolean): void { - log.message(`The generated portal can be found at ${portalDirectory}`); - log.message(`Server started at http://localhost:${port}`); + log.message(`Portal successfully generated at: ${portalDirectory}`); + log.message(`Server running at: http://localhost:${port}`); if (!hotReloadDisabled) { - log.message(`Hot reload enabled. Watching the following build folder for any changes:`); + log.message(`Hot reload is enabled. Watching the following build folder for any changes:`); log.message(`${buildDirectory}`); } log.message(`Press CTRL+C to stop the server.`); From 5d50c8661c062848c0fada90ee14accf7fd5ae30 Mon Sep 17 00:00:00 2001 From: Sohail Date: Tue, 26 Aug 2025 15:38:54 +0500 Subject: [PATCH 17/77] sonarqube --- src/prompts/portal/copilot.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/prompts/portal/copilot.ts b/src/prompts/portal/copilot.ts index 19635145e..9527dcd55 100644 --- a/src/prompts/portal/copilot.ts +++ b/src/prompts/portal/copilot.ts @@ -28,7 +28,6 @@ export class PortalCopilotPrompts { if (isCancel(selectedKey)) { return null; - return null; } return selectedKey; From 61d2fd136b18bb05b2f09cbbf0914e0d63a42c8d Mon Sep 17 00:00:00 2001 From: Ali Asghar <75574550+aliasghar98@users.noreply.github.com> Date: Tue, 26 Aug 2025 16:50:13 +0500 Subject: [PATCH 18/77] refactor: incorporated first feedback in quickstart --- src/actions/portal/quickstart.ts | 43 +++-- src/commands/portal/quickstart.ts | 2 + src/infrastructure/file-service.ts | 45 +++++ .../services/portal-scaffold-service.ts | 51 +++--- src/prompts/portal/quickstart.ts | 161 +++++++----------- .../quickstart.ts => file/directoryNode.ts} | 2 +- src/types/resource-context.ts | 9 +- 7 files changed, 173 insertions(+), 140 deletions(-) rename src/types/{portal/quickstart.ts => file/directoryNode.ts} (97%) diff --git a/src/actions/portal/quickstart.ts b/src/actions/portal/quickstart.ts index 4fa1ecc27..c9614884b 100644 --- a/src/actions/portal/quickstart.ts +++ b/src/actions/portal/quickstart.ts @@ -51,6 +51,7 @@ export class PortalQuickstartAction { const specDirectory = await this.importSpec(tempDirectory); if (specDirectory.isErr()) { this.prompts.specImportError(specDirectory.error); + if (specDirectory.error === "cancelled") return ActionResult.cancelled(); return ActionResult.failed(); } @@ -66,16 +67,23 @@ export class PortalQuickstartAction { } // Step 3/4 - const selectedLanguages = await this.selectLanguages(); + const selectedLanguagesResult = await this.selectLanguages(); + if (selectedLanguagesResult.isErr()) { + return ActionResult.cancelled(); + } // Step 4/4 - const { sourceDirectory, portalDirectory } = await this.selectInputDirectory(); + const selectInputDirectoryResult = await this.selectInputDirectory(); + if (selectInputDirectoryResult.isErr()) { + return ActionResult.cancelled(); + } + const { sourceDirectory, portalDirectory } = selectInputDirectoryResult.value; const buildDirectoryResult = await this.setupBuildDirectory( tempDirectory, sourceDirectory, validatedSpecDirectory.value!, - selectedLanguages + selectedLanguagesResult.value ); if (buildDirectoryResult.isErr()) { this.prompts.buildSetupError(buildDirectoryResult.error); @@ -114,6 +122,9 @@ export class PortalQuickstartAction { private async importSpec(tempDirectory: DirectoryPath): Promise> { this.prompts.importSpecStep(); const inputPath = await this.prompts.specPathPrompt(defaultSpecUrl); + if (inputPath === null) { + return err("Operation cancelled. No API Definition was provided."); + } const resourceContext = new ResourceContext(tempDirectory); const result = await resourceContext.resolveTo(inputPath, "spec"); @@ -164,18 +175,31 @@ export class PortalQuickstartAction { return Result.success(result.value); } - private async selectLanguages(): Promise { + private async selectLanguages(): Promise> { this.prompts.selectLanguagesStep(); - return await this.prompts.selectLanguagesPrompt(); + const languages = await this.prompts.selectLanguagesPrompt(); + if (languages === null) { + this.prompts.noLanguagesSelected(); + return err("cancelled"); + } + + return ok(languages); } - private async selectInputDirectory(): Promise<{ sourceDirectory: DirectoryPath; portalDirectory: DirectoryPath }> { + private async selectInputDirectory(): Promise< + ResultEx<{ sourceDirectory: DirectoryPath; portalDirectory: DirectoryPath }, string> + > { this.prompts.selectInputDirectoryStep(); - const workingDirectory = new DirectoryPath(await this.prompts.inputDirectoryPathPrompt()); + const inputDirectoryPath = await this.prompts.inputDirectoryPathPrompt(); + if (inputDirectoryPath === null) { + this.prompts.noInputDirectoryProvided(); + return err("cancelled"); + } - return { sourceDirectory: workingDirectory.join("src"), portalDirectory: workingDirectory.join("portal") }; + const workingDirectory = new DirectoryPath(inputDirectoryPath); + return ok({ sourceDirectory: workingDirectory.join("src"), portalDirectory: workingDirectory.join("portal") }); } //TODO: Remove tempDirectory as param. @@ -189,13 +213,12 @@ export class PortalQuickstartAction { sourceDirectory.toString(), this.portalScaffoldService.createBuildDirectory(tempDirectory, specDirectory, selectedLanguages) ); - if (result.isErr()) { return err(result.error); } await this.fileService.copyDirectoryContents(result.value, sourceDirectory); - this.prompts.displayBuildDirectoryAsTree(sourceDirectory.toString()); + this.prompts.displayBuildDirectoryAsTree(sourceDirectory); return ok(result.value); } diff --git a/src/commands/portal/quickstart.ts b/src/commands/portal/quickstart.ts index 5f24674bd..ce8defb3f 100644 --- a/src/commands/portal/quickstart.ts +++ b/src/commands/portal/quickstart.ts @@ -10,6 +10,8 @@ import { format, intro, outro } from "../../prompts/format.js"; export default class PortalQuickstart extends Command { static description = "Create your first API Portal using APIMatic's Docs as Code offering."; + static summary = "Get started with your first API Portal in four easy steps."; + static cmdTxt = format.cmd("apimatic", "portal", "quickstart"); static examples = [this.cmdTxt]; diff --git a/src/infrastructure/file-service.ts b/src/infrastructure/file-service.ts index d2ad66612..9def54fb3 100644 --- a/src/infrastructure/file-service.ts +++ b/src/infrastructure/file-service.ts @@ -5,6 +5,7 @@ import { FilePath } from "../types/file/filePath.js"; import { DirectoryPath } from "../types/file/directoryPath.js"; import { pipeline } from "stream"; import { promisify } from "util"; +import { DirectoryNode } from "../types/file/directoryNode.js"; export class FileService { public async fileExists(file: FilePath): Promise { @@ -88,6 +89,50 @@ export class FileService { public async copy(source: FilePath, destination: FilePath) { await fsExtra.copyFile(source.toString(), destination.toString()); } + + public removeQuotes(path: string): string { + const quotes = ['"', "'"]; + + for (const quote of quotes) { + if (path.startsWith(quote) && path.endsWith(quote) && path.length > 1) { + return this.removeQuotes(path.slice(1, -1)); // Recursive call + } + } + return path; + } + + public convertDirectoryStructureToJson( + directoryPath: string, + descriptions: { [key: string]: string }, + parentPath = "" + ): { [key: string]: DirectoryNode | string | null | undefined } { + const directoryStructure: { [key: string]: DirectoryNode | string | null | undefined } = {}; + + const items = fs.readdirSync(directoryPath); + items.forEach((item) => { + if (item === ".git") return; // Skip .git directory + + const itemPath = path.join(directoryPath, item); + const relativePath = path.join(parentPath, item); + const stats = fs.statSync(itemPath); + + if (stats.isDirectory()) { + const subdirectoryStructure = this.convertDirectoryStructureToJson(itemPath, descriptions, relativePath); + + const folderName = descriptions[path.normalize(relativePath)] + ? `${item} : ${descriptions[path.normalize(relativePath)]}` + : item; + + directoryStructure[folderName] = subdirectoryStructure; + } else { + directoryStructure[ + descriptions[path.normalize(relativePath)] ? `${item} : ${descriptions[path.normalize(relativePath)]}` : item + ] = null; + } + }); + + return directoryStructure; + } } const streamPipeline = promisify(pipeline); diff --git a/src/infrastructure/services/portal-scaffold-service.ts b/src/infrastructure/services/portal-scaffold-service.ts index 237acfef9..3a16b2cf0 100644 --- a/src/infrastructure/services/portal-scaffold-service.ts +++ b/src/infrastructure/services/portal-scaffold-service.ts @@ -6,10 +6,11 @@ import { ResourceContext } from "../../types/resource-context.js"; import { BuildContext } from "../../types/build-context.js"; import { FilePath } from "../../types/file/filePath.js"; +const zipUrl = `https://github.com/apimatic/static-portal-workflow/archive/refs/heads/master.zip` as const; +const repositoryFolderName = "static-portal-workflow-master" as const; + export class PortalScaffoldService { private readonly fileService: FileService = new FileService(); - private readonly zipUrl = `https://github.com/apimatic/static-portal-workflow/archive/refs/heads/master.zip`; - private readonly repositoryFolderName = "static-portal-workflow-master" as const; private readonly defaultSpecFileName = new FileName("openapi.json"); public async createBuildDirectory( @@ -17,34 +18,28 @@ export class PortalScaffoldService { specDirectory: DirectoryPath, selectedLanguages: string[] ): Promise> { - try { - const resourceContext = new ResourceContext(tempDirectory); - const result = await resourceContext.resolveTo(this.zipUrl, this.repositoryFolderName); - if (result.isErr()) { - return err("Unable to setup the build directory, please try again later."); - } - const extractedFolder = result.value.join(this.repositoryFolderName); - await this.fileService.deleteDirectory(extractedFolder.join(".github")); + const resourceContext = new ResourceContext(tempDirectory); + const result = await resourceContext.resolveTo(zipUrl, repositoryFolderName); + if (result.isErr()) { + return err(result.error); + } + const extractedFolder = result.value.join(repositoryFolderName); + await this.fileService.deleteDirectory(extractedFolder.join(".github")); - // Setup spec. - const tempSpecDirectory = extractedFolder.join("spec"); - // TODO: Replace this with SpecContext - await this.fileService.deleteFile(new FilePath(tempSpecDirectory, this.defaultSpecFileName)); - await this.fileService.copyDirectory(specDirectory, tempSpecDirectory); + // Setup spec. + const tempSpecDirectory = extractedFolder.join("spec"); + // TODO: Replace this with SpecContext + await this.fileService.deleteFile(new FilePath(tempSpecDirectory, this.defaultSpecFileName)); + await this.fileService.copyDirectory(specDirectory, tempSpecDirectory); - const buildContext = new BuildContext(extractedFolder); - const buildFile = await buildContext.getBuildFileContents(); - buildFile.generatePortal!.languageConfig = selectedLanguages.reduce((config, lang) => { - config[lang] = {}; - return config; - }, {} as { [key: string]: object }); - await buildContext.updateBuildFileContents(buildFile); + const buildContext = new BuildContext(extractedFolder); + const buildFile = await buildContext.getBuildFileContents(); + buildFile.generatePortal!.languageConfig = selectedLanguages.reduce((config, lang) => { + config[lang] = {}; + return config; + }, {} as { [key: string]: object }); + await buildContext.updateBuildFileContents(buildFile); - return ok(extractedFolder); - } catch { - return err( - "There was an error setting up your build directory. Please try again later. If the problem persists, please reach out to our team at support@apimatic.io " - ); - } + return ok(extractedFolder); } } diff --git a/src/prompts/portal/quickstart.ts b/src/prompts/portal/quickstart.ts index ad3a0cb2d..c23a268b9 100644 --- a/src/prompts/portal/quickstart.ts +++ b/src/prompts/portal/quickstart.ts @@ -2,33 +2,32 @@ import * as path from "path"; import fs from "fs"; import axios from "axios"; import treeify from "treeify"; -import { text, select, multiselect, log, isCancel, cancel, spinner } from "@clack/prompts"; +import { text, select, multiselect, log, isCancel, spinner } from "@clack/prompts"; import { getMessageInGreenColor, getMessageInRedColor } from "../../utils/utils.js"; -import { DirectoryNode } from "../../types/portal/quickstart.js"; import { UrlPath } from "../../types/file/urlPath.js"; -import { withSpinner } from "../format.js"; +import { format as f, withSpinner } from "../format.js"; import { DirectoryPath } from "../../types/file/directoryPath.js"; import { Result } from "neverthrow"; +import { FileService } from "../../infrastructure/file-service.js"; + +const vscodeExtensionUrl = + "https://marketplace.visualstudio.com/items?itemName=apimatic-developers.apimatic-for-vscode"; +const serverUrl = "http://localhost:3000"; +const referenceDocumentationUrl = "https://docs.apimatic.io/cli-getting-started/advanced-portal-setup"; +const descriptions: { [key: string]: string } = Object.entries({ + "APIMATIC-BUILD.json": "# Defines all configurations for the API portal, including programming languages and themes", + spec: "# Contains all API definition files", + content: "# Includes custom documentation pages in Markdown", + "content/toc.yml": "# Controls the structure of the side navigation bar in the API portal", + static: "# Includes all static files, such as images, GIFs, and PDFs" +}).reduce((acc, [key, value]) => { + acc[path.normalize(key)] = value; + return acc; +}, {} as { [key: string]: string }); +const defaultPortalDirectoryPath = process.cwd(); 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 referenceDocumentationUrl = - "\u001b[4mhttps://docs.apimatic.io/cli-getting-started/advanced-portal-setup\u001b[0m"; - private readonly defaultPortalDirectoryPath = process.cwd(); - private readonly descriptions: { [key: string]: string } = Object.entries({ - "APIMATIC-BUILD.json": - "# Defines all configurations for the API portal, including programming languages and themes", - spec: "# Contains all API definition files", - content: "# Includes custom documentation pages in Markdown", - "content/toc.yml": "# Controls the structure of the side navigation bar in the API portal", - static: "# Includes all static files, such as images, GIFs, and PDFs" - }).reduce((acc, [key, value]) => { - acc[path.normalize(key)] = value; - return acc; - }, {} as { [key: string]: string }); - + private readonly fileService = new FileService(); // TODO: Remove after refactoring validate action. private readonly spin = spinner(); @@ -61,7 +60,7 @@ export class PortalQuickstartPrompts { } //TODO: Very complex validation, needs to be improved. - public async specPathPrompt(defaultSpecUrl: UrlPath): Promise { + public async specPathPrompt(defaultSpecUrl: UrlPath): Promise { while (true) { const spec = await text({ message: `Provide a local path or a public URL for your OpenAPI definition file:`, @@ -70,7 +69,7 @@ export class PortalQuickstartPrompts { validate: (input) => { if (!input) return; - const cleanedPath = this.removeQuotes(input.trim() ?? ""); + const cleanedPath = this.fileService.removeQuotes(input.trim() ?? ""); if (!UrlPath.create(cleanedPath)) { const dirPath = path.resolve(cleanedPath); @@ -89,11 +88,10 @@ export class PortalQuickstartPrompts { }); if (isCancel(spec)) { - cancel("Operation cancelled."); - process.exit(1); + return null; } - const cleanedPath = this.removeQuotes(String(spec).trim()); + const cleanedPath = this.fileService.removeQuotes(String(spec).trim()); // Async validation for URLs if (UrlPath.create(cleanedPath)) { @@ -115,6 +113,10 @@ export class PortalQuickstartPrompts { } } + public noSpecProvided() { + log.error("Operation cancelled. No API Definition was provided."); + } + public specImportError(error: string) { log.error(error); } @@ -129,15 +131,14 @@ export class PortalQuickstartPrompts { options: [ { value: "no", - label: `1. Fix the issues using APIMatic's interactive VS Code Extension: ${this.vscodeExtensionUrl}` + label: `1. Fix the issues using APIMatic's interactive VS Code Extension: ${vscodeExtensionUrl}` }, { value: "yes", label: `2. Use an example API spec instead (recommended)` } ] }); if (isCancel(useDefaultSpec)) { - cancel("Operation cancelled."); - return process.exit(1); + return false; } return useDefaultSpec === "yes"; @@ -178,7 +179,7 @@ export class PortalQuickstartPrompts { log.step(message); } - public async selectLanguagesPrompt(): Promise { + public async selectLanguagesPrompt(): Promise { const languages = (await multiselect({ message: "Your API Portal will contain SDKs and SDK Documentation in the following Languages. Press enter to continue with all languages, or use the arrow keys and spacebar to customize your selection:", @@ -195,41 +196,44 @@ export class PortalQuickstartPrompts { })) as string[]; if (isCancel(languages)) { - cancel("Operation cancelled."); - return process.exit(1); + return null; } return ["http", ...languages]; } + public noLanguagesSelected() { + log.error("Operation cancelled. No programming languages were selected."); + } + public selectInputDirectoryStep() { const message = `Step 4 of 4: Generate source files for Docs as Code`; log.step(message); } - public async inputDirectoryPathPrompt(): Promise { + public async inputDirectoryPathPrompt(): Promise { const directory = await text({ 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 cleanedPath = this.removeQuotes(input?.trim() ?? ""); - const dirPath = path.resolve(cleanedPath); + const cleanedPath = this.fileService.removeQuotes(input?.trim() ?? ""); + const inputDirectory = new DirectoryPath(cleanedPath); - if (!fs.existsSync(dirPath) && dirPath != this.defaultPortalDirectoryPath) { + if (!fs.existsSync(inputDirectory.toString()) && inputDirectory.toString() != defaultPortalDirectoryPath) { return getMessageInRedColor("Error: The specified directory path does not exist. Please try again."); } - if (dirPath !== this.defaultPortalDirectoryPath) { - const files = fs.readdirSync(dirPath).filter((item) => !item.startsWith(".")); + if (inputDirectory.toString() !== defaultPortalDirectoryPath) { + const files = fs.readdirSync(inputDirectory.toString()).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." ); } - } else if (fs.existsSync(dirPath)) { + } else if (fs.existsSync(inputDirectory.toString())) { // For ignoring hidden files and folders in the current directory in MacOS. - const files = fs.readdirSync(dirPath).filter((item) => !item.startsWith(".")); + const files = fs.readdirSync(inputDirectory.toString()).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." @@ -240,17 +244,20 @@ export class PortalQuickstartPrompts { }); if (isCancel(directory)) { - cancel("Operation cancelled."); - return process.exit(1); + return null; } if (directory === "./") { - return this.defaultPortalDirectoryPath; + return defaultPortalDirectoryPath; } else { - return this.removeQuotes(String(directory).trim()); + return this.fileService.removeQuotes(String(directory).trim()); } } + public noInputDirectoryProvided() { + log.error("Operation cancelled. No build directory was provided."); + } + public createBuildDirectory(sourceDirectory: string, fn: Promise>) { return withSpinner( "Generating build directory", @@ -264,8 +271,11 @@ export class PortalQuickstartPrompts { log.error(message); } - public displayBuildDirectoryAsTree(buildDirectory: string): void { - const structuredBuildDirectory = this.convertDirectoryStructureToJson(buildDirectory) as treeify.TreeObject; + public displayBuildDirectoryAsTree(buildDirectory: DirectoryPath): void { + const structuredBuildDirectory = this.fileService.convertDirectoryStructureToJson( + buildDirectory.toString(), + descriptions + ) as treeify.TreeObject; const tree = treeify.asTree(structuredBuildDirectory, true, true); @@ -282,57 +292,16 @@ export class PortalQuickstartPrompts { } public nextSteps(buildDirectory: string): void { - log.step( - `Your API Portal is live at: ${this.serverUrl}\n` + - `Hot reload enabled! Edit files in ${buildDirectory} to see changes instantly reflected in your API Portal.\n` + - `Press CTRL+C to stop the server.` - ); - log.step( + const firstMessage = + `Your API Portal is live at: ${f.path(serverUrl)}\n` + + `Hot reload enabled! Edit files in ${f.path(buildDirectory)} to see changes instantly reflected in your API Portal.\n` + + `Press CTRL+C to stop the server.`; + const secondMessage = `What's next?\n` + - `- Use the API Playground or an SDK to call your API.\n` + - `- Customize the Portal theme, add API recipes and enable AI features: ${this.referenceDocumentationUrl}` - ); - } - - private removeQuotes(str: string): string { - const quotes = ['"', "'"]; - - for (const quote of quotes) { - if (str.startsWith(quote) && str.endsWith(quote) && str.length > 1) { - return this.removeQuotes(str.slice(1, -1)); // Recursive call - } - } - return str; - } - - private convertDirectoryStructureToJson(dirPath: string, parentPath = ""): DirectoryNode { - const directoryStructure: DirectoryNode = {}; - - const items = fs.readdirSync(dirPath); - items.forEach((item) => { - if (item === ".git") return; // Skip .git directory - - const itemPath = path.join(dirPath, item); - const relativePath = path.join(parentPath, item); - const stats = fs.statSync(itemPath); - - if (stats.isDirectory()) { - const subdirectoryStructure = this.convertDirectoryStructureToJson(itemPath, relativePath); - - const folderName = this.descriptions[path.normalize(relativePath)] - ? `${item} : ${this.descriptions[path.normalize(relativePath)]}` - : item; - - directoryStructure[folderName] = subdirectoryStructure; - } else { - directoryStructure[ - this.descriptions[path.normalize(relativePath)] - ? `${item} : ${this.descriptions[path.normalize(relativePath)]}` - : item - ] = null; - } - }); + `- Use the API Playground or an SDK to call your API.\n` + + `- Customize the Portal theme, add API recipes and enable AI features: ${f.path(referenceDocumentationUrl)}`; - return directoryStructure; + log.step(firstMessage); + log.step(secondMessage); } } diff --git a/src/types/portal/quickstart.ts b/src/types/file/directoryNode.ts similarity index 97% rename from src/types/portal/quickstart.ts rename to src/types/file/directoryNode.ts index 3d50da43e..2dd6df3e4 100644 --- a/src/types/portal/quickstart.ts +++ b/src/types/file/directoryNode.ts @@ -1,3 +1,3 @@ export interface DirectoryNode { [key: string]: DirectoryNode | string | null | undefined; -} +} \ No newline at end of file diff --git a/src/types/resource-context.ts b/src/types/resource-context.ts index 189428289..14495b37c 100644 --- a/src/types/resource-context.ts +++ b/src/types/resource-context.ts @@ -1,5 +1,5 @@ import * as path from "path"; -import { err, ok, Result } from "neverthrow"; +import { err, ok } from "neverthrow"; import { UrlPath } from "./file/urlPath.js"; import { FilePath } from "./file/filePath.js"; import { DirectoryPath } from "./file/directoryPath.js"; @@ -15,16 +15,15 @@ export class ResourceContext { constructor(private readonly tempDirectory: DirectoryPath) {} - public async resolveTo(resourcePath: string, destinationSubPath: string){ + public async resolveTo(resourcePath: string, destinationSubPath: string) { const urlPath = UrlPath.create(resourcePath); - const fileName = new FileName(path.basename(resourcePath)) + const fileName = new FileName(path.basename(resourcePath)); const destinationFilePath = new FilePath(this.tempDirectory, fileName); if (urlPath) { const downloadFileResult = await this.fileDownloadService.downloadFile(urlPath); if (downloadFileResult.isErr()) { - // TODO: Update message here - return err("Unable to download the file. Please verify that the provided URL is correct and publicly accessible. "); + return err(downloadFileResult.error); } await this.fileService.writeFile(destinationFilePath, downloadFileResult.value); } else { From 69f2476bdd7ad2290205d3a75dc5f577ef4acc85 Mon Sep 17 00:00:00 2001 From: Ali Asghar <75574550+aliasghar98@users.noreply.github.com> Date: Tue, 26 Aug 2025 16:52:25 +0500 Subject: [PATCH 19/77] refactor: moved constants back within class in portal scaffold service --- src/infrastructure/services/portal-scaffold-service.ts | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/src/infrastructure/services/portal-scaffold-service.ts b/src/infrastructure/services/portal-scaffold-service.ts index 3a16b2cf0..13fa74cc5 100644 --- a/src/infrastructure/services/portal-scaffold-service.ts +++ b/src/infrastructure/services/portal-scaffold-service.ts @@ -6,11 +6,10 @@ import { ResourceContext } from "../../types/resource-context.js"; import { BuildContext } from "../../types/build-context.js"; import { FilePath } from "../../types/file/filePath.js"; -const zipUrl = `https://github.com/apimatic/static-portal-workflow/archive/refs/heads/master.zip` as const; -const repositoryFolderName = "static-portal-workflow-master" as const; - export class PortalScaffoldService { private readonly fileService: FileService = new FileService(); + private readonly zipUrl = `https://github.com/apimatic/static-portal-workflow/archive/refs/heads/master.zip` as const; + private readonly repositoryFolderName = "static-portal-workflow-master" as const; private readonly defaultSpecFileName = new FileName("openapi.json"); public async createBuildDirectory( @@ -19,11 +18,11 @@ export class PortalScaffoldService { selectedLanguages: string[] ): Promise> { const resourceContext = new ResourceContext(tempDirectory); - const result = await resourceContext.resolveTo(zipUrl, repositoryFolderName); + const result = await resourceContext.resolveTo(this.zipUrl, this.repositoryFolderName); if (result.isErr()) { return err(result.error); } - const extractedFolder = result.value.join(repositoryFolderName); + const extractedFolder = result.value.join(this.repositoryFolderName); await this.fileService.deleteDirectory(extractedFolder.join(".github")); // Setup spec. From 5d1e2616fc4eb8b2625d467a1823b36108055eb2 Mon Sep 17 00:00:00 2001 From: Shield-Jaguar Date: Tue, 26 Aug 2025 18:15:28 +0500 Subject: [PATCH 20/77] refactor --- src/actions/portal/serve.ts | 20 ++++++++++--------- src/commands/portal/serve.ts | 3 ++- src/infrastructure/network-service.ts | 5 +++-- .../services/portal-serve-service.ts | 2 +- src/prompts/format.ts | 1 + src/prompts/portal/serve.ts | 12 ++++++----- 6 files changed, 25 insertions(+), 18 deletions(-) diff --git a/src/actions/portal/serve.ts b/src/actions/portal/serve.ts index 474af39c8..075fb99b4 100644 --- a/src/actions/portal/serve.ts +++ b/src/actions/portal/serve.ts @@ -4,10 +4,12 @@ import { ActionResult } from "../action-result.js"; import { CommandMetadata } from "../../types/common/command-metadata.js"; import { GenerateAction } from "./generate.js"; import { PortalServeService } from "../../infrastructure/services/portal-serve-service.js"; +import { NetworkService } from "../../infrastructure/network-service.js"; export class PortalServeAction { private readonly prompts: PortalServePrompts = new PortalServePrompts(); private readonly portalServeService: PortalServeService = new PortalServeService(); + private readonly networkService: NetworkService = new NetworkService(); private readonly configDir: DirectoryPath; private readonly commandMetadata: CommandMetadata; private readonly authKey: string | null; @@ -26,30 +28,30 @@ export class PortalServeAction { noReload: boolean, displayServeCommandMessages: boolean = true ): Promise { - const portAvailable = await this.portalServeService.isPortAvailable(port); - const serverPort = portAvailable ? port : await this.portalServeService.getServerPort(); - if (!portAvailable) { - this.prompts.portAlreadyInUse(port, serverPort); + + const servePort = await this.networkService.getServerPort([port, 3000, 3001, 3002]); + if (servePort != port) { + this.prompts.portAlreadyInUse(port, servePort); } const generatePortalAction = new GenerateAction(this.configDir, this.commandMetadata, this.authKey); const result = await generatePortalAction.execute(buildDirectory, portalDirectory, false, false); return result.mapAll>( async () => { - this.portalServeService.servePortal( + await this.portalServeService.servePortal( buildDirectory, portalDirectory, generatePortalAction.execute, - serverPort, + servePort, openInBrowser, noReload ); if (displayServeCommandMessages) { this.prompts.nextSteps( - buildDirectory.toString(), - portalDirectory.toString(), - serverPort.toString(), + buildDirectory, + portalDirectory, + servePort, noReload ); } diff --git a/src/commands/portal/serve.ts b/src/commands/portal/serve.ts index a2bca4071..55382aec0 100644 --- a/src/commands/portal/serve.ts +++ b/src/commands/portal/serve.ts @@ -37,7 +37,8 @@ export default class PortalServe extends Command { static cmdTxt = format.cmd("apimatic", "portal", "serve"); static examples = [ this.cmdTxt, - `${this.cmdTxt} ${format.flag("input", '"./"')} ` + + `${this.cmdTxt}` + + `${format.flag("input", '"./"')} ` + `${format.flag("destination", '"./portal"')} ` + `${format.flag("port", "3000")} ` + `${format.flag("open")} ` + diff --git a/src/infrastructure/network-service.ts b/src/infrastructure/network-service.ts index 1dadbc22d..f61dd6149 100644 --- a/src/infrastructure/network-service.ts +++ b/src/infrastructure/network-service.ts @@ -3,6 +3,7 @@ import net from "net"; import open from "open"; export class NetworkService { + public async isPortAvailable(port: number): Promise { return new Promise((resolve) => { const server = net @@ -22,7 +23,7 @@ export class NetworkService { return await getPort({ port: preferredPorts }); } - public openUrlInBrowser(url: string): void { - open(url); + public openUrlInBrowser(url: string) { + return open(url); } } diff --git a/src/infrastructure/services/portal-serve-service.ts b/src/infrastructure/services/portal-serve-service.ts index ca4faf3c4..ffacd7030 100644 --- a/src/infrastructure/services/portal-serve-service.ts +++ b/src/infrastructure/services/portal-serve-service.ts @@ -39,7 +39,7 @@ export class PortalServeService { const server = this.application .listen(serverPort, async () => { if (openInBrowser) { - this.networkService.openUrlInBrowser(`http://localhost:${serverPort}`); + await this.networkService.openUrlInBrowser(`http://localhost:${serverPort}`); } if (!noReload) { diff --git a/src/prompts/format.ts b/src/prompts/format.ts index 385b38650..031f9c018 100644 --- a/src/prompts/format.ts +++ b/src/prompts/format.ts @@ -8,6 +8,7 @@ export const format = { // Core element types var: (text: string) => pc.magenta(`'${text}'`), path: (text: string) => pc.cyan(`'${text}'`), + link: (text: string) => pc.cyan(`'${text}'`), // TODO: merge with Saeed's implementation cmd: (cmd: string, topic: string, action: string) => `${pc.blue(cmd)} ${pc.dim(topic)} ${pc.dim(action)}`, flag: (name: string, value: string | undefined = undefined) => { if (value) { diff --git a/src/prompts/portal/serve.ts b/src/prompts/portal/serve.ts index a84f520b9..f237ba5b7 100644 --- a/src/prompts/portal/serve.ts +++ b/src/prompts/portal/serve.ts @@ -1,4 +1,6 @@ import { log } from "@clack/prompts"; +import { format as f } from "../format.js"; +import { DirectoryPath } from "../../types/file/directoryPath.js"; export class PortalServePrompts { // TODO: Remove this method. @@ -15,16 +17,16 @@ export class PortalServePrompts { } public portAlreadyInUse(currentPort: number, availablePort: number) { - const message = `Port ${currentPort} is already in use. Available port ${availablePort} will be used.`; + const message = `Port ${f.var(currentPort.toString())} is already in use. Available port ${f.var(availablePort.toString())} will be used.`; log.step(message); } - public nextSteps(buildDirectory: string, portalDirectory: string, port: string, hotReloadDisabled: boolean): void { - log.message(`Portal successfully generated at: ${portalDirectory}`); - log.message(`Server running at: http://localhost:${port}`); + public nextSteps(buildDirectory: DirectoryPath, portalDirectory: DirectoryPath, port: number, hotReloadDisabled: boolean): void { + log.message(`Portal successfully generated at: ${f.path(portalDirectory.toString())}`); + log.message(`Server running at: ${f.link(`http://localhost:${port}`)}`); if (!hotReloadDisabled) { log.message(`Hot reload is enabled. Watching the following build folder for any changes:`); - log.message(`${buildDirectory}`); + log.message(`${f.path(buildDirectory.toString())}`); } log.message(`Press CTRL+C to stop the server.`); } From 07d2605bfca5e660f41d55fe823b902131e5ef55 Mon Sep 17 00:00:00 2001 From: Mujahid Daud Khan <37825767+Shield-Jaguar@users.noreply.github.com> Date: Tue, 26 Aug 2025 18:19:39 +0500 Subject: [PATCH 21/77] feat: add prompt and refactor in auth (#186) --- eslint.config.js | 1 + package-lock.json | 20 +++++++-- package.json | 1 + src/actions/auth/login.ts | 71 ++++++++++++++------------------ src/actions/auth/logout.ts | 17 ++++++++ src/actions/auth/status.ts | 29 +++++++++++++ src/actions/portal/quickstart.ts | 12 +++--- src/client-utils/auth-manager.ts | 4 +- src/client-utils/sdk-client.ts | 5 --- src/commands/auth/login.ts | 17 ++++---- src/commands/auth/logout.ts | 22 +++++----- src/commands/auth/status.ts | 26 +++++++----- src/prompts/auth/login.ts | 31 +++++++++++--- src/prompts/auth/logout.ts | 9 ++++ src/prompts/auth/status.ts | 31 ++++++++++++++ src/types/api/account.ts | 2 +- src/types/sdk/generate.ts | 16 +++++++ 17 files changed, 221 insertions(+), 93 deletions(-) create mode 100644 src/actions/auth/logout.ts create mode 100644 src/actions/auth/status.ts create mode 100644 src/prompts/auth/logout.ts create mode 100644 src/prompts/auth/status.ts diff --git a/eslint.config.js b/eslint.config.js index 4bf287062..04de4a1bd 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -12,6 +12,7 @@ export default [ sourceType: "module", globals: { process: "readonly", + setTimeout: "readonly", NodeJS: true, }, }, diff --git a/package-lock.json b/package-lock.json index 997a8d843..b5bab3d4f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -43,6 +43,7 @@ "devDependencies": { "@commitlint/cli": "^15.0.0", "@commitlint/config-conventional": "^15.0.0", + "@eslint/js": "^9.34.0", "@oclif/test": "^4.1.13", "@types/archiver": "^5.3.4", "@types/base-64": "^1.0.0", @@ -2416,9 +2417,9 @@ } }, "node_modules/@eslint/js": { - "version": "9.29.0", - "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.29.0.tgz", - "integrity": "sha512-3PIF4cBw/y+1u2EazflInpV+lYsSG0aByVIQzAgb1m1MhHFSbqTyNqtBKHgWf/9Ykud+DhILS9EGkmekVhbKoQ==", + "version": "9.34.0", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.34.0.tgz", + "integrity": "sha512-EoyvqQnBNsV1CWaEJ559rxXL4c8V92gxirbawSmVUOWXlsRxxQXl6LmCpdUblgxgSkDIqKnhzba2SjRTI/A5Rw==", "dev": true, "license": "MIT", "engines": { @@ -8151,6 +8152,19 @@ "url": "https://opencollective.com/eslint" } }, + "node_modules/eslint/node_modules/@eslint/js": { + "version": "9.29.0", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.29.0.tgz", + "integrity": "sha512-3PIF4cBw/y+1u2EazflInpV+lYsSG0aByVIQzAgb1m1MhHFSbqTyNqtBKHgWf/9Ykud+DhILS9EGkmekVhbKoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + } + }, "node_modules/eslint/node_modules/brace-expansion": { "version": "1.1.11", "dev": true, diff --git a/package.json b/package.json index 5048856b7..f875207a9 100644 --- a/package.json +++ b/package.json @@ -78,6 +78,7 @@ "devDependencies": { "@commitlint/cli": "^15.0.0", "@commitlint/config-conventional": "^15.0.0", + "@eslint/js": "^9.34.0", "@oclif/test": "^4.1.13", "@types/archiver": "^5.3.4", "@types/base-64": "^1.0.0", diff --git a/src/actions/auth/login.ts b/src/actions/auth/login.ts index 1f61d0081..77cbb6717 100644 --- a/src/actions/auth/login.ts +++ b/src/actions/auth/login.ts @@ -1,13 +1,16 @@ import { AuthService } from "../../infrastructure/services/auth-service.js"; import { ApiService } from "../../infrastructure/services/api-service.js"; import { DirectoryPath } from "../../types/file/directoryPath.js"; -import { err, ok, Result } from "neverthrow"; import { v4 as uuid } from "uuid"; import open from "open"; import { setAuthInfo } from "../../client-utils/auth-manager.js"; import { LoginPrompts } from "../../prompts/auth/login.js"; -import { getErrorMessage, ServiceError } from "../../infrastructure/api-utils.js"; import { CommandMetadata } from "../../types/common/command-metadata.js"; +import { ActionResult } from "../action-result.js"; +import { err, ok, Result } from "neverthrow"; +import { StatusAction } from "./status.js"; + +type LoginTimout = "TIMEOUT"; export class LoginAction { private readonly authService = new AuthService(); @@ -16,20 +19,30 @@ export class LoginAction { constructor(private readonly configDir: DirectoryPath, private readonly commandMetadata: CommandMetadata) {} - public async execute(apiKey: string | undefined = undefined): Promise> { - if (!apiKey) { - const result = await this.poolDeviceToken(this.commandMetadata.shell); - return ( - await result.asyncMap(async (token) => { - return await this.verifyKeyAndSave(token, this.commandMetadata.shell); - }) - ).andThen((r) => r); - } else { - return await this.verifyKeyAndSave(apiKey, this.commandMetadata.shell); + public async execute(apiKey: string | undefined = undefined): Promise { + const apiKeyResult = await this.pollDeviceToken(apiKey, this.commandMetadata.shell); + if (apiKeyResult.isErr()) { + this.prompts.loginTimeout(); + return ActionResult.failed(); + } + + // TODO: Use status endpoint here + // Problem, we don't have email info here, just the key and required for setAuthInfo + const accountInfoResult = + await this.prompts.accountInfoSpinner( + this.apiService.getAccountInfo(this.configDir, this.commandMetadata.shell, apiKeyResult.value) + ); + if (accountInfoResult.isErr()) { + this.prompts.invalidKeyProvided(accountInfoResult.error); + return ActionResult.failed(); } + await setAuthInfo(accountInfoResult.value.Email, apiKeyResult.value, false, this.configDir); + this.prompts.loginSuccessful(accountInfoResult.value.Email); + return ActionResult.success(); } - private async poolDeviceToken(shell: string): Promise> { + private async pollDeviceToken(apiKey: string | undefined, shell: string): Promise> { + if (apiKey) return ok(apiKey); const state = uuid(); this.prompts.openBrowser(); await open(this.authService.getDeviceLoginUrl(state)); @@ -40,37 +53,13 @@ export class LoginAction { while (true) { if (Date.now() - startTime > timeoutDuration) { - return err("Authentication timed out. Please try again."); + return err("TIMEOUT"); } - const result = await this.authService.getDeviceLoginToken(state, shell); - const token = result.match( - (res) => res.apiKey, - () => { - /* ignore errors */ - } - ); - if (token) return ok(token); - - // eslint-disable-next-line no-undef + if (result.isOk()) { + return ok(result.value.apiKey); + } await new Promise((resolve) => setTimeout(resolve, delayMs)); } } - - private async verifyKeyAndSave(apiKey: string, shell: string): Promise> { - const result = await this.apiService.getAccountInfo(this.configDir, shell, apiKey); - return result - .asyncMap(async (info) => { - await setAuthInfo(info.Email, apiKey, false, this.configDir); - return info.Email; - }) - .mapErr((e) => { - switch (e) { - case ServiceError.UnAuthorized: - return "The provided auth key is invalid"; - default: - return getErrorMessage(e); - } - }); - } } diff --git a/src/actions/auth/logout.ts b/src/actions/auth/logout.ts new file mode 100644 index 000000000..44e6c9e19 --- /dev/null +++ b/src/actions/auth/logout.ts @@ -0,0 +1,17 @@ +import { DirectoryPath } from "../../types/file/directoryPath.js"; +import { ActionResult } from "../action-result.js"; +import { removeAuthInfo } from "../../client-utils/auth-manager.js"; +import { LogoutPrompts } from "../../prompts/auth/logout.js"; + +export class LogoutAction { + + private readonly prompts = new LogoutPrompts(); + + constructor(private readonly configDir: DirectoryPath) {} + + public async execute(): Promise { + this.prompts.removeAuthInfo(); + await removeAuthInfo(this.configDir) + return ActionResult.success(); + } +} diff --git a/src/actions/auth/status.ts b/src/actions/auth/status.ts new file mode 100644 index 000000000..e77432bdc --- /dev/null +++ b/src/actions/auth/status.ts @@ -0,0 +1,29 @@ +import { ActionResult } from "../action-result.js"; +import { DirectoryPath } from "../../types/file/directoryPath.js"; +import { CommandMetadata } from "../../types/common/command-metadata.js"; +import { getAuthInfo } from "../../client-utils/auth-manager.js"; +import { StatusPrompts } from "../../prompts/auth/status.js"; +import { ApiService } from "../../infrastructure/services/api-service.js"; + +export class StatusAction { + private readonly prompts = new StatusPrompts(); + private readonly apiService = new ApiService(); + + constructor(private readonly configDir: DirectoryPath, private readonly commandMetadata: CommandMetadata) {} + + public async execute(authKey: string | null): Promise { + const accountInfo = await getAuthInfo(this.configDir.toString()); + if (accountInfo === null) { + return ActionResult.failed(); + } + const result = await this.prompts.accountInfoSpinner( + this.apiService.getAccountInfo(this.configDir, this.commandMetadata.shell, authKey) + ); + if (result.isErr()) { + this.prompts.invalidKeyProvided(result.error); + return ActionResult.failed(); + } + this.prompts.showAccountInfo(result.value); + return ActionResult.success(); + } +} diff --git a/src/actions/portal/quickstart.ts b/src/actions/portal/quickstart.ts index 7a40212ac..88ab4bbe2 100644 --- a/src/actions/portal/quickstart.ts +++ b/src/actions/portal/quickstart.ts @@ -112,13 +112,11 @@ export class PortalQuickstartAction { this.prompts.displayInfo("You need to be logged in to continue."); const loginResult = await new LoginAction(this.configDir, this.commandMetadata).execute(); - - if (loginResult.isErr()) { - this.prompts.logError(loginResult.error); - return Result.failure("Unable to login, please check your credentials and try again later."); - } - this.prompts.displaySuccess(`Logged in as: ${loginResult.value}`); - return Result.success("Authentication was successful."); + // TODO: fix error messages after refactoring + return loginResult.mapAll(() => + Result.success("Authentication was successful."), + () => Result.failure("Unable to login, please check your credentials and try again later."), + () => Result.failure("Unable to login, please check your credentials and try again later.")); } // TODO: create TempSpecContext and then refactor this. diff --git a/src/client-utils/auth-manager.ts b/src/client-utils/auth-manager.ts index a4e95f068..13addcb94 100644 --- a/src/client-utils/auth-manager.ts +++ b/src/client-utils/auth-manager.ts @@ -47,12 +47,12 @@ export async function setAuthInfo( return await fs.writeFile(configFilePath, JSON.stringify(credentials)); } -export async function removeAuthInfo(configDir: string): Promise { +export async function removeAuthInfo(configDir: DirectoryPath): Promise { const credentials: AuthInfo = { email: "", authKey: "" }; - const configFilePath = path.join(configDir, "config.json"); + const configFilePath = path.join(configDir.toString(), "config.json"); if (!fs.existsSync(configFilePath)) fs.createFileSync(configFilePath); diff --git a/src/client-utils/sdk-client.ts b/src/client-utils/sdk-client.ts index f17ae4f91..ffd50125d 100644 --- a/src/client-utils/sdk-client.ts +++ b/src/client-utils/sdk-client.ts @@ -23,11 +23,6 @@ export class SDKClient { return SDKClient.instance; } - public async logout(configDir: string): Promise { - await removeAuthInfo(configDir); - return "Logged out"; - } - public async status(configDir: string): Promise { try { let storedAuthInfo: AuthInfo | null = await getAuthInfo(configDir); diff --git a/src/commands/auth/login.ts b/src/commands/auth/login.ts index eada191ce..830f17b45 100644 --- a/src/commands/auth/login.ts +++ b/src/commands/auth/login.ts @@ -1,13 +1,18 @@ import { Command, Flags } from "@oclif/core"; import { DirectoryPath } from "../../types/file/directoryPath.js"; -import { LoginPrompts } from "../../prompts/auth/login.js"; import { LoginAction } from "../../actions/auth/login.js"; import { CommandMetadata } from "../../types/common/command-metadata.js"; +import { format, intro, outro } from "../../prompts/format.js"; export default class Login extends Command { + static summary = "Login to your APIMatic account"; + static description = "Login using your APIMatic credentials or an API Key"; - static examples = [`apimatic auth login`, `apimatic auth login --auth-key={api-key}`]; + private static cmdTxt = format.cmd('apimatic', 'auth' ,'login'); + static examples = [ + Login.cmdTxt, + `${Login.cmdTxt} ${format.flag('auth-key', '{api-key}')}`]; static flags = { "auth-key": Flags.string({ @@ -16,7 +21,6 @@ export default class Login extends Command { }) }; - private readonly prompts = new LoginPrompts(); async run() { const { @@ -31,11 +35,10 @@ export default class Login extends Command { commandName: Login.id, shell: this.config.shell }; + + intro("Login"); const loginAction = new LoginAction(new DirectoryPath(this.config.configDir), commandMetadata); const result = await loginAction.execute(authKey); - result.match( - (email) => this.prompts.loginSuccessful(email), - (error) => this.prompts.logError(error) - ); + outro(result); } } diff --git a/src/commands/auth/logout.ts b/src/commands/auth/logout.ts index 0ccc3d22f..b9b046734 100644 --- a/src/commands/auth/logout.ts +++ b/src/commands/auth/logout.ts @@ -1,20 +1,20 @@ import { Command } from "@oclif/core"; +import { format, intro, outro } from "../../prompts/format.js"; +import { LogoutAction } from "../../actions/auth/logout.js"; +import { DirectoryPath } from "../../types/file/directoryPath.js"; -import { SDKClient } from "../../client-utils/sdk-client.js"; +export default class Logout extends Command { + static summary = "Clears the local login credentials."; -export default class Login extends Command { - static description = "Clears the local login credentials."; + static description = "Clears the local login credentials. This will also clear any cached credentials from the CLI. To clear the browser credentials, please visit https://app.apimatic.io/account/manage."; - static examples = [`apimatic auth logout`]; + private static cmdTxt = format.cmd('apimatic', 'auth' ,'logout'); + static examples = [Logout.cmdTxt]; async run() { - try { - const client = SDKClient.getInstance(); - const response = await client.logout(this.config.configDir); - this.log(response); - } catch (error) { - this.error(error as string); - } + intro("Logout"); + const actionResult = await new LogoutAction(new DirectoryPath(this.config.configDir)).execute(); + outro(actionResult) } } diff --git a/src/commands/auth/status.ts b/src/commands/auth/status.ts index 0816d3a33..9660f795c 100644 --- a/src/commands/auth/status.ts +++ b/src/commands/auth/status.ts @@ -1,21 +1,27 @@ import { Command } from "@oclif/core"; -import { SDKClient } from "../../client-utils/sdk-client.js"; +import { format, intro, outro } from "../../prompts/format.js"; +import { StatusAction } from "../../actions/auth/status.js"; +import { DirectoryPath } from "../../types/file/directoryPath.js"; +import { CommandMetadata } from "../../types/common/command-metadata.js"; + export default class Status extends Command { static description = "View the currently logged in user."; - static examples = [`apimatic auth status`]; + private static cmdTxt = format.cmd('apimatic', 'auth' ,'status'); + static examples = [Status.cmdTxt]; async run() { - try { - const client = SDKClient.getInstance(); - // TODO: Add validation for auth key from the server. - const response = await client.status(this.config.configDir); - this.log(response); - } catch (error) { - this.error(error as string); - } + const commandMetadata: CommandMetadata = { + commandName: Status.id, + shell: this.config.shell + }; + + intro('Status') + const statusAction = new StatusAction(new DirectoryPath(this.config.configDir), commandMetadata); + const actionResult = await statusAction.execute(null); + outro(actionResult) } } diff --git a/src/prompts/auth/login.ts b/src/prompts/auth/login.ts index 79ef7271a..1fe5bdb03 100644 --- a/src/prompts/auth/login.ts +++ b/src/prompts/auth/login.ts @@ -1,15 +1,34 @@ import { log } from "@clack/prompts"; +import { getErrorMessage, ServiceError } from "../../infrastructure/api-utils.js"; +import { SubscriptionInfo } from "../../types/api/account.js"; +import { Result } from "neverthrow"; +import { withSpinner } from "../format.js"; export class LoginPrompts { - logError(error: string) { - log.error(error); - } - - loginSuccessful(email: string) { + public loginSuccessful(email: string) { log.success(`Successfully logged in as ${email}`); } - openBrowser() { + public openBrowser() { log.info("Please continue with authentication in the opened browser window."); } + + public invalidKeyProvided(serviceError: ServiceError) { + const message = + serviceError === ServiceError.NetworkError ? "Invalid API key provided." : getErrorMessage(serviceError); + log.error(message); + } + + public loginTimeout() { + log.error("Authentication timed out. Please try again."); + } + + public accountInfoSpinner(fn: Promise>) { + return withSpinner( + "Retrieving your subscription info", + "Retrieved subscription info", + "Error retrieving subscription info", + fn + ); + } } diff --git a/src/prompts/auth/logout.ts b/src/prompts/auth/logout.ts new file mode 100644 index 000000000..023d4e7e2 --- /dev/null +++ b/src/prompts/auth/logout.ts @@ -0,0 +1,9 @@ +import { log } from "@clack/prompts"; + + +export class LogoutPrompts { + + public removeAuthInfo() { + log.info("Removing auth info"); + } +} diff --git a/src/prompts/auth/status.ts b/src/prompts/auth/status.ts new file mode 100644 index 000000000..eb3d08282 --- /dev/null +++ b/src/prompts/auth/status.ts @@ -0,0 +1,31 @@ +import { Result } from "neverthrow"; +import { SubscriptionInfo } from "../../types/api/account.js"; +import { getErrorMessage, ServiceError } from "../../infrastructure/api-utils.js"; +import { format, withSpinner } from "../format.js"; +import { log } from "@clack/prompts"; +import { mapLanguages } from "../../types/sdk/generate.js"; + +export class StatusPrompts { + public accountInfoSpinner(fn: Promise>) { + return withSpinner( + "Retrieving your subscription info", + "Retrieved subscription info", + "Error retrieving subscription info", + fn + ); + } + + public invalidKeyProvided(serviceError: ServiceError) { + const message = + serviceError === ServiceError.UnAuthorized ? "Invalid API key provided." : getErrorMessage(serviceError); + log.error(message); + } + + public showAccountInfo(info: SubscriptionInfo) { + const languages = mapLanguages(info.allowedLanguages); + const message = `Account Information: + Email: ${format.var(info.Email)} + Allowed Languages: ${languages.map((language) => format.var(language)).join(", ")}`; + log.info(`${message}`); + } +} diff --git a/src/types/api/account.ts b/src/types/api/account.ts index 47bd7439c..1c33c5abf 100644 --- a/src/types/api/account.ts +++ b/src/types/api/account.ts @@ -4,7 +4,7 @@ export interface SubscriptionInfo { FullName: string; SecurityStamp: string; tenantId: string; - allowedLanguages: string; // might be a comma-separated string or code — clarify if needed + allowedLanguages: number; // might be a comma-separated string or code — clarify if needed isPackagePublishingAllowed: boolean; ApiCopilotKeys: string[]; } diff --git a/src/types/sdk/generate.ts b/src/types/sdk/generate.ts index d935e304a..7cd6cf382 100644 --- a/src/types/sdk/generate.ts +++ b/src/types/sdk/generate.ts @@ -7,3 +7,19 @@ export enum Language { TYPESCRIPT = "typescript", GO = "go" } + +const languageMap: { [key: number]: Language } = { + 1: Language.CSHARP, + 2: Language.GO, + 4: Language.JAVA, + 8: Language.PHP, + 16: Language.PYTHON, + 32: Language.RUBY, + 128: Language.TYPESCRIPT, +}; + +export function mapLanguages(languageFlag: number): Language[] { + return Object.entries(languageMap) + .filter(([flag]) => (languageFlag & parseInt(flag)) !== 0) + .map(([, language]) => language); +} From 37b5e0ce646ff211d81003f775969186882f6650 Mon Sep 17 00:00:00 2001 From: Shield-Jaguar Date: Tue, 26 Aug 2025 19:28:26 +0500 Subject: [PATCH 22/77] refactor: work in progress --- src/actions/portal/serve.ts | 217 +++++++++++++++--- .../portal/serve/portal-watcher.ts | 50 +--- src/infrastructure/launcher-service.ts | 6 + src/infrastructure/network-service.ts | 5 - .../services/portal-serve-service.ts | 106 --------- 5 files changed, 190 insertions(+), 194 deletions(-) diff --git a/src/actions/portal/serve.ts b/src/actions/portal/serve.ts index 075fb99b4..61cfbe36d 100644 --- a/src/actions/portal/serve.ts +++ b/src/actions/portal/serve.ts @@ -3,13 +3,27 @@ import { DirectoryPath } from "../../types/file/directoryPath.js"; import { ActionResult } from "../action-result.js"; import { CommandMetadata } from "../../types/common/command-metadata.js"; import { GenerateAction } from "./generate.js"; -import { PortalServeService } from "../../infrastructure/services/portal-serve-service.js"; import { NetworkService } from "../../infrastructure/network-service.js"; +import { createServer } from "livereload"; +import connectLiveReload from "connect-livereload"; +import express, { Express } from "express"; +import { UrlPath } from "../../types/file/urlPath.js"; +import console from "console"; +import { PortalWatcher } from "../../application/portal/serve/portal-watcher.js"; +import { LauncherService } from "../../infrastructure/launcher-service.js"; +import { WatcherHandler } from "../../application/portal/serve/watcher-handler.js"; +import chokidar from "chokidar"; +import crypto from "crypto"; +import { Mutex } from "async-mutex"; export class PortalServeAction { private readonly prompts: PortalServePrompts = new PortalServePrompts(); - private readonly portalServeService: PortalServeService = new PortalServeService(); private readonly networkService: NetworkService = new NetworkService(); + private readonly portalWatcher: PortalWatcher = new PortalWatcher(); + private readonly launcherService: LauncherService = new LauncherService(); + private readonly watcherHandler: WatcherHandler = new WatcherHandler(); + + private readonly application: Express = express(); private readonly configDir: DirectoryPath; private readonly commandMetadata: CommandMetadata; private readonly authKey: string | null; @@ -29,41 +43,176 @@ export class PortalServeAction { displayServeCommandMessages: boolean = true ): Promise { - const servePort = await this.networkService.getServerPort([port, 3000, 3001, 3002]); - if (servePort != port) { - this.prompts.portAlreadyInUse(port, servePort); - } - const generatePortalAction = new GenerateAction(this.configDir, this.commandMetadata, this.authKey); - const result = await generatePortalAction.execute(buildDirectory, portalDirectory, false, false); - return result.mapAll>( - async () => { - await this.portalServeService.servePortal( - buildDirectory, - portalDirectory, - generatePortalAction.execute, - servePort, - openInBrowser, - noReload - ); - if (displayServeCommandMessages) { - this.prompts.nextSteps( - buildDirectory, - portalDirectory, - servePort, - noReload - ); + + const watcher = chokidar.watch(buildDirectory.toString(), { + ignored: [/(^|[/\\])\..+/], + ignoreInitial: true, + persistent: true, + awaitWriteFinish: true, + atomic: true + }); + + const deletedDirectories = new Set(); + const eventQueue = new Map(); + const mutex = new Mutex(); + + + // live reload code + + const liveReloadServer = createServer(); + this.application.use(connectLiveReload()) + .use(express.static(portalDirectory.toString(), { extensions: ["html"] })) + .listen(5034); + + if (openInBrowser) { + await this.launcherService.openUrlInBrowser(new UrlPath(`http://localhost:${5034}`)); + } + + // end live reload code + + + watcher + .on("all", async (event, path) => { + // triggers folder deletion as a single event + if (event == "unlinkDir") { + deletedDirectories.add(path); + } + + if (event == "unlink") { + for (const dir of deletedDirectories) { + if (path.startsWith(dir)) { + return; + } + } } - return ActionResult.success(); - }, - async () => { - return ActionResult.failed(); - }, - async () => { - return ActionResult.cancelled(); - } - ); + const eventId: string = `${Date.now()}-${crypto.randomUUID()}`; + + await mutex.runExclusive(async () => { + eventQueue.clear(); + eventQueue.set(eventId, path); + }); + + await this.watcherHandler.execute(async () => { + const result = await generatePortalAction.execute(buildDirectory, portalDirectory, true, false); + // TODO: check for result + + liveReloadServer.refresh(portalDirectory.toString()); + }); + }) + .on("error", () => { + console.error( + "An unexpected error occurred while watching your build folder for changes. Please try again later. If the issue persists, contact our team at support@apimatic.io" + ); + watcher.close(); + }); + + return ActionResult.success(); + + // TODO: check if i can show prompt + //watcher.close(); + + + + + + + + + + + + + + + + + + + // const servePort = await this.networkService.getServerPort([port, 3000, 3001, 3002]); + // if (servePort != port) { + // this.prompts.portAlreadyInUse(port, servePort); + // } + + + + //const generatePortalAction = new GenerateAction(this.configDir, this.commandMetadata, this.authKey); + //const result = await generatePortalAction.execute(buildDirectory, portalDirectory, false, false); + + + + + // watcher (src) -> generate() -> -> reload() + // liveReload (des) -> watcher (automated) -> generate() -> + + // assuming result is success + + + // + + // const liveReloadServer = createServer(); + // liveReloadServer.watch(portalDirectory.toString()); + // this.application.use(connectLiveReload()); + // this.application.use(express.static(portalDirectory.toString(), { extensions: ["html"] })); + // + // const server = this.application + // .listen(servePort, async () => { + // if (openInBrowser) { + // await this.launcherService.openUrlInBrowser(new UrlPath(`http://localhost:${servePort}`)); + // } + // + // if (!noReload) { + // await this.portalWatcher.watchAndRegeneratePortalOnChange(buildDirectory, portalDirectory, generatePortalAction.execute); + // } + // + // if (process.platform !== "darwin") { + // //For non-macOS users. + // if (process.stdin.setRawMode) { + // process.stdin.setRawMode(false); + // } + // } + // }) + // .on("error", () => {}); + // + // const shutdown = async () => { + // console.log("Shutting down server..."); + // if (liveReloadServer) { + // liveReloadServer.close(); + // } + // if (server) { + // server.close(); + // } + // console.log("Server shut down successfully."); + // // TODO: Find a better way. + // process.exit(0); + // }; + // + // process.on("SIGINT", shutdown); + // process.on("SIGTERM", shutdown); + + + // return result.mapAll>( + // async () => { + // + // if (displayServeCommandMessages) { + // this.prompts.nextSteps( + // buildDirectory, + // portalDirectory, + // servePort, + // noReload + // ); + // } + // + // return ActionResult.success(); + // }, + // async () => { + // return ActionResult.failed(); + // }, + // async () => { + // return ActionResult.cancelled(); + // } + // ); } } diff --git a/src/application/portal/serve/portal-watcher.ts b/src/application/portal/serve/portal-watcher.ts index 393588115..682046ef1 100644 --- a/src/application/portal/serve/portal-watcher.ts +++ b/src/application/portal/serve/portal-watcher.ts @@ -7,11 +7,7 @@ import { DirectoryPath } from "../../../types/file/directoryPath.js"; import { ActionResult } from "../../../actions/action-result.js"; export class PortalWatcher { - private watcherHandler: WatcherHandler; - - constructor() { - this.watcherHandler = new WatcherHandler(); - } + private readonly watcherHandler: WatcherHandler = new WatcherHandler(); public async watchAndRegeneratePortalOnChange( buildDirectory: DirectoryPath, @@ -24,51 +20,7 @@ export class PortalWatcher { ) => Promise ) { //Regex matches any hidden files and folders. - const watcher = chokidar.watch(buildDirectory.toString(), { - ignored: [/(^|[/\\])\..+/], - ignoreInitial: true, - persistent: true, - awaitWriteFinish: true, - atomic: true - }); - - const deletedDirectories = new Set(); - const eventQueue = new Map(); - const mutex = new Mutex(); - - watcher - .on("all", async (event, path) => { - if (event == "unlinkDir") { - deletedDirectories.add(path); - } - - if (event == "unlink") { - for (const dir of deletedDirectories) { - if (path.startsWith(dir)) { - return; - } - } - } - - const eventId: string = `${Date.now()}-${crypto.randomUUID()}`; - - await mutex.runExclusive(async () => { - eventQueue.clear(); - eventQueue.set(eventId, path); - }); - - await this.watcherHandler.execute(async () => { - await this.handleFileChange(buildDirectory, portalDirectory, eventQueue, eventId, generatePortal); - }); - }) - .on("error", () => { - console.error( - "An unexpected error occurred while watching your build folder for changes. Please try again later. If the issue persists, contact our team at support@apimatic.io" - ); - watcher.close(); - }); - return watcher; } // TODO: Remove this method. Figure out a better way to do this. diff --git a/src/infrastructure/launcher-service.ts b/src/infrastructure/launcher-service.ts index 5570f8be6..d333d6b59 100644 --- a/src/infrastructure/launcher-service.ts +++ b/src/infrastructure/launcher-service.ts @@ -2,6 +2,8 @@ import { FilePath } from "../types/file/filePath.js"; import { execa } from "execa"; import os from "os"; import { spawn } from "child_process"; +import open from "open"; +import { UrlPath } from "../types/file/urlPath.js"; export class LauncherService { @@ -47,4 +49,8 @@ export class LauncherService { // Silently ignore errors } } + + public openUrlInBrowser(url: UrlPath) { + return open(url.toString()); + } } diff --git a/src/infrastructure/network-service.ts b/src/infrastructure/network-service.ts index f61dd6149..07fb8a0ba 100644 --- a/src/infrastructure/network-service.ts +++ b/src/infrastructure/network-service.ts @@ -1,6 +1,5 @@ import getPort from "get-port"; import net from "net"; -import open from "open"; export class NetworkService { @@ -22,8 +21,4 @@ export class NetworkService { public async getServerPort(preferredPorts: number[]): Promise { return await getPort({ port: preferredPorts }); } - - public openUrlInBrowser(url: string) { - return open(url); - } } diff --git a/src/infrastructure/services/portal-serve-service.ts b/src/infrastructure/services/portal-serve-service.ts index ffacd7030..e69de29bb 100644 --- a/src/infrastructure/services/portal-serve-service.ts +++ b/src/infrastructure/services/portal-serve-service.ts @@ -1,106 +0,0 @@ -import express, { Express } from "express"; -import { Server } from "http"; -import { LiveReloadServer, createServer } from "livereload"; -import connectLiveReload from "connect-livereload"; -import { ok, err, Result } from "neverthrow"; -import console from "console"; -import { DirectoryPath } from "../../types/file/directoryPath.js"; -import { NetworkService } from "../network-service.js"; -import { PortalWatcher } from "../../application/portal/serve/portal-watcher.js"; -import { ActionResult } from "../../actions/action-result.js"; - -export class PortalServeService { - private readonly application: Express = express(); - private readonly portalWatcher: PortalWatcher = new PortalWatcher(); - private readonly networkService: NetworkService = new NetworkService(); - - public async servePortal( - buildDirectory: DirectoryPath, - portalDirectory: DirectoryPath, - generatePortal: ( - buildDirectory: DirectoryPath, - portalDirectory: DirectoryPath, - force: boolean, - zipPortal: boolean - ) => Promise, - serverPort: number, - openInBrowser: boolean, - noReload: boolean - ): Promise> { - const createLiveReloadServerResult = await this.createLiveReloadServer(); - if (createLiveReloadServerResult.isErr()) { - return err("Failed to create live reload server."); - } - - createLiveReloadServerResult.value.watch(portalDirectory.toString()); - this.application.use(connectLiveReload()); - this.application.use(express.static(portalDirectory.toString(), { extensions: ["html"] })); - - const server = this.application - .listen(serverPort, async () => { - if (openInBrowser) { - await this.networkService.openUrlInBrowser(`http://localhost:${serverPort}`); - } - - if (!noReload) { - await this.portalWatcher.watchAndRegeneratePortalOnChange(buildDirectory, portalDirectory, generatePortal); - } - - if (process.platform !== "darwin") { - //For non-macOS users. - if (process.stdin.setRawMode) { - process.stdin.setRawMode(false); - } - } - }) - .on("error", () => {}); - - const shutdown = async () => { - console.log("Shutting down server..."); - this.cleanUp(createLiveReloadServerResult.value, server); - console.log("Server shut down successfully."); - // TODO: Find a better way. - process.exit(0); - }; - - process.on("SIGINT", shutdown); - process.on("SIGTERM", shutdown); - - return ok(server); - } - - // TODO: Remove this and find a better way. - public async isPortAvailable(port: number) { - return this.networkService.isPortAvailable(port); - } - - // TODO: Remove this and find a better way. - public async getServerPort() { - return this.networkService.getServerPort([3000, 3001, 3002]); - } - - private async cleanUp(liveReloadServer: LiveReloadServer, server: Server): Promise { - if (liveReloadServer) { - liveReloadServer.close(); - } - if (server) { - server.close(); - } - } - - private async createLiveReloadServer(): Promise> { - try { - const preferredPorts = [35729, 35730, 35731]; - const availablePort = await this.networkService.getServerPort(preferredPorts); - const liveReloadServer = createServer({ - port: availablePort - }); - - return ok(liveReloadServer); - } catch { - return err( - "An unexpected error occurred while serving your portal, please try again later. If the issue persists, contact our team at support@apimatic.io for assistance." - ); - } - } -} From cbe1aef7b7d740c16c04ccf75c78f6b110984c42 Mon Sep 17 00:00:00 2001 From: Shield-Jaguar Date: Wed, 27 Aug 2025 10:37:51 +0500 Subject: [PATCH 23/77] feat: update serve prompting --- src/actions/portal/serve.ts | 77 +++++++++---------- .../portal/serve/portal-watcher.ts | 50 ------------ .../portal/serve/watcher-handler.ts | 2 +- src/prompts/portal/serve.ts | 24 ++++++ 4 files changed, 62 insertions(+), 91 deletions(-) delete mode 100644 src/application/portal/serve/portal-watcher.ts diff --git a/src/actions/portal/serve.ts b/src/actions/portal/serve.ts index 61cfbe36d..adddd64e0 100644 --- a/src/actions/portal/serve.ts +++ b/src/actions/portal/serve.ts @@ -9,7 +9,6 @@ import connectLiveReload from "connect-livereload"; import express, { Express } from "express"; import { UrlPath } from "../../types/file/urlPath.js"; import console from "console"; -import { PortalWatcher } from "../../application/portal/serve/portal-watcher.js"; import { LauncherService } from "../../infrastructure/launcher-service.js"; import { WatcherHandler } from "../../application/portal/serve/watcher-handler.js"; import chokidar from "chokidar"; @@ -19,14 +18,14 @@ import { Mutex } from "async-mutex"; export class PortalServeAction { private readonly prompts: PortalServePrompts = new PortalServePrompts(); private readonly networkService: NetworkService = new NetworkService(); - private readonly portalWatcher: PortalWatcher = new PortalWatcher(); private readonly launcherService: LauncherService = new LauncherService(); - private readonly watcherHandler: WatcherHandler = new WatcherHandler(); + private readonly watcherHandler: WatcherHandler = new WatcherHandler(); private readonly application: Express = express(); private readonly configDir: DirectoryPath; private readonly commandMetadata: CommandMetadata; private readonly authKey: string | null; + private isPortalServed = false; public constructor(configDir: DirectoryPath, commandMetadata: CommandMetadata, authKey: string | null = null) { this.configDir = configDir; @@ -42,13 +41,11 @@ export class PortalServeAction { noReload: boolean, displayServeCommandMessages: boolean = true ): Promise { - const generatePortalAction = new GenerateAction(this.configDir, this.commandMetadata, this.authKey); - const watcher = chokidar.watch(buildDirectory.toString(), { ignored: [/(^|[/\\])\..+/], - ignoreInitial: true, + ignoreInitial: false, // TODO: check this flag with Saeed persistent: true, awaitWriteFinish: true, atomic: true @@ -58,20 +55,22 @@ export class PortalServeAction { const eventQueue = new Map(); const mutex = new Mutex(); - // live reload code + const servePort = await this.networkService.getServerPort([port, 3000, 3001, 3002]); + if (servePort != port) { + this.prompts.portAlreadyInUse(port, servePort); + } + const liveReloadServer = createServer(); - this.application.use(connectLiveReload()) + this.application + .use(connectLiveReload()) .use(express.static(portalDirectory.toString(), { extensions: ["html"] })) - .listen(5034); + .listen(servePort); - if (openInBrowser) { - await this.launcherService.openUrlInBrowser(new UrlPath(`http://localhost:${5034}`)); - } - // end live reload code + // end live reload code watcher .on("all", async (event, path) => { @@ -95,10 +94,26 @@ export class PortalServeAction { eventQueue.set(eventId, path); }); + + await this.watcherHandler.execute(async () => { + + if (this.isPortalServed) { + this.prompts.changesDetected(); + } + const result = await generatePortalAction.execute(buildDirectory, portalDirectory, true, false); // TODO: check for result + const portalUrl = new UrlPath(`http://localhost:${servePort}`); + if (!this.isPortalServed) { + this.prompts.portalServed(portalUrl) + if (openInBrowser){ + await this.launcherService.openUrlInBrowser(portalUrl); + } + this.isPortalServed = true; + } + this.prompts.waitingForChanges(); liveReloadServer.refresh(portalDirectory.toString()); }); }) @@ -109,6 +124,15 @@ export class PortalServeAction { watcher.close(); }); + // Wait for SIGINT or SIGTERM + await this.prompts.blockPrompt(); + + // TODO: find a better way to stop server + await watcher.close(); + this.watcherHandler.close(); + liveReloadServer.close(); + + // TODO: return stopped return ActionResult.success(); // TODO: check if i can show prompt @@ -116,40 +140,14 @@ export class PortalServeAction { - - - - - - - - - - - - - - - - // const servePort = await this.networkService.getServerPort([port, 3000, 3001, 3002]); - // if (servePort != port) { - // this.prompts.portAlreadyInUse(port, servePort); - // } - - - //const generatePortalAction = new GenerateAction(this.configDir, this.commandMetadata, this.authKey); //const result = await generatePortalAction.execute(buildDirectory, portalDirectory, false, false); - - - // watcher (src) -> generate() -> -> reload() // liveReload (des) -> watcher (automated) -> generate() -> // assuming result is success - // // const liveReloadServer = createServer(); @@ -192,7 +190,6 @@ export class PortalServeAction { // process.on("SIGINT", shutdown); // process.on("SIGTERM", shutdown); - // return result.mapAll>( // async () => { // diff --git a/src/application/portal/serve/portal-watcher.ts b/src/application/portal/serve/portal-watcher.ts deleted file mode 100644 index 682046ef1..000000000 --- a/src/application/portal/serve/portal-watcher.ts +++ /dev/null @@ -1,50 +0,0 @@ -import chokidar from "chokidar"; -import crypto from "crypto"; -import console from "console"; -import { Mutex } from "async-mutex"; -import { WatcherHandler } from "./watcher-handler.js"; -import { DirectoryPath } from "../../../types/file/directoryPath.js"; -import { ActionResult } from "../../../actions/action-result.js"; - -export class PortalWatcher { - private readonly watcherHandler: WatcherHandler = new WatcherHandler(); - - public async watchAndRegeneratePortalOnChange( - buildDirectory: DirectoryPath, - portalDirectory: DirectoryPath, - generatePortal: ( - buildDirectory: DirectoryPath, - portalDirectory: DirectoryPath, - force: boolean, - zipPortal: boolean - ) => Promise - ) { - //Regex matches any hidden files and folders. - - } - - // TODO: Remove this method. Figure out a better way to do this. - public cancelPendingOperations(): void { - if (this.watcherHandler) { - this.watcherHandler.cancel(); - } - } - - protected async handleFileChange( - buildDirectory: DirectoryPath, - portalDirectory: DirectoryPath, - eventQueue: Map, - eventId: string, - generatePortal: ( - buildDirectory: DirectoryPath, - portalDirectory: DirectoryPath, - force: boolean, - zipPortal: boolean - ) => Promise - ): Promise { - if (!eventQueue.has(eventId)) { - return; - } - await generatePortal(buildDirectory, portalDirectory, true, false); - } -} diff --git a/src/application/portal/serve/watcher-handler.ts b/src/application/portal/serve/watcher-handler.ts index 3883aa0e9..85c51b8c0 100644 --- a/src/application/portal/serve/watcher-handler.ts +++ b/src/application/portal/serve/watcher-handler.ts @@ -51,7 +51,7 @@ export class WatcherHandler { } // Method to clear any pending execution. - public cancel(): void { + public close(): void { if (this.debounceTimer) { clearTimeout(this.debounceTimer); this.debounceTimer = null; diff --git a/src/prompts/portal/serve.ts b/src/prompts/portal/serve.ts index f237ba5b7..9c92a0fcc 100644 --- a/src/prompts/portal/serve.ts +++ b/src/prompts/portal/serve.ts @@ -1,6 +1,8 @@ import { log } from "@clack/prompts"; import { format as f } from "../format.js"; import { DirectoryPath } from "../../types/file/directoryPath.js"; +import { once } from "events"; +import { UrlPath } from "../../types/file/urlPath.js"; export class PortalServePrompts { // TODO: Remove this method. @@ -30,4 +32,26 @@ export class PortalServePrompts { } log.message(`Press CTRL+C to stop the server.`); } + + public portalServed(urlPath: UrlPath) { + const message = `Server running at: ${f.link(urlPath.toString())}`; + log.message(message); + } + + public waitingForChanges() { + const message = "Watching for changes... Press CTRL+C to stop." + log.message(message); + } + + public async blockPrompt() { + await Promise.race([ + once(process, "SIGINT"), + once(process, "SIGTERM") + ]); + } + + public changesDetected() { + const message = "Changes detected..." + log.info(message); + } } From 5639864d5ea55a930345fa6a40d5eab1b28ee883 Mon Sep 17 00:00:00 2001 From: Ali Asghar Date: Wed, 27 Aug 2025 13:32:27 +0500 Subject: [PATCH 24/77] fix: portal serve command not closing properly and incorrect handling of spec file in quickstart --- src/actions/portal/quickstart.ts | 13 +++++++++++-- src/actions/portal/serve.ts | 27 +++++++++++++-------------- 2 files changed, 24 insertions(+), 16 deletions(-) diff --git a/src/actions/portal/quickstart.ts b/src/actions/portal/quickstart.ts index aee30c8b0..b1118f618 100644 --- a/src/actions/portal/quickstart.ts +++ b/src/actions/portal/quickstart.ts @@ -16,6 +16,7 @@ import { ValidationService } from "../../infrastructure/services/validation-serv import { ResourceContext } from "../../types/resource-context.js"; import { FileName } from "../../types/file/fileName.js"; import { CommandMetadata } from "../../types/common/command-metadata.js"; +import { createResourceInput } from "../../types/file/resource-input.js"; const defaultSpecUrl: UrlPath = new UrlPath( "https://raw.githubusercontent.com/apimatic/static-portal-workflow/refs/heads/master/spec/openapi.json" @@ -126,12 +127,20 @@ export class PortalQuickstartAction { return err("Operation cancelled. No API Definition was provided."); } + const urlPath = UrlPath.create(inputPath); const resourceContext = new ResourceContext(tempDirectory); - const result = await resourceContext.resolveTo(new UrlPath(inputPath)); + const result = + urlPath === undefined + ? await resourceContext.resolveTo(createResourceInput(inputPath)) + : await resourceContext.resolveTo(urlPath); if (result.isErr()) { return err(result.error); } - return ok(new DirectoryPath(result.value.toString(), "spec")); + + const specDirectory = tempDirectory.join("spec"); + await this.fileService.createDirectoryIfNotExists(specDirectory); + await this.fileService.copy(result.value, result.value.replaceDirectory(specDirectory)); + return ok(specDirectory); } // TODO: Needs to be refactored after refactoring validate action. diff --git a/src/actions/portal/serve.ts b/src/actions/portal/serve.ts index adddd64e0..16f0ffc9e 100644 --- a/src/actions/portal/serve.ts +++ b/src/actions/portal/serve.ts @@ -63,13 +63,11 @@ export class PortalServeAction { } const liveReloadServer = createServer(); - this.application + const server = this.application .use(connectLiveReload()) .use(express.static(portalDirectory.toString(), { extensions: ["html"] })) .listen(servePort); - - // end live reload code watcher @@ -94,12 +92,9 @@ export class PortalServeAction { eventQueue.set(eventId, path); }); - - await this.watcherHandler.execute(async () => { - if (this.isPortalServed) { - this.prompts.changesDetected(); + this.prompts.changesDetected(); } const result = await generatePortalAction.execute(buildDirectory, portalDirectory, true, false); @@ -107,8 +102,11 @@ export class PortalServeAction { const portalUrl = new UrlPath(`http://localhost:${servePort}`); if (!this.isPortalServed) { - this.prompts.portalServed(portalUrl) - if (openInBrowser){ + if (displayServeCommandMessages) { + this.prompts.portalServed(portalUrl); + } + + if (openInBrowser) { await this.launcherService.openUrlInBrowser(portalUrl); } this.isPortalServed = true; @@ -125,12 +123,15 @@ export class PortalServeAction { }); // Wait for SIGINT or SIGTERM - await this.prompts.blockPrompt(); + if (!noReload) { + await this.prompts.blockPrompt(); + } // TODO: find a better way to stop server - await watcher.close(); - this.watcherHandler.close(); + await watcher.close(); + this.watcherHandler.close(); liveReloadServer.close(); + server.close(); // TODO: return stopped return ActionResult.success(); @@ -138,8 +139,6 @@ export class PortalServeAction { // TODO: check if i can show prompt //watcher.close(); - - //const generatePortalAction = new GenerateAction(this.configDir, this.commandMetadata, this.authKey); //const result = await generatePortalAction.execute(buildDirectory, portalDirectory, false, false); From aa01e97a774d481f5566a94b77e4e9df88a1eea3 Mon Sep 17 00:00:00 2001 From: Ali Asghar <75574550+aliasghar98@users.noreply.github.com> Date: Thu, 28 Aug 2025 10:22:49 +0500 Subject: [PATCH 25/77] chore: removed unintended changes in launch.json --- .vscode/launch.json | 2 -- 1 file changed, 2 deletions(-) diff --git a/.vscode/launch.json b/.vscode/launch.json index a37162b2b..8237404d7 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -21,7 +21,6 @@ // "portal:quickstart", // "portal:generate", // "portal:toc:new", - "portal:generate", //"--expand-endpoints", //"--expand-models", // "--force", @@ -32,7 +31,6 @@ // "portal:serve", // "api:transform", // "--help", - "--input=C:\\Users\\Ali\\Desktop\\test-cli\\test-prompts-framework-integration" // "--folder=C:/Users/User/Desktop/demo/portal", // "--destination=C:\\Users\\User\\Desktop\\demo\\portal\\api-portal" // "--source=C:/Users/User/Desktop/demo/portal/", From 36dc9c284fed842da120329db5d502f7efc98c45 Mon Sep 17 00:00:00 2001 From: Ali Asghar <75574550+aliasghar98@users.noreply.github.com> Date: Thu, 28 Aug 2025 10:23:30 +0500 Subject: [PATCH 26/77] chore: removed unintended comma in launch.json --- .vscode/launch.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.vscode/launch.json b/.vscode/launch.json index 8237404d7..9a03fe51b 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -30,7 +30,7 @@ // "test-portal", // "portal:serve", // "api:transform", - // "--help", + // "--help" // "--folder=C:/Users/User/Desktop/demo/portal", // "--destination=C:\\Users\\User\\Desktop\\demo\\portal\\api-portal" // "--source=C:/Users/User/Desktop/demo/portal/", From 376512ad1e0e9757324ef3e3ae18181ee1549d71 Mon Sep 17 00:00:00 2001 From: Muhammad Sohail <62895181+sohail2721@users.noreply.github.com> Date: Thu, 28 Aug 2025 11:08:48 +0500 Subject: [PATCH 27/77] refactor: SDK generate (#185) --- src/actions/portal/generate.ts | 1 - src/actions/sdk/generate.ts | 69 ++++--------------- src/commands/sdk/generate.ts | 36 ++++++---- src/infrastructure/services/portal-service.ts | 33 ++++++--- src/prompts/api/transform.ts | 6 +- src/prompts/sdk/generate.ts | 44 ++++++------ src/types/sdk-context.ts | 17 ++--- 7 files changed, 93 insertions(+), 113 deletions(-) diff --git a/src/actions/portal/generate.ts b/src/actions/portal/generate.ts index 5bd835082..c7f308971 100644 --- a/src/actions/portal/generate.ts +++ b/src/actions/portal/generate.ts @@ -46,7 +46,6 @@ export class GenerateAction { return ActionResult.cancelled(); } - return await withDirPath(async (tempDirectory) => { const tempContext = new TempContext(tempDirectory); const buildZipPath = await tempContext.zip(buildDirectory); diff --git a/src/actions/sdk/generate.ts b/src/actions/sdk/generate.ts index a9a191d96..677c93e60 100644 --- a/src/actions/sdk/generate.ts +++ b/src/actions/sdk/generate.ts @@ -1,22 +1,16 @@ -import { ZipService } from "../../infrastructure/zip-service.js"; -import { FileService } from "../../infrastructure/file-service.js"; import { PortalService } from "../../infrastructure/services/portal-service.js"; import { DirectoryPath } from "../../types/file/directoryPath.js"; -import { FilePath } from "../../types/file/filePath.js"; -import { FileName } from "../../types/file/fileName.js"; import { ActionResult } from "../action-result.js"; import { withDirPath } from "../../infrastructure/tmp-extensions.js"; import { SdkContext } from "../../types/sdk-context.js"; -import { Platforms } from "@apimatic/sdk"; import { SpecContext } from "../../types/spec-context.js"; import { SdkGeneratePrompts } from "../../prompts/sdk/generate.js"; -import { Language } from "../../types/sdk/generate.js"; import { CommandMetadata } from "../../types/common/command-metadata.js"; +import { TempContext } from "../../types/temp-context.js"; +import { Language } from "../../types/sdk/generate.js"; export class GenerateAction { private readonly prompts: SdkGeneratePrompts = new SdkGeneratePrompts(); - private readonly zipArchiver: ZipService = new ZipService(); - private readonly fileService: FileService = new FileService(); private readonly portalService: PortalService = new PortalService(); private readonly configDir: DirectoryPath; private readonly commandMetadata: CommandMetadata; @@ -36,73 +30,38 @@ export class GenerateAction { zipSdk: boolean ): Promise => { if (specDirectory.isEqual(sdkDirectory)) { - // return ActionResult.error(`The spec directory and sdk directory cannot be the same: "${specDirectory}"`); + this.prompts.sameSpecAndSdkDir(specDirectory); return ActionResult.failed(); } const specContext = new SpecContext(specDirectory); if (!(await specContext.validate())) { - // return ActionResult.error(`The spec directory is either empty or invalid: "${specDirectory}"`); + this.prompts.invalidSpecDirectory(specDirectory); return ActionResult.failed(); } const sdkContext = new SdkContext(sdkDirectory, language); - if (!force && (await sdkContext.exists()) && !(await this.prompts.overwriteSdk(sdkDirectory))) { - // return ActionResult.error( - // "Please enter a different destination folder or remove the existing files and try again." - // ); - return ActionResult.failed(); + if (!force && (await sdkContext.exists()) && !(await this.prompts.overwriteSdk(sdkContext.sdkLanguageDirectory))) { + this.prompts.destinationDirNotEmpty(); + return ActionResult.cancelled(); } return await withDirPath(async (tempDirectory) => { - this.prompts.displaySdkGenerationMessage(); + const tempContext = new TempContext(tempDirectory); + const specZipPath = await tempContext.zip(specDirectory); - const specZipPath = new FilePath(tempDirectory, new FileName("spec.zip")); - await this.zipArchiver.archive(specDirectory, specZipPath); - - const platform = this.convertLanguageToPlatform(language); - const response = await this.portalService.generateSdk( - specZipPath, - platform, - this.configDir, - this.commandMetadata, - this.authKey + const response = await this.prompts.generateSDK( + this.portalService.generateSdk(specZipPath, language, this.configDir, this.commandMetadata, this.authKey) ); - if (!response.isSuccess()) { - this.prompts.displaySdkGenerationErrorMessage(); - // return ActionResult.error(response.error!); + if (response.isErr()) { + this.prompts.logGenerationError(response.error); return ActionResult.failed(); } - const tempSdkFilePath = new FilePath(tempDirectory, new FileName("sdk.zip")); - await this.fileService.writeFile(tempSdkFilePath, response.value); - + const tempSdkFilePath = await tempContext.save(response.value); await sdkContext.save(tempSdkFilePath, zipSdk); - this.prompts.displaySdkGenerationSuccessMessage(); - return ActionResult.success(); }); }; - - private convertLanguageToPlatform(language: Language): Platforms { - switch (language) { - case Language.CSHARP: - return Platforms.CsNetStandardLib; - case Language.JAVA: - return Platforms.JavaEclipseJreLib; - case Language.PHP: - return Platforms.PhpGenericLibV2; - case Language.PYTHON: - return Platforms.PythonGenericLib; - case Language.RUBY: - return Platforms.RubyGenericLib; - case Language.TYPESCRIPT: - return Platforms.TsGenericLib; - case Language.GO: - return Platforms.GoGenericLib; - default: - throw new Error(`Unknown LanguagePlatform: ${language}`); - } - } } diff --git a/src/commands/sdk/generate.ts b/src/commands/sdk/generate.ts index b7165fb75..2bbfc47c2 100644 --- a/src/commands/sdk/generate.ts +++ b/src/commands/sdk/generate.ts @@ -1,46 +1,52 @@ import { Command, Flags } from "@oclif/core"; import { DirectoryPath } from "../../types/file/directoryPath.js"; import { FlagsProvider } from "../../types/flags-provider.js"; -import { SdkGeneratePrompts } from "../../prompts/sdk/generate.js"; import { GenerateAction } from "../../actions/sdk/generate.js"; import { Language } from "../../types/sdk/generate.js"; import { CommandMetadata } from "../../types/common/command-metadata.js"; -import { outro } from "../../prompts/format.js"; +import { format, intro, outro } from "../../prompts/format.js"; const DEFAULT_WORKING_DIRECTORY = "./"; export default class SdkGenerate extends Command { - static description = "Generate an SDK for your API"; + static readonly summary = "Generate an SDK for your API"; + + static readonly description = `Generate Software Development Kits (SDKs) from API specifications. +Supports multiple programming languages including Java, C#, Python, JavaScript, and more.`; + + static readonly cmdTxt = format.cmd("apimatic", "sdk", "generate"); + static flags = { language: Flags.string({ char: "l", required: true, - options: Object.values(Language).map((p) => p.toString()), - description: `language for which the sdk will be generated.` + description: "Programming language for SDK generation", + options: Object.values(Language).map((p) => p.valueOf()), }), spec: Flags.string({ - description: "path to the folder containing the API specification file.", + description: "Path to the folder containing the API specification file", default: "./src/spec" }), destination: Flags.string({ char: "d", - description: `[default: ./sdk/] path where the sdk will be generated.` + description: "Directory where the SDK will be generated" }), ...FlagsProvider.force, zip: Flags.boolean({ default: false, - description: "download the generated SDK as a .zip archive." + description: "Download the generated SDK as a .zip archive" }), ...FlagsProvider.authKey }; static examples = [ - `apimatic sdk generate --language=java`, - `apimatic sdk generate --language=csharp --spec="./src/spec"` + `${SdkGenerate.cmdTxt} ${format.flag("language", "java")}`, + `${SdkGenerate.cmdTxt} ${format.flag("language", "csharp")} ${format.flag("spec", "./src/spec")}`, + `${SdkGenerate.cmdTxt} ${format.flag("language", "python")} ${format.flag("destination", "./sdk")} ${format.flag( + "zip" + )}` ]; - private readonly prompts: SdkGeneratePrompts = new SdkGeneratePrompts(); - async run() { const { flags: { language, spec, destination, force, zip: zipSdk, "auth-key": authKey } @@ -48,20 +54,20 @@ export default class SdkGenerate extends Command { const workingDirectory = new DirectoryPath(DEFAULT_WORKING_DIRECTORY); const specDirectory = new DirectoryPath(spec); - - const sdkDirectory = destination ? new DirectoryPath(destination) : workingDirectory.join("sdk").join(language); + const sdkDirectory = destination ? new DirectoryPath(destination) : workingDirectory.join("sdk"); const commandMetadata: CommandMetadata = { commandName: SdkGenerate.id, shell: this.config.shell }; + intro("Generate SDK"); const action = new GenerateAction(this.getConfigDir(), commandMetadata, authKey); const result = await action.execute(specDirectory, sdkDirectory, language as Language, force, zipSdk); outro(result); } - private getConfigDir = () => { + private readonly getConfigDir = () => { return new DirectoryPath(this.config.configDir); }; } diff --git a/src/infrastructure/services/portal-service.ts b/src/infrastructure/services/portal-service.ts index 517edac8d..0d55f26e6 100644 --- a/src/infrastructure/services/portal-service.ts +++ b/src/infrastructure/services/portal-service.ts @@ -14,7 +14,7 @@ import { Transformation, ExportFormats, InternalServerErrorResponseError, - Platforms, + Platforms } from "@apimatic/sdk"; import { AuthInfo, getAuthInfo } from "../../client-utils/auth-manager.js"; import { Result } from "../../types/common/result.js"; @@ -27,6 +27,7 @@ import { FileService } from "../file-service.js"; import { apiClientFactory } from "./api-client-factory.js"; import { CommandMetadata } from "../../types/common/command-metadata.js"; import { err, ok, Result as ResultEx } from "neverthrow"; +import { Language } from "../../types/sdk/generate.js"; export class PortalService { private readonly CONTENT_TYPE = ContentType.EnumMultipartformdata; @@ -47,7 +48,11 @@ export class PortalService { const docsPortalManagementController = new DocsPortalManagementController(client); try { - const response = await docsPortalManagementController.generateOnPremPortalViaBuildInput(this.CONTENT_TYPE, file, this.createOriginQueryParameter(eventMetadata.commandName)); + const response = await docsPortalManagementController.generateOnPremPortalViaBuildInput( + this.CONTENT_TYPE, + file, + this.createOriginQueryParameter(eventMetadata.commandName) + ); return ok(response.result as NodeJS.ReadableStream); } catch (error) { return err(await PortalService.handlePortalGenerationErrors(error)); @@ -59,11 +64,11 @@ export class PortalService { // TODO: Pass stream as parameter instead of file path. public async generateSdk( specPath: FilePath, - sdkPlatform: Platforms, + language: Language, configDir: DirectoryPath, commandMetadata: CommandMetadata, authKey: string | null - ): Promise> { + ): Promise> { const specFileStream = await this.fileService.getStream(specPath); const file = new FileWrapper(specFileStream); const authInfo: AuthInfo | null = await getAuthInfo(configDir.toString()); @@ -75,13 +80,13 @@ export class PortalService { const response = await sdkGenerationController.generateSdkViaFile( Accept.EnumApplicationjson, file, - sdkPlatform, + this.languagePlatform[language], this.createOriginQueryParameter(commandMetadata.commandName) ); const sdkResponse = await sdkGenerationController.downloadSdk(response.result.id); - return Result.success(sdkResponse.result as NodeJS.ReadableStream); + return ok(sdkResponse.result as NodeJS.ReadableStream); } catch (error) { - return Result.failure(await this.handleSdkGenerationErrors(error)); + return err(await this.handleSdkGenerationErrors(error)); } finally { specFileStream.close(); } @@ -153,8 +158,8 @@ export class PortalService { //500 const message = error.result?.message; return `${ - message ?? "An unknown error occurred." - } Please try again later or reach out to our team at support@apimatic.io for help if your problem persists.`; + message ?? "An unknown error occurred." + } Please try again later or reach out to our team at support@apimatic.io for help if your problem persists.`; } else { return "An unexpected error occurred while generating the portal, please try again later. If the problem persists, please reach out to our team at support@apimatic.io"; } @@ -201,4 +206,14 @@ export class PortalService { ); } }; + + private readonly languagePlatform: Record = { + [Language.CSHARP]: Platforms.CsNetStandardLib, + [Language.JAVA]: Platforms.JavaEclipseJreLib, + [Language.PHP]: Platforms.PhpGenericLibV2, + [Language.PYTHON]: Platforms.PythonGenericLib, + [Language.RUBY]: Platforms.RubyGenericLib, + [Language.TYPESCRIPT]: Platforms.TsGenericLib, + [Language.GO]: Platforms.GoGenericLib + }; } diff --git a/src/prompts/api/transform.ts b/src/prompts/api/transform.ts index 57431bea8..16473e3e2 100644 --- a/src/prompts/api/transform.ts +++ b/src/prompts/api/transform.ts @@ -1,4 +1,4 @@ -import { outro, log, isCancel, confirm } from "@clack/prompts"; +import { log, isCancel, confirm } from "@clack/prompts"; import { DirectoryPath } from "../../types/file/directoryPath.js"; import { format as f, withSpinner } from "../format.js"; import { Result } from "neverthrow"; @@ -33,10 +33,6 @@ export class ApiTransformPrompts { return withSpinner("Transforming API", "API transformed successfully.", "API transformation failed.", fn); } - displayOutroMessage(transformedApiPath: DirectoryPath): void { - outro(`The transformed API specification can be found at ${transformedApiPath}`); - } - logTransformationError(error: string): void { log.error(error); } diff --git a/src/prompts/sdk/generate.ts b/src/prompts/sdk/generate.ts index 3d84f24cd..5c21d76e1 100644 --- a/src/prompts/sdk/generate.ts +++ b/src/prompts/sdk/generate.ts @@ -1,10 +1,10 @@ -import { spinner, isCancel, confirm, log } from "@clack/prompts"; -import { getMessageInRedColor, getMessageInMagentaColor, getMessageInCyanColor } from "../../utils/utils.js"; +import { isCancel, confirm, log } from "@clack/prompts"; import { DirectoryPath } from "../../types/file/directoryPath.js"; +import { format as f, withSpinner } from "../format.js"; +import { Result } from "neverthrow"; +import { getErrorMessage, ServiceError } from "../../infrastructure/api-utils.js"; export class SdkGeneratePrompts { - private readonly spin = spinner(); - public async overwriteSdk(directory: DirectoryPath): Promise { const overwrite = await confirm({ message: `The destination '${directory}' is not empty, do you want to overwrite?`, @@ -18,29 +18,33 @@ export class SdkGeneratePrompts { return overwrite; } - displaySdkGenerationMessage(): void { - this.spin.start(getMessageInMagentaColor("Generating SDK")); + public sameSpecAndSdkDir(directory: DirectoryPath) { + const message = `The ${f.var("src")} and ${f.var("portal")} directories must be different. Current value: ${f.path( + directory.toString() + )}`; this.logGenerationError(message); } - displaySdkGenerationSuccessMessage(): void { - this.spin.stop(getMessageInCyanColor("SDK generated successfully.")); - this.cleanUpStandardInput(); + public invalidSpecDirectory(directory: DirectoryPath) { + const message = `The ${f.var("src")} directory is either empty or invalid: ${f.path(directory.toString())}`; + this.logGenerationError(message); } - displaySdkGenerationErrorMessage(): void { - this.spin.stop(getMessageInRedColor(`SDK Generation failed.`), 1); - this.cleanUpStandardInput(); + public destinationDirNotEmpty() { + const message = `Please enter a different destination folder or remove the existing files and try again.`; + this.logGenerationError(message); } - logError(error: string): void { - log.error(error); + public async generateSDK(fn: Promise>) { + return withSpinner("Generating SDK", "SDK generated successfully.", "SDK Generation failed.", fn); } - //This clears the standard input to allow interrupts like CTRL+C to work properly. - private cleanUpStandardInput(): void { - if (process.stdin.isTTY) { - process.stdin.setRawMode(false); - process.stdin.pause(); - } + public networkError(serviceError: ServiceError): void { + const message = getErrorMessage(serviceError); + this.logGenerationError(message); } + + public logGenerationError(error: string): void { + log.error(error); + } + } diff --git a/src/types/sdk-context.ts b/src/types/sdk-context.ts index 2af32ca67..35af3a69f 100644 --- a/src/types/sdk-context.ts +++ b/src/types/sdk-context.ts @@ -6,28 +6,29 @@ import { ZipService } from "../infrastructure/zip-service.js"; import { Language } from "./sdk/generate.js"; export class SdkContext { - private readonly fileService = new FileService(); private readonly zipService = new ZipService(); - constructor(private readonly sdkDirectory: DirectoryPath, - private readonly platform: Language) { + constructor(private readonly sdkDirectory: DirectoryPath, private readonly language: Language) {} + + private get ZipPath(): FilePath { + return new FilePath(this.sdkLanguageDirectory, new FileName(`${this.language}.zip`)); } - public get ZipPath(): FilePath { - return new FilePath(this.sdkDirectory, new FileName(`${this.platform}.zip`)); + public get sdkLanguageDirectory(): DirectoryPath{ + return this.sdkDirectory.join(this.language); } public async exists() { - return !await this.fileService.directoryEmpty(this.sdkDirectory); + return !(await this.fileService.directoryEmpty(this.sdkDirectory)); } public async save(tempPortalFilePath: FilePath, zipPortal: boolean) { - await this.fileService.cleanDirectory(this.sdkDirectory); + await this.fileService.cleanDirectory(this.sdkLanguageDirectory); if (zipPortal) { await this.fileService.copy(tempPortalFilePath, this.ZipPath); } else { - await this.zipService.unArchive(tempPortalFilePath, this.sdkDirectory); + await this.zipService.unArchive(tempPortalFilePath, this.sdkLanguageDirectory); } } } From 6d718e20c2acc04cb3d395413140f14eca12cdcb Mon Sep 17 00:00:00 2001 From: Ali Asghar <75574550+aliasghar98@users.noreply.github.com> Date: Thu, 28 Aug 2025 13:01:25 +0500 Subject: [PATCH 28/77] fix: removed changes in generate prompts --- src/prompts/portal/generate.ts | 9 --------- 1 file changed, 9 deletions(-) diff --git a/src/prompts/portal/generate.ts b/src/prompts/portal/generate.ts index 9e15d43a6..50edb55a2 100644 --- a/src/prompts/portal/generate.ts +++ b/src/prompts/portal/generate.ts @@ -37,7 +37,6 @@ export class PortalGeneratePrompts { public generatePortal(fn: Promise>) { const result = withSpinner("Generating portal", "Portal generated successfully.", "Portal Generation failed.", fn); - this.cleanUpStandardInput(); return result; } @@ -50,12 +49,4 @@ export class PortalGeneratePrompts { An error report has been written at the destination path: ${f.path(reportPath.toString())}`; log.error(message); } - - // This clears the standard input to allow interrupts like CTRL+C to work properly. - private cleanUpStandardInput(): void { - if (process.stdin.isTTY) { - process.stdin.setRawMode(false); - process.stdin.pause(); - } - } } From 4fc0e6d257e7b7d1917f09ce06c9fa60945f0a17 Mon Sep 17 00:00:00 2001 From: Ali Asghar <75574550+aliasghar98@users.noreply.github.com> Date: Thu, 28 Aug 2025 13:05:03 +0500 Subject: [PATCH 29/77] fix: removed all changes from portal generate --- src/prompts/portal/generate.ts | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/prompts/portal/generate.ts b/src/prompts/portal/generate.ts index 50edb55a2..fe2d65c47 100644 --- a/src/prompts/portal/generate.ts +++ b/src/prompts/portal/generate.ts @@ -36,15 +36,14 @@ export class PortalGeneratePrompts { } public generatePortal(fn: Promise>) { - const result = withSpinner("Generating portal", "Portal generated successfully.", "Portal Generation failed.", fn); - return result; + return withSpinner("Generating portal", "Portal generated successfully.", "Portal Generation failed.", fn); } public portalGenerationError(error: string) { log.error(error); } - public portalGenerationErrorWithReport(reportPath: FilePath) { + portalGenerationErrorWithReport(reportPath: FilePath) { const message = `An error occurred during portal generation due to an issue with the input. An error report has been written at the destination path: ${f.path(reportPath.toString())}`; log.error(message); From e82b990c22b562abf342a19e1a1bc038fc6c622d Mon Sep 17 00:00:00 2001 From: Ali Asghar <75574550+aliasghar98@users.noreply.github.com> Date: Thu, 28 Aug 2025 17:03:25 +0500 Subject: [PATCH 30/77] fix: resolved serve issues with quickstart --- src/actions/portal/serve.ts | 202 ++++++------------ .../debounce-service.ts} | 2 +- src/prompts/portal/serve.ts | 39 ++-- 3 files changed, 89 insertions(+), 154 deletions(-) rename src/{application/portal/serve/watcher-handler.ts => infrastructure/debounce-service.ts} (98%) diff --git a/src/actions/portal/serve.ts b/src/actions/portal/serve.ts index 16f0ffc9e..b1a184e43 100644 --- a/src/actions/portal/serve.ts +++ b/src/actions/portal/serve.ts @@ -1,31 +1,28 @@ +import { createServer } from "livereload"; +import connectLiveReload from "connect-livereload"; +import express, { Express } from "express"; +import chokidar from "chokidar"; +import crypto from "crypto"; +import { Mutex } from "async-mutex"; import { PortalServePrompts } from "../../prompts/portal/serve.js"; import { DirectoryPath } from "../../types/file/directoryPath.js"; import { ActionResult } from "../action-result.js"; import { CommandMetadata } from "../../types/common/command-metadata.js"; import { GenerateAction } from "./generate.js"; import { NetworkService } from "../../infrastructure/network-service.js"; -import { createServer } from "livereload"; -import connectLiveReload from "connect-livereload"; -import express, { Express } from "express"; import { UrlPath } from "../../types/file/urlPath.js"; -import console from "console"; import { LauncherService } from "../../infrastructure/launcher-service.js"; -import { WatcherHandler } from "../../application/portal/serve/watcher-handler.js"; -import chokidar from "chokidar"; -import crypto from "crypto"; -import { Mutex } from "async-mutex"; +import { DebounceService } from "../../infrastructure/debounce-service.js"; export class PortalServeAction { private readonly prompts: PortalServePrompts = new PortalServePrompts(); private readonly networkService: NetworkService = new NetworkService(); private readonly launcherService: LauncherService = new LauncherService(); - private readonly watcherHandler: WatcherHandler = new WatcherHandler(); - + private readonly debounceService: DebounceService = new DebounceService(); private readonly application: Express = express(); private readonly configDir: DirectoryPath; private readonly commandMetadata: CommandMetadata; private readonly authKey: string | null; - private isPortalServed = false; public constructor(configDir: DirectoryPath, commandMetadata: CommandMetadata, authKey: string | null = null) { this.configDir = configDir; @@ -41,11 +38,41 @@ export class PortalServeAction { noReload: boolean, displayServeCommandMessages: boolean = true ): Promise { + const servePort = await this.networkService.getServerPort([port, 3000, 3001, 3002]); + if (servePort != port) { + this.prompts.portAlreadyInUse(port, servePort); + } + + // Generate once, return early if there was a problem. const generatePortalAction = new GenerateAction(this.configDir, this.commandMetadata, this.authKey); + const result = await generatePortalAction.execute(buildDirectory, portalDirectory, false, false); + const isFailedOrCancelledResult = result.mapAll( + () => null, + () => ActionResult.failed(), + () => ActionResult.cancelled() + ); + if (isFailedOrCancelledResult) { + return isFailedOrCancelledResult; + } + + const portalUrl = new UrlPath(`http://localhost:${servePort}`); + if (displayServeCommandMessages) { + this.prompts.portalServed(portalUrl); + this.prompts.waitingForChanges(); + } + if (openInBrowser) { + await this.launcherService.openUrlInBrowser(portalUrl); + } + + const liveReloadServer = createServer(); + const server = this.application + .use(connectLiveReload()) + .use(express.static(portalDirectory.toString(), { extensions: ["html"] })) + .listen(servePort); const watcher = chokidar.watch(buildDirectory.toString(), { ignored: [/(^|[/\\])\..+/], - ignoreInitial: false, // TODO: check this flag with Saeed + ignoreInitial: true, // TODO: check this flag with Saeed persistent: true, awaitWriteFinish: true, atomic: true @@ -55,20 +82,13 @@ export class PortalServeAction { const eventQueue = new Map(); const mutex = new Mutex(); - // live reload code - - const servePort = await this.networkService.getServerPort([port, 3000, 3001, 3002]); - if (servePort != port) { - this.prompts.portAlreadyInUse(port, servePort); - } - - const liveReloadServer = createServer(); - const server = this.application - .use(connectLiveReload()) - .use(express.static(portalDirectory.toString(), { extensions: ["html"] })) - .listen(servePort); - - // end live reload code + const shutdown = async () => { + await watcher.close(); + this.debounceService.close(); + liveReloadServer.close(); + server.close(); + this.prompts.serverClosed(); + }; watcher .on("all", async (event, path) => { @@ -92,123 +112,41 @@ export class PortalServeAction { eventQueue.set(eventId, path); }); - await this.watcherHandler.execute(async () => { - if (this.isPortalServed) { - this.prompts.changesDetected(); - } - - const result = await generatePortalAction.execute(buildDirectory, portalDirectory, true, false); - // TODO: check for result - - const portalUrl = new UrlPath(`http://localhost:${servePort}`); - if (!this.isPortalServed) { - if (displayServeCommandMessages) { - this.prompts.portalServed(portalUrl); - } - - if (openInBrowser) { - await this.launcherService.openUrlInBrowser(portalUrl); - } - this.isPortalServed = true; - } + await this.debounceService.execute(async () => { + this.prompts.changesDetected(); + await generatePortalAction.execute(buildDirectory, portalDirectory, true, false); this.prompts.waitingForChanges(); + liveReloadServer.refresh(portalDirectory.toString()); + this.clearStandardInput(); }); }) - .on("error", () => { - console.error( - "An unexpected error occurred while watching your build folder for changes. Please try again later. If the issue persists, contact our team at support@apimatic.io" - ); - watcher.close(); + .on("error", async () => { + this.prompts.watcherError(); + await watcher.close(); + this.debounceService.close(); + liveReloadServer.close(); + server.close(); + return ActionResult.failed(); }); // Wait for SIGINT or SIGTERM if (!noReload) { - await this.prompts.blockPrompt(); + this.clearStandardInput(); + process.on("SIGINT", shutdown); + process.on("SIGTERM", shutdown); + } else { + await shutdown(); } - // TODO: find a better way to stop server - await watcher.close(); - this.watcherHandler.close(); - liveReloadServer.close(); - server.close(); - - // TODO: return stopped return ActionResult.success(); + } - // TODO: check if i can show prompt - //watcher.close(); - - //const generatePortalAction = new GenerateAction(this.configDir, this.commandMetadata, this.authKey); - //const result = await generatePortalAction.execute(buildDirectory, portalDirectory, false, false); - - // watcher (src) -> generate() -> -> reload() - // liveReload (des) -> watcher (automated) -> generate() -> - - // assuming result is success - - // - - // const liveReloadServer = createServer(); - // liveReloadServer.watch(portalDirectory.toString()); - // this.application.use(connectLiveReload()); - // this.application.use(express.static(portalDirectory.toString(), { extensions: ["html"] })); - // - // const server = this.application - // .listen(servePort, async () => { - // if (openInBrowser) { - // await this.launcherService.openUrlInBrowser(new UrlPath(`http://localhost:${servePort}`)); - // } - // - // if (!noReload) { - // await this.portalWatcher.watchAndRegeneratePortalOnChange(buildDirectory, portalDirectory, generatePortalAction.execute); - // } - // - // if (process.platform !== "darwin") { - // //For non-macOS users. - // if (process.stdin.setRawMode) { - // process.stdin.setRawMode(false); - // } - // } - // }) - // .on("error", () => {}); - // - // const shutdown = async () => { - // console.log("Shutting down server..."); - // if (liveReloadServer) { - // liveReloadServer.close(); - // } - // if (server) { - // server.close(); - // } - // console.log("Server shut down successfully."); - // // TODO: Find a better way. - // process.exit(0); - // }; - // - // process.on("SIGINT", shutdown); - // process.on("SIGTERM", shutdown); - - // return result.mapAll>( - // async () => { - // - // if (displayServeCommandMessages) { - // this.prompts.nextSteps( - // buildDirectory, - // portalDirectory, - // servePort, - // noReload - // ); - // } - // - // return ActionResult.success(); - // }, - // async () => { - // return ActionResult.failed(); - // }, - // async () => { - // return ActionResult.cancelled(); - // } - // ); + // This clears the standard input to allow interrupts like CTRL+C to work properly. + private clearStandardInput() { + if (process.platform !== "darwin" && process.stdin.isTTY) { + process.stdin.setRawMode(false); + process.stdin.pause(); + } } } diff --git a/src/application/portal/serve/watcher-handler.ts b/src/infrastructure/debounce-service.ts similarity index 98% rename from src/application/portal/serve/watcher-handler.ts rename to src/infrastructure/debounce-service.ts index 85c51b8c0..11b849454 100644 --- a/src/application/portal/serve/watcher-handler.ts +++ b/src/infrastructure/debounce-service.ts @@ -1,6 +1,6 @@ import { clearTimeout, setTimeout } from "timers"; -export class WatcherHandler { +export class DebounceService { private isProcessing = false; private latestHandler: (() => Promise) | null = null; private debounceTimer: NodeJS.Timeout | null = null; diff --git a/src/prompts/portal/serve.ts b/src/prompts/portal/serve.ts index 9c92a0fcc..d30b55649 100644 --- a/src/prompts/portal/serve.ts +++ b/src/prompts/portal/serve.ts @@ -1,7 +1,7 @@ +import console from "console"; import { log } from "@clack/prompts"; import { format as f } from "../format.js"; import { DirectoryPath } from "../../types/file/directoryPath.js"; -import { once } from "events"; import { UrlPath } from "../../types/file/urlPath.js"; export class PortalServePrompts { @@ -19,39 +19,36 @@ export class PortalServePrompts { } public portAlreadyInUse(currentPort: number, availablePort: number) { - const message = `Port ${f.var(currentPort.toString())} is already in use. Available port ${f.var(availablePort.toString())} will be used.`; + const message = `Port ${f.var(currentPort.toString())} is already in use. Available port ${f.var( + availablePort.toString() + )} will be used.`; log.step(message); } - public nextSteps(buildDirectory: DirectoryPath, portalDirectory: DirectoryPath, port: number, hotReloadDisabled: boolean): void { - log.message(`Portal successfully generated at: ${f.path(portalDirectory.toString())}`); - log.message(`Server running at: ${f.link(`http://localhost:${port}`)}`); - if (!hotReloadDisabled) { - log.message(`Hot reload is enabled. Watching the following build folder for any changes:`); - log.message(`${f.path(buildDirectory.toString())}`); - } - log.message(`Press CTRL+C to stop the server.`); - } - public portalServed(urlPath: UrlPath) { const message = `Server running at: ${f.link(urlPath.toString())}`; log.message(message); } public waitingForChanges() { - const message = "Watching for changes... Press CTRL+C to stop." + const message = "Watching for changes... Press CTRL+C to stop."; log.message(message); } - public async blockPrompt() { - await Promise.race([ - once(process, "SIGINT"), - once(process, "SIGTERM") - ]); - } - public changesDetected() { - const message = "Changes detected..." + const message = "Changes detected..."; log.info(message); } + + public watcherError() { + const message = + "An unexpected error occurred while watching your build folder for changes. Please try again later. If the issue persists, contact our team at support@apimatic.io"; + + log.error(message); + } + + public serverClosed() { + const message = "Server shutdown successfully."; + console.log(message); + } } From 018d539c4473e7ad31700d5390abaa6749b7d561 Mon Sep 17 00:00:00 2001 From: Ali Asghar Date: Fri, 29 Aug 2025 00:33:40 +0500 Subject: [PATCH 31/77] refactor: deleted empty service file and added two new services for serve action --- src/actions/portal/serve.ts | 29 ++++++------ src/infrastructure/live-reload-service.ts | 32 +++++++++++++ src/infrastructure/server-service.ts | 45 +++++++++++++++++++ .../services/portal-serve-service.ts | 0 4 files changed, 91 insertions(+), 15 deletions(-) create mode 100644 src/infrastructure/live-reload-service.ts create mode 100644 src/infrastructure/server-service.ts delete mode 100644 src/infrastructure/services/portal-serve-service.ts diff --git a/src/actions/portal/serve.ts b/src/actions/portal/serve.ts index b1a184e43..8c5061304 100644 --- a/src/actions/portal/serve.ts +++ b/src/actions/portal/serve.ts @@ -1,6 +1,3 @@ -import { createServer } from "livereload"; -import connectLiveReload from "connect-livereload"; -import express, { Express } from "express"; import chokidar from "chokidar"; import crypto from "crypto"; import { Mutex } from "async-mutex"; @@ -13,13 +10,16 @@ import { NetworkService } from "../../infrastructure/network-service.js"; import { UrlPath } from "../../types/file/urlPath.js"; import { LauncherService } from "../../infrastructure/launcher-service.js"; import { DebounceService } from "../../infrastructure/debounce-service.js"; +import { ServerService } from "../../infrastructure/server-service.js"; +import { LiveReloadService } from "../../infrastructure/live-reload-service.js"; export class PortalServeAction { private readonly prompts: PortalServePrompts = new PortalServePrompts(); + private readonly serverService: ServerService = new ServerService(); + private readonly liveReloadService: LiveReloadService = new LiveReloadService(); private readonly networkService: NetworkService = new NetworkService(); private readonly launcherService: LauncherService = new LauncherService(); private readonly debounceService: DebounceService = new DebounceService(); - private readonly application: Express = express(); private readonly configDir: DirectoryPath; private readonly commandMetadata: CommandMetadata; private readonly authKey: string | null; @@ -64,15 +64,14 @@ export class PortalServeAction { await this.launcherService.openUrlInBrowser(portalUrl); } - const liveReloadServer = createServer(); - const server = this.application - .use(connectLiveReload()) - .use(express.static(portalDirectory.toString(), { extensions: ["html"] })) - .listen(servePort); + this.liveReloadService.start(); + this.serverService.use(this.liveReloadService.getMiddleware()); + this.serverService.serveStatic(portalDirectory, { extensions: ["html"] }); + this.serverService.start(servePort); const watcher = chokidar.watch(buildDirectory.toString(), { ignored: [/(^|[/\\])\..+/], - ignoreInitial: true, // TODO: check this flag with Saeed + ignoreInitial: true, persistent: true, awaitWriteFinish: true, atomic: true @@ -85,8 +84,8 @@ export class PortalServeAction { const shutdown = async () => { await watcher.close(); this.debounceService.close(); - liveReloadServer.close(); - server.close(); + this.liveReloadService.stop(); + this.serverService.stop(); this.prompts.serverClosed(); }; @@ -117,7 +116,7 @@ export class PortalServeAction { await generatePortalAction.execute(buildDirectory, portalDirectory, true, false); this.prompts.waitingForChanges(); - liveReloadServer.refresh(portalDirectory.toString()); + this.liveReloadService.refresh(portalDirectory); this.clearStandardInput(); }); }) @@ -125,8 +124,8 @@ export class PortalServeAction { this.prompts.watcherError(); await watcher.close(); this.debounceService.close(); - liveReloadServer.close(); - server.close(); + this.liveReloadService.stop(); + this.serverService.stop(); return ActionResult.failed(); }); diff --git a/src/infrastructure/live-reload-service.ts b/src/infrastructure/live-reload-service.ts new file mode 100644 index 000000000..7ce04ccde --- /dev/null +++ b/src/infrastructure/live-reload-service.ts @@ -0,0 +1,32 @@ +import { createServer, LiveReloadServer } from "livereload"; +import connectLiveReload from "connect-livereload"; +import { DirectoryPath } from "../types/file/directoryPath.js"; + +export class LiveReloadService { + private liveReloadServer: LiveReloadServer | null = null; + + public start(): void { + this.liveReloadServer = createServer(); + } + + public stop(): void { + if (this.liveReloadServer) { + this.liveReloadServer.close(); + this.liveReloadServer = null; + } + } + + public refresh(directory: DirectoryPath): void { + if (this.liveReloadServer) { + this.liveReloadServer.refresh(directory.toString()); + } + } + + public getMiddleware(): ReturnType { + return connectLiveReload(); + } + + public isRunning(): boolean { + return this.liveReloadServer !== null; + } +} diff --git a/src/infrastructure/server-service.ts b/src/infrastructure/server-service.ts new file mode 100644 index 000000000..cecaad955 --- /dev/null +++ b/src/infrastructure/server-service.ts @@ -0,0 +1,45 @@ +import express, { Express } from "express"; +import { Server } from "http"; +import { DirectoryPath } from "../types/file/directoryPath.js"; +import { HandleFunction } from "connect"; + +export class ServerService { + private readonly app: Express; + private server: Server | null = null; + + public constructor() { + this.app = express(); + } + + public use(middleware: HandleFunction): void { + this.app.use(middleware); + } + + public serveStatic(directory: DirectoryPath, options?: { extensions?: string[] }): void { + const staticOptions = { + extensions: options?.extensions || ["html"] + }; + this.app.use(express.static(directory.toString(), staticOptions)); + } + + public start(port: number): void { + this.server = this.app.listen(port); + } + + public stop(): Promise { + return new Promise((resolve) => { + if (this.server) { + this.server.close(() => { + this.server = null; + resolve(); + }); + } else { + resolve(); + } + }); + } + + public isRunning(): boolean { + return this.server !== null; + } +} diff --git a/src/infrastructure/services/portal-serve-service.ts b/src/infrastructure/services/portal-serve-service.ts deleted file mode 100644 index e69de29bb..000000000 From ef974d9894d0fb6b5db1d802a66248e0993078fa Mon Sep 17 00:00:00 2001 From: Ali Asghar Date: Fri, 29 Aug 2025 00:51:16 +0500 Subject: [PATCH 32/77] refactor: created file watcher service, integration pending --- src/infrastructure/file-watcher-service.ts | 106 +++++++++++++++++++++ 1 file changed, 106 insertions(+) create mode 100644 src/infrastructure/file-watcher-service.ts diff --git a/src/infrastructure/file-watcher-service.ts b/src/infrastructure/file-watcher-service.ts new file mode 100644 index 000000000..1cfa11166 --- /dev/null +++ b/src/infrastructure/file-watcher-service.ts @@ -0,0 +1,106 @@ +import chokidar from "chokidar"; +import crypto from "crypto"; +import { Mutex } from "async-mutex"; +import { DirectoryPath } from "../types/file/directoryPath.js"; + +export interface FileWatcherOptions { + ignored?: RegExp[]; + ignoreInitial?: boolean; + persistent?: boolean; + awaitWriteFinish?: boolean; + atomic?: boolean; +} + +export interface FileChangeEvent { + event: string; + path: string; + eventId: string; +} + +export type FileChangeHandler = (event: FileChangeEvent) => Promise; +export type ErrorHandler = () => Promise; + +export class FileWatcherService { + private watcher: chokidar.FSWatcher | null = null; + private readonly options: FileWatcherOptions; + private readonly deletedDirectories = new Set(); + private readonly eventQueue = new Map(); + private readonly mutex = new Mutex(); + + public constructor(options: FileWatcherOptions = {}) { + this.options = { + ignored: [/(^|[/\\])\..+/], + ignoreInitial: true, + persistent: true, + awaitWriteFinish: true, + atomic: true, + ...options + }; + } + + public startWatching(directory: DirectoryPath): void { + if (this.watcher) { + return; + } + + this.watcher = chokidar.watch(directory.toString(), this.options); + } + + public async stopWatching(): Promise { + if (this.watcher) { + await this.watcher.close(); + this.watcher = null; + } + this.deletedDirectories.clear(); + this.eventQueue.clear(); + } + + public onFileChange(handler: FileChangeHandler): void { + if (!this.watcher) { + return; + } + + this.watcher.on("all", async (event: string, path: string) => { + // Handle folder deletion events + if (event === "unlinkDir") { + this.deletedDirectories.add(path); + } + + // Skip file deletion events for deleted directories + if (event === "unlink") { + for (const dir of this.deletedDirectories) { + if (path.startsWith(dir)) { + return; + } + } + } + + const eventId = `${Date.now()}-${crypto.randomUUID()}`; + const fileChangeEvent: FileChangeEvent = { event, path, eventId }; + + // Use mutex to handle concurrent events + await this.mutex.runExclusive(async () => { + this.eventQueue.clear(); + this.eventQueue.set(eventId, path); + }); + + await handler(fileChangeEvent); + }); + } + + public onError(handler: ErrorHandler): void { + if (!this.watcher) { + return; + } + + this.watcher.on("error", handler); + } + + public isWatching(): boolean { + return this.watcher !== null; + } + + public getWatcher(): chokidar.FSWatcher | null { + return this.watcher; + } +} From 63c671303009fbaf862f93defa3c91e2b9338aad Mon Sep 17 00:00:00 2001 From: Ali Asghar <75574550+aliasghar98@users.noreply.github.com> Date: Fri, 29 Aug 2025 10:23:33 +0500 Subject: [PATCH 33/77] refactor: integrated file-watcher-service in serve action --- src/actions/portal/serve.ts | 76 +++++++--------------- src/infrastructure/file-watcher-service.ts | 12 ++-- 2 files changed, 28 insertions(+), 60 deletions(-) diff --git a/src/actions/portal/serve.ts b/src/actions/portal/serve.ts index 8c5061304..819767768 100644 --- a/src/actions/portal/serve.ts +++ b/src/actions/portal/serve.ts @@ -1,6 +1,3 @@ -import chokidar from "chokidar"; -import crypto from "crypto"; -import { Mutex } from "async-mutex"; import { PortalServePrompts } from "../../prompts/portal/serve.js"; import { DirectoryPath } from "../../types/file/directoryPath.js"; import { ActionResult } from "../action-result.js"; @@ -12,17 +9,20 @@ import { LauncherService } from "../../infrastructure/launcher-service.js"; import { DebounceService } from "../../infrastructure/debounce-service.js"; import { ServerService } from "../../infrastructure/server-service.js"; import { LiveReloadService } from "../../infrastructure/live-reload-service.js"; +import { FileWatcherService } from "../../infrastructure/file-watcher-service.js"; export class PortalServeAction { private readonly prompts: PortalServePrompts = new PortalServePrompts(); private readonly serverService: ServerService = new ServerService(); private readonly liveReloadService: LiveReloadService = new LiveReloadService(); + private readonly fileWatcherService: FileWatcherService = new FileWatcherService(); private readonly networkService: NetworkService = new NetworkService(); private readonly launcherService: LauncherService = new LauncherService(); private readonly debounceService: DebounceService = new DebounceService(); private readonly configDir: DirectoryPath; private readonly commandMetadata: CommandMetadata; private readonly authKey: string | null; + private watcherFailedToRun: boolean = false; public constructor(configDir: DirectoryPath, commandMetadata: CommandMetadata, authKey: string | null = null) { this.configDir = configDir; @@ -69,65 +69,32 @@ export class PortalServeAction { this.serverService.serveStatic(portalDirectory, { extensions: ["html"] }); this.serverService.start(servePort); - const watcher = chokidar.watch(buildDirectory.toString(), { - ignored: [/(^|[/\\])\..+/], - ignoreInitial: true, - persistent: true, - awaitWriteFinish: true, - atomic: true - }); - - const deletedDirectories = new Set(); - const eventQueue = new Map(); - const mutex = new Mutex(); + this.fileWatcherService.startWatching(buildDirectory); const shutdown = async () => { - await watcher.close(); + this.fileWatcherService.stopWatching(); this.debounceService.close(); this.liveReloadService.stop(); this.serverService.stop(); this.prompts.serverClosed(); }; - watcher - .on("all", async (event, path) => { - // triggers folder deletion as a single event - if (event == "unlinkDir") { - deletedDirectories.add(path); - } - - if (event == "unlink") { - for (const dir of deletedDirectories) { - if (path.startsWith(dir)) { - return; - } - } - } - - const eventId: string = `${Date.now()}-${crypto.randomUUID()}`; - - await mutex.runExclusive(async () => { - eventQueue.clear(); - eventQueue.set(eventId, path); - }); - - await this.debounceService.execute(async () => { - this.prompts.changesDetected(); - await generatePortalAction.execute(buildDirectory, portalDirectory, true, false); - this.prompts.waitingForChanges(); + this.fileWatcherService.onFileChange(async () => { + await this.debounceService.execute(async () => { + this.prompts.changesDetected(); + await generatePortalAction.execute(buildDirectory, portalDirectory, true, false); + this.prompts.waitingForChanges(); - this.liveReloadService.refresh(portalDirectory); - this.clearStandardInput(); - }); - }) - .on("error", async () => { - this.prompts.watcherError(); - await watcher.close(); - this.debounceService.close(); - this.liveReloadService.stop(); - this.serverService.stop(); - return ActionResult.failed(); + this.liveReloadService.refresh(portalDirectory); + this.clearStandardInput(); }); + }); + + this.fileWatcherService.onError(async () => { + this.prompts.watcherError(); + shutdown(); + this.watcherFailedToRun = true; + }); // Wait for SIGINT or SIGTERM if (!noReload) { @@ -138,6 +105,11 @@ export class PortalServeAction { await shutdown(); } + // TODO: Figure out a better way to achieve this. + if (this.watcherFailedToRun) { + return ActionResult.failed(); + } + return ActionResult.success(); } diff --git a/src/infrastructure/file-watcher-service.ts b/src/infrastructure/file-watcher-service.ts index 1cfa11166..520f802e0 100644 --- a/src/infrastructure/file-watcher-service.ts +++ b/src/infrastructure/file-watcher-service.ts @@ -3,7 +3,7 @@ import crypto from "crypto"; import { Mutex } from "async-mutex"; import { DirectoryPath } from "../types/file/directoryPath.js"; -export interface FileWatcherOptions { +interface FileWatcherOptions { ignored?: RegExp[]; ignoreInitial?: boolean; persistent?: boolean; @@ -11,14 +11,14 @@ export interface FileWatcherOptions { atomic?: boolean; } -export interface FileChangeEvent { +interface FileChangeEvent { event: string; path: string; eventId: string; } -export type FileChangeHandler = (event: FileChangeEvent) => Promise; -export type ErrorHandler = () => Promise; +type FileChangeHandler = (event: FileChangeEvent) => Promise; +type ErrorHandler = () => Promise; export class FileWatcherService { private watcher: chokidar.FSWatcher | null = null; @@ -99,8 +99,4 @@ export class FileWatcherService { public isWatching(): boolean { return this.watcher !== null; } - - public getWatcher(): chokidar.FSWatcher | null { - return this.watcher; - } } From c8de5ed15e1b74a577b19146ecb66337584c78e0 Mon Sep 17 00:00:00 2001 From: Ali Asghar <75574550+aliasghar98@users.noreply.github.com> Date: Fri, 29 Aug 2025 16:58:26 +0500 Subject: [PATCH 34/77] wip: refactor serve action into infra services --- src/actions/action-result.ts | 4 + src/actions/api/validate.ts | 22 ++-- src/actions/portal/quickstart.ts | 44 +++---- src/actions/portal/serve.ts | 114 +++++++++--------- .../file-watcher.ts} | 31 ++--- src/infrastructure/entities/live-server.ts | 52 ++++++++ src/infrastructure/live-reload-service.ts | 32 ----- src/infrastructure/server-service.ts | 45 ------- .../services/validation-service.ts | 2 +- src/prompts/portal/serve.ts | 16 +-- 10 files changed, 150 insertions(+), 212 deletions(-) rename src/infrastructure/{file-watcher-service.ts => entities/file-watcher.ts} (73%) create mode 100644 src/infrastructure/entities/live-server.ts delete mode 100644 src/infrastructure/live-reload-service.ts delete mode 100644 src/infrastructure/server-service.ts diff --git a/src/actions/action-result.ts b/src/actions/action-result.ts index 03f97b3f9..41d8ba8b6 100644 --- a/src/actions/action-result.ts +++ b/src/actions/action-result.ts @@ -33,6 +33,10 @@ export class ActionResult { return this.resultType.valueOf(); } + public isFailed() { + return this.resultType === ResultType.Failure; + } + public mapAll(onSuccess: () => T, onFailure: () => T, onCancel: () => T): T { switch (this.resultType) { case ResultType.Success: diff --git a/src/actions/api/validate.ts b/src/actions/api/validate.ts index 79f603bab..e4b7069be 100644 --- a/src/actions/api/validate.ts +++ b/src/actions/api/validate.ts @@ -2,8 +2,6 @@ import { DirectoryPath } from "../../types/file/directoryPath.js"; import { ActionResult } from "../action-result.js"; import { ApiValidatePrompts } from "../../prompts/api/validate.js"; import { ValidationService } from "../../infrastructure/services/validation-service.js"; -import { ApiValidationSummary } from "@apimatic/sdk"; -import { Result } from "neverthrow"; import { CommandMetadata } from "../../types/common/command-metadata.js"; import { ResourceInput } from "../../types/file/resource-input.js"; import { withDirPath } from "../../infrastructure/tmp-extensions.js"; @@ -21,17 +19,18 @@ export class ValidateAction { this.commandMetadata = commandMetadata; } - public readonly execute = async (resourcePath: ResourceInput): Promise => { + public readonly execute = async ( + resourcePath: ResourceInput, + displayValidationSummary = true + ): Promise => { return await withDirPath(async (tempDirectory) => { - - const resourceContext = new ResourceContext(tempDirectory); const specFileDirResult = await resourceContext.resolveTo(resourcePath); - if (specFileDirResult.isErr()){ + if (specFileDirResult.isErr()) { this.prompts.networkError(specFileDirResult.error); return ActionResult.failed(); } - const validationSummaryResult: Result= await this.prompts.validateApi( + const validationSummaryResult = await this.prompts.validateApi( this.validationService.validateViaFile({ file: specFileDirResult.value, commandMetadata: this.commandMetadata, @@ -44,13 +43,14 @@ export class ValidateAction { return ActionResult.failed(); } const validationSummary = validationSummaryResult.value; - if (validationSummary?.success) { + if (displayValidationSummary) { this.prompts.displayValidationMessages(validationSummary); + } + if (validationSummary?.success) { return ActionResult.success(); - } else { - this.prompts.displayValidationMessages(validationSummary); - return ActionResult.failed(); } + + return ActionResult.failed(); }); }; } diff --git a/src/actions/portal/quickstart.ts b/src/actions/portal/quickstart.ts index b1118f618..425bcd0ad 100644 --- a/src/actions/portal/quickstart.ts +++ b/src/actions/portal/quickstart.ts @@ -17,6 +17,7 @@ import { ResourceContext } from "../../types/resource-context.js"; import { FileName } from "../../types/file/fileName.js"; import { CommandMetadata } from "../../types/common/command-metadata.js"; import { createResourceInput } from "../../types/file/resource-input.js"; +import { ValidateAction } from "../api/validate.js"; const defaultSpecUrl: UrlPath = new UrlPath( "https://raw.githubusercontent.com/apimatic/static-portal-workflow/refs/heads/master/spec/openapi.json" @@ -149,42 +150,27 @@ export class PortalQuickstartAction { specDirectory: DirectoryPath ): Promise> { this.prompts.validateSpecStep(); - this.prompts.startProgressIndicator( - `Running your API Definition through APIMatic's 1200+ CodeGen Specific validation and linting rules` - ); - - //TODO: Replace with validate action. const specZipFilePath = new FilePath(tempDirectory, new FileName("spec.zip")); await this.zipService.archive(specDirectory, specZipFilePath); - const validationResult = await this.validationService.validateViaFile({ - file: specZipFilePath, - commandMetadata: this.commandMetadata - }); - // TODO: Add spinner when refactoring - if (validationResult.isErr()) { - this.prompts.stopProgressIndicator(`Something went wrong while validating your API Definition.`, 1); - return Result.failure(validationResult.error!); - } + const validateAction = new ValidateAction(this.configDir, this.commandMetadata); + const validationResult = await validateAction.execute(specZipFilePath, false); - const validationPassed = validationResult.value!.success; - if (validationPassed) { - this.prompts.stopProgressIndicator(`Validation Successful.`); - return Result.success(specDirectory); - } + if (validationResult.isFailed()) { + if (!(await this.prompts.useDefaultSpecPrompt())) { + return Result.cancelled(specDirectory); + } - this.prompts.stopProgressIndicator(`Oops, it looks like there are some errors in your API Definition.`, 1); - if (!(await this.prompts.useDefaultSpecPrompt())) { - return Result.cancelled(specDirectory); + const resourceContext = new ResourceContext(tempDirectory); + const result = await resourceContext.resolveTo(defaultSpecUrl); + if (result.isErr()) { + return Result.failure(result.error); + } + await this.fileService.cleanDirectory(specDirectory); + await this.fileService.copy(result.value, result.value.replaceDirectory(specDirectory)); } - // Use default spec... - const resourceContext = new ResourceContext(tempDirectory); - const result = await resourceContext.resolveTo(defaultSpecUrl); - if (result.isErr()) { - return Result.failure(result.error); - } - return Result.success(new DirectoryPath(result.value.toString(), "spec")); + return Result.success(specDirectory); } private async selectLanguages(): Promise> { diff --git a/src/actions/portal/serve.ts b/src/actions/portal/serve.ts index 819767768..2f1d7cf19 100644 --- a/src/actions/portal/serve.ts +++ b/src/actions/portal/serve.ts @@ -1,21 +1,17 @@ +import { once } from "events"; import { PortalServePrompts } from "../../prompts/portal/serve.js"; import { DirectoryPath } from "../../types/file/directoryPath.js"; import { ActionResult } from "../action-result.js"; import { CommandMetadata } from "../../types/common/command-metadata.js"; -import { GenerateAction } from "./generate.js"; +// import { GenerateAction } from "./generate.js"; import { NetworkService } from "../../infrastructure/network-service.js"; -import { UrlPath } from "../../types/file/urlPath.js"; import { LauncherService } from "../../infrastructure/launcher-service.js"; import { DebounceService } from "../../infrastructure/debounce-service.js"; -import { ServerService } from "../../infrastructure/server-service.js"; -import { LiveReloadService } from "../../infrastructure/live-reload-service.js"; -import { FileWatcherService } from "../../infrastructure/file-watcher-service.js"; +import { LiveServer } from "../../infrastructure/entities/live-server.js"; +import { FileWatcher } from "../../infrastructure/entities/file-watcher.js"; export class PortalServeAction { private readonly prompts: PortalServePrompts = new PortalServePrompts(); - private readonly serverService: ServerService = new ServerService(); - private readonly liveReloadService: LiveReloadService = new LiveReloadService(); - private readonly fileWatcherService: FileWatcherService = new FileWatcherService(); private readonly networkService: NetworkService = new NetworkService(); private readonly launcherService: LauncherService = new LauncherService(); private readonly debounceService: DebounceService = new DebounceService(); @@ -40,76 +36,76 @@ export class PortalServeAction { ): Promise { const servePort = await this.networkService.getServerPort([port, 3000, 3001, 3002]); if (servePort != port) { - this.prompts.portAlreadyInUse(port, servePort); + this.prompts.usingFallbackPort(port, servePort); } // Generate once, return early if there was a problem. - const generatePortalAction = new GenerateAction(this.configDir, this.commandMetadata, this.authKey); - const result = await generatePortalAction.execute(buildDirectory, portalDirectory, false, false); - const isFailedOrCancelledResult = result.mapAll( - () => null, - () => ActionResult.failed(), - () => ActionResult.cancelled() - ); - if (isFailedOrCancelledResult) { - return isFailedOrCancelledResult; - } + // const generatePortalAction = new GenerateAction(this.configDir, this.commandMetadata, this.authKey); + // const result = await generatePortalAction.execute(buildDirectory, portalDirectory, false, false); + // const isFailedOrCancelledResult = result.mapAll( + // () => null, + // () => ActionResult.failed(), + // () => ActionResult.cancelled() + // ); + // if (isFailedOrCancelledResult) { + // return isFailedOrCancelledResult; + // } + + + const liveServer = new LiveServer(); + const portalUrl = liveServer.start(portalDirectory, servePort, openInBrowser, !noReload); - const portalUrl = new UrlPath(`http://localhost:${servePort}`); if (displayServeCommandMessages) { this.prompts.portalServed(portalUrl); - this.prompts.waitingForChanges(); - } - if (openInBrowser) { - await this.launcherService.openUrlInBrowser(portalUrl); } - this.liveReloadService.start(); - this.serverService.use(this.liveReloadService.getMiddleware()); - this.serverService.serveStatic(portalDirectory, { extensions: ["html"] }); - this.serverService.start(servePort); - - this.fileWatcherService.startWatching(buildDirectory); - - const shutdown = async () => { - this.fileWatcherService.stopWatching(); - this.debounceService.close(); - this.liveReloadService.stop(); - this.serverService.stop(); - this.prompts.serverClosed(); - }; - - this.fileWatcherService.onFileChange(async () => { - await this.debounceService.execute(async () => { - this.prompts.changesDetected(); - await generatePortalAction.execute(buildDirectory, portalDirectory, true, false); - this.prompts.waitingForChanges(); - - this.liveReloadService.refresh(portalDirectory); - this.clearStandardInput(); + // Hot-reload enabled. + if (!noReload) { + const fileWatcher = new FileWatcher({ + ignored: [/(^|[/\\])\..+/], + ignoreInitial: true, + persistent: true, + awaitWriteFinish: true, + atomic: true }); - }); + fileWatcher.watch(buildDirectory); + fileWatcher.onFileChange(async () => { + await this.debounceService.execute(async () => { + this.prompts.changesDetected(); + await generatePortalAction.execute(buildDirectory, portalDirectory, true, false); + this.prompts.waitingForChanges(); - this.fileWatcherService.onError(async () => { - this.prompts.watcherError(); - shutdown(); - this.watcherFailedToRun = true; - }); + liveServer.refresh(portalDirectory); + this.clearStandardInput(); + }); + }); - // Wait for SIGINT or SIGTERM - if (!noReload) { - this.clearStandardInput(); + fileWatcher.onError(async () => { + this.prompts.watcherError(); + shutdown(); + this.watcherFailedToRun = true; + }); + + const shutdown = async () => { + await fileWatcher.stopWatching(); + this.debounceService.close(); + }; process.on("SIGINT", shutdown); process.on("SIGTERM", shutdown); + + this.prompts.waitingForChanges(); } else { - await shutdown(); + await this.blockExecution(); } + liveServer.stop(); + // TODO: Figure out a better way to achieve this. if (this.watcherFailedToRun) { return ActionResult.failed(); } + this.prompts.serverClosed(); return ActionResult.success(); } @@ -120,4 +116,8 @@ export class PortalServeAction { process.stdin.pause(); } } + + private async blockExecution() { + await Promise.race([once(process, "SIGINT"), once(process, "SIGTERM")]); + } } diff --git a/src/infrastructure/file-watcher-service.ts b/src/infrastructure/entities/file-watcher.ts similarity index 73% rename from src/infrastructure/file-watcher-service.ts rename to src/infrastructure/entities/file-watcher.ts index 520f802e0..38bf73c3e 100644 --- a/src/infrastructure/file-watcher-service.ts +++ b/src/infrastructure/entities/file-watcher.ts @@ -1,7 +1,7 @@ import chokidar from "chokidar"; import crypto from "crypto"; import { Mutex } from "async-mutex"; -import { DirectoryPath } from "../types/file/directoryPath.js"; +import { DirectoryPath } from "../../types/file/directoryPath.js"; interface FileWatcherOptions { ignored?: RegExp[]; @@ -20,37 +20,24 @@ interface FileChangeEvent { type FileChangeHandler = (event: FileChangeEvent) => Promise; type ErrorHandler = () => Promise; -export class FileWatcherService { - private watcher: chokidar.FSWatcher | null = null; - private readonly options: FileWatcherOptions; +export class FileWatcher { + private readonly watcher: chokidar.FSWatcher; private readonly deletedDirectories = new Set(); private readonly eventQueue = new Map(); private readonly mutex = new Mutex(); - public constructor(options: FileWatcherOptions = {}) { - this.options = { - ignored: [/(^|[/\\])\..+/], - ignoreInitial: true, - persistent: true, - awaitWriteFinish: true, - atomic: true, + constructor(options: FileWatcherOptions = {}) { + this.watcher = new chokidar.FSWatcher({ ...options - }; + }); } - public startWatching(directory: DirectoryPath): void { - if (this.watcher) { - return; - } - - this.watcher = chokidar.watch(directory.toString(), this.options); + public watch(directory: DirectoryPath): void { + this.watcher.add(directory.toString()); } public async stopWatching(): Promise { - if (this.watcher) { - await this.watcher.close(); - this.watcher = null; - } + this.watcher.close(); this.deletedDirectories.clear(); this.eventQueue.clear(); } diff --git a/src/infrastructure/entities/live-server.ts b/src/infrastructure/entities/live-server.ts new file mode 100644 index 000000000..61367edc2 --- /dev/null +++ b/src/infrastructure/entities/live-server.ts @@ -0,0 +1,52 @@ +import express, { Express } from "express"; +import { createServer as createLiveReloadServer, LiveReloadServer } from "livereload"; +import connectLiveReload from "connect-livereload"; +import { Server } from "http"; +import { DirectoryPath } from "../../types/file/directoryPath.js"; +import { UrlPath } from "../../types/file/urlPath.js"; +import { LauncherService } from "../launcher-service.js"; + +export class LiveServer { + private readonly launcherService: LauncherService = new LauncherService(); + private readonly application: Express = express(); + private liveReloadServer: LiveReloadServer | null = null; + private server: Server | null = null; + + constructor() { + this.application = express(); + } + + public start(directory: DirectoryPath, port: number, openInBrowser: boolean, hotReloadEnabled: boolean): UrlPath { + if (hotReloadEnabled) { + this.liveReloadServer = createLiveReloadServer(); + this.application.use(connectLiveReload()); + } + this.application.use(express.static(directory.toString(), { extensions: ["html"] })); + this.server = this.application.listen(port); + + const portalUrl = new UrlPath(`http://localhost:${port}`); + + if (openInBrowser) { + this.launcherService.openUrlInBrowser(portalUrl); + } + + return portalUrl; + } + + public stop(): void { + if (this.server) { + this.server.close(); + this.server = null; + } + if (this.liveReloadServer) { + this.liveReloadServer.close(); + this.liveReloadServer = null; + } + } + + public refresh(directory: DirectoryPath) { + if (this.liveReloadServer) { + this.liveReloadServer.refresh(directory.toString()); + } + } +} diff --git a/src/infrastructure/live-reload-service.ts b/src/infrastructure/live-reload-service.ts deleted file mode 100644 index 7ce04ccde..000000000 --- a/src/infrastructure/live-reload-service.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { createServer, LiveReloadServer } from "livereload"; -import connectLiveReload from "connect-livereload"; -import { DirectoryPath } from "../types/file/directoryPath.js"; - -export class LiveReloadService { - private liveReloadServer: LiveReloadServer | null = null; - - public start(): void { - this.liveReloadServer = createServer(); - } - - public stop(): void { - if (this.liveReloadServer) { - this.liveReloadServer.close(); - this.liveReloadServer = null; - } - } - - public refresh(directory: DirectoryPath): void { - if (this.liveReloadServer) { - this.liveReloadServer.refresh(directory.toString()); - } - } - - public getMiddleware(): ReturnType { - return connectLiveReload(); - } - - public isRunning(): boolean { - return this.liveReloadServer !== null; - } -} diff --git a/src/infrastructure/server-service.ts b/src/infrastructure/server-service.ts deleted file mode 100644 index cecaad955..000000000 --- a/src/infrastructure/server-service.ts +++ /dev/null @@ -1,45 +0,0 @@ -import express, { Express } from "express"; -import { Server } from "http"; -import { DirectoryPath } from "../types/file/directoryPath.js"; -import { HandleFunction } from "connect"; - -export class ServerService { - private readonly app: Express; - private server: Server | null = null; - - public constructor() { - this.app = express(); - } - - public use(middleware: HandleFunction): void { - this.app.use(middleware); - } - - public serveStatic(directory: DirectoryPath, options?: { extensions?: string[] }): void { - const staticOptions = { - extensions: options?.extensions || ["html"] - }; - this.app.use(express.static(directory.toString(), staticOptions)); - } - - public start(port: number): void { - this.server = this.app.listen(port); - } - - public stop(): Promise { - return new Promise((resolve) => { - if (this.server) { - this.server.close(() => { - this.server = null; - resolve(); - }); - } else { - resolve(); - } - }); - } - - public isRunning(): boolean { - return this.server !== null; - } -} diff --git a/src/infrastructure/services/validation-service.ts b/src/infrastructure/services/validation-service.ts index e4b47a91a..0df814c22 100644 --- a/src/infrastructure/services/validation-service.ts +++ b/src/infrastructure/services/validation-service.ts @@ -58,7 +58,7 @@ export class ValidationService { const apiError = error as ApiError; if (apiError.statusCode === 400) { - return "Your API Definition is invalid. Please check the errors and try again."; + return "Your API Definition is invalid. Please fix the issues and try again."; } else if (apiError.statusCode === 401) { return "You are not authorized to perform this action. Please run 'auth:login' or provide a valid auth key."; } else if (apiError.statusCode === 403) { diff --git a/src/prompts/portal/serve.ts b/src/prompts/portal/serve.ts index d30b55649..57f84522b 100644 --- a/src/prompts/portal/serve.ts +++ b/src/prompts/portal/serve.ts @@ -1,24 +1,10 @@ import console from "console"; import { log } from "@clack/prompts"; import { format as f } from "../format.js"; -import { DirectoryPath } from "../../types/file/directoryPath.js"; import { UrlPath } from "../../types/file/urlPath.js"; export class PortalServePrompts { - // TODO: Remove this method. - public logError(message: string) { - log.error(message); - } - - public startServerError(message: string) { - log.error(message); - } - - public createLiveReloadServerError(message: string) { - log.error(message); - } - - public portAlreadyInUse(currentPort: number, availablePort: number) { + public usingFallbackPort(currentPort: number, availablePort: number) { const message = `Port ${f.var(currentPort.toString())} is already in use. Available port ${f.var( availablePort.toString() )} will be used.`; From 216e545b83815ab89a39be7d1109a2c792382b8b Mon Sep 17 00:00:00 2001 From: Shield-Jaguar Date: Fri, 29 Aug 2025 17:54:24 +0500 Subject: [PATCH 35/77] feat: update before refactoring --- src/actions/portal/quickstart.ts | 4 +- src/actions/portal/serve.ts | 147 ++++++++++++++----------- src/infrastructure/debounce-service.ts | 2 +- src/prompts/portal/serve.ts | 2 + 4 files changed, 88 insertions(+), 67 deletions(-) diff --git a/src/actions/portal/quickstart.ts b/src/actions/portal/quickstart.ts index 425bcd0ad..a0dbd5054 100644 --- a/src/actions/portal/quickstart.ts +++ b/src/actions/portal/quickstart.ts @@ -225,8 +225,8 @@ export class PortalQuickstartAction { buildDirectory: DirectoryPath, portalDirectory: DirectoryPath ): Promise> { - const portalServeAction = new PortalServeAction(this.configDir, this.commandMetadata, null); - const result = await portalServeAction.execute(buildDirectory, portalDirectory, defaultPort, true, false, false); + const portalServeAction = new PortalServeAction(this.configDir, this.commandMetadata, null, false); + const result = await portalServeAction.execute(buildDirectory, portalDirectory, defaultPort, true, false); return result.mapAll>( () => { diff --git a/src/actions/portal/serve.ts b/src/actions/portal/serve.ts index 2f1d7cf19..8f4e074c3 100644 --- a/src/actions/portal/serve.ts +++ b/src/actions/portal/serve.ts @@ -1,29 +1,37 @@ +import { createServer } from "livereload"; +import connectLiveReload from "connect-livereload"; +import express, { Express } from "express"; +import chokidar from "chokidar"; +import crypto from "crypto"; +import { Mutex } from "async-mutex"; import { once } from "events"; import { PortalServePrompts } from "../../prompts/portal/serve.js"; import { DirectoryPath } from "../../types/file/directoryPath.js"; import { ActionResult } from "../action-result.js"; import { CommandMetadata } from "../../types/common/command-metadata.js"; -// import { GenerateAction } from "./generate.js"; +import { GenerateAction } from "./generate.js"; import { NetworkService } from "../../infrastructure/network-service.js"; +import { UrlPath } from "../../types/file/urlPath.js"; import { LauncherService } from "../../infrastructure/launcher-service.js"; import { DebounceService } from "../../infrastructure/debounce-service.js"; -import { LiveServer } from "../../infrastructure/entities/live-server.js"; -import { FileWatcher } from "../../infrastructure/entities/file-watcher.js"; export class PortalServeAction { - private readonly prompts: PortalServePrompts = new PortalServePrompts(); + private readonly prompts: PortalServePrompts; private readonly networkService: NetworkService = new NetworkService(); private readonly launcherService: LauncherService = new LauncherService(); private readonly debounceService: DebounceService = new DebounceService(); + private readonly application: Express = express(); private readonly configDir: DirectoryPath; private readonly commandMetadata: CommandMetadata; private readonly authKey: string | null; - private watcherFailedToRun: boolean = false; + private watcher: chokidar.FSWatcher | undefined; + private isPortalServed: boolean = false; - public constructor(configDir: DirectoryPath, commandMetadata: CommandMetadata, authKey: string | null = null) { + public constructor(configDir: DirectoryPath, commandMetadata: CommandMetadata, authKey: string | null = null, displayMessages: boolean = true) { this.configDir = configDir; this.commandMetadata = commandMetadata; this.authKey = authKey; + this.prompts = new PortalServePrompts(displayMessages); } public async execute( @@ -31,81 +39,92 @@ export class PortalServeAction { portalDirectory: DirectoryPath, port: number, openInBrowser: boolean, - noReload: boolean, - displayServeCommandMessages: boolean = true + hotReload: boolean, ): Promise { + const generatePortalAction = new GenerateAction(this.configDir, this.commandMetadata, this.authKey); + const servePort = await this.networkService.getServerPort([port, 3000, 3001, 3002]); if (servePort != port) { this.prompts.usingFallbackPort(port, servePort); } - // Generate once, return early if there was a problem. - // const generatePortalAction = new GenerateAction(this.configDir, this.commandMetadata, this.authKey); - // const result = await generatePortalAction.execute(buildDirectory, portalDirectory, false, false); - // const isFailedOrCancelledResult = result.mapAll( - // () => null, - // () => ActionResult.failed(), - // () => ActionResult.cancelled() - // ); - // if (isFailedOrCancelledResult) { - // return isFailedOrCancelledResult; - // } - - - const liveServer = new LiveServer(); - const portalUrl = liveServer.start(portalDirectory, servePort, openInBrowser, !noReload); - - if (displayServeCommandMessages) { - this.prompts.portalServed(portalUrl); - } + const liveReloadServer = createServer(); + const server = this.application + .use(connectLiveReload()) + .use(express.static(portalDirectory.toString(), { extensions: ["html"] })) + .listen(servePort); + + this.watcher = chokidar.watch(buildDirectory.toString(), { + ignored: [/(^|[/\\])\..+/], + ignoreInitial: false, // TODO: check this flag with Saeed + persistent: true, + awaitWriteFinish: true, + atomic: true + }); + + const deletedDirectories = new Set(); + const eventQueue = new Map(); + const mutex = new Mutex(); + + this.watcher + .on("all", async (event, path) => { + // triggers folder deletion as a single event + if (event == "unlinkDir") { + deletedDirectories.add(path); + } + if (event == "unlink") { + for (const dir of deletedDirectories) { + if (path.startsWith(dir)) { + return; + } + } + } + const eventId: string = `${Date.now()}-${crypto.randomUUID()}`; + await mutex.runExclusive(async () => { + eventQueue.clear(); + eventQueue.set(eventId, path); + }); + + await this.debounceService.batchSingleRequest(async () => { + if (this.isPortalServed) { + this.prompts.changesDetected(); + } - // Hot-reload enabled. - if (!noReload) { - const fileWatcher = new FileWatcher({ - ignored: [/(^|[/\\])\..+/], - ignoreInitial: true, - persistent: true, - awaitWriteFinish: true, - atomic: true - }); - fileWatcher.watch(buildDirectory); - fileWatcher.onFileChange(async () => { - await this.debounceService.execute(async () => { - this.prompts.changesDetected(); await generatePortalAction.execute(buildDirectory, portalDirectory, true, false); + // toDO: check for success + + const portalUrl = new UrlPath(`http://localhost:${servePort}`); + if (!this.isPortalServed) { + this.prompts.portalServed(portalUrl); + + if (openInBrowser) { + await this.launcherService.openUrlInBrowser(portalUrl); + } + this.isPortalServed = true; + } this.prompts.waitingForChanges(); - liveServer.refresh(portalDirectory); + liveReloadServer.refresh(portalDirectory.toString()); this.clearStandardInput(); }); - }); - - fileWatcher.onError(async () => { + }) + .on("error", async () => { this.prompts.watcherError(); - shutdown(); - this.watcherFailedToRun = true; + await shutdown(); }); - const shutdown = async () => { - await fileWatcher.stopWatching(); - this.debounceService.close(); - }; - process.on("SIGINT", shutdown); - process.on("SIGTERM", shutdown); - - this.prompts.waitingForChanges(); - } else { - await this.blockExecution(); - } - - liveServer.stop(); + // Wait for SIGINT or SIGTERM + await this.blockExecution(); - // TODO: Figure out a better way to achieve this. - if (this.watcherFailedToRun) { - return ActionResult.failed(); - } + // Clean up. + // end live reload code + const shutdown = async () => { + await this.watcher?.close(); + this.debounceService.close(); + liveReloadServer.close(); + server.close(); + }; - this.prompts.serverClosed(); return ActionResult.success(); } diff --git a/src/infrastructure/debounce-service.ts b/src/infrastructure/debounce-service.ts index 11b849454..3f483771b 100644 --- a/src/infrastructure/debounce-service.ts +++ b/src/infrastructure/debounce-service.ts @@ -10,7 +10,7 @@ export class DebounceService { this.debounceMs = debounceMs; } - async execute(handler: () => Promise): Promise { + async batchSingleRequest(handler: () => Promise): Promise { // Always store the latest handler this.latestHandler = handler; diff --git a/src/prompts/portal/serve.ts b/src/prompts/portal/serve.ts index 57f84522b..581e52cb4 100644 --- a/src/prompts/portal/serve.ts +++ b/src/prompts/portal/serve.ts @@ -4,6 +4,8 @@ import { format as f } from "../format.js"; import { UrlPath } from "../../types/file/urlPath.js"; export class PortalServePrompts { + constructor(private readonly displayMessages: boolean) {} + public usingFallbackPort(currentPort: number, availablePort: number) { const message = `Port ${f.var(currentPort.toString())} is already in use. Available port ${f.var( availablePort.toString() From 35cf3aa8e403a9fac2426906d00195c5fc3703c6 Mon Sep 17 00:00:00 2001 From: Shield-Jaguar Date: Fri, 29 Aug 2025 18:11:38 +0500 Subject: [PATCH 36/77] feat: update cleanup --- src/actions/portal/serve.ts | 51 ++++++++++++++++++++---------------- src/commands/portal/serve.ts | 2 +- src/prompts/portal/serve.ts | 8 +++--- 3 files changed, 34 insertions(+), 27 deletions(-) diff --git a/src/actions/portal/serve.ts b/src/actions/portal/serve.ts index 8f4e074c3..f5b70a69d 100644 --- a/src/actions/portal/serve.ts +++ b/src/actions/portal/serve.ts @@ -19,12 +19,11 @@ export class PortalServeAction { private readonly prompts: PortalServePrompts; private readonly networkService: NetworkService = new NetworkService(); private readonly launcherService: LauncherService = new LauncherService(); - private readonly debounceService: DebounceService = new DebounceService(); private readonly application: Express = express(); private readonly configDir: DirectoryPath; private readonly commandMetadata: CommandMetadata; private readonly authKey: string | null; - private watcher: chokidar.FSWatcher | undefined; + private isPortalServed: boolean = false; public constructor(configDir: DirectoryPath, commandMetadata: CommandMetadata, authKey: string | null = null, displayMessages: boolean = true) { @@ -54,7 +53,21 @@ export class PortalServeAction { .use(express.static(portalDirectory.toString(), { extensions: ["html"] })) .listen(servePort); - this.watcher = chokidar.watch(buildDirectory.toString(), { + if(!hotReload) { + await generatePortalAction.execute(buildDirectory, portalDirectory, true, false) + const portalUrl = new UrlPath(`http://localhost:${servePort}`); + this.prompts.portalServed(portalUrl); + if (openInBrowser) { + await this.launcherService.openUrlInBrowser(portalUrl); + } + this.prompts.promptForExit(); + await this.blockExecution(); + liveReloadServer.close(); + server.close(); + return ActionResult.success(); + } + + const watcher = chokidar.watch(buildDirectory.toString(), { ignored: [/(^|[/\\])\..+/], ignoreInitial: false, // TODO: check this flag with Saeed persistent: true, @@ -66,7 +79,9 @@ export class PortalServeAction { const eventQueue = new Map(); const mutex = new Mutex(); - this.watcher + const debounceService: DebounceService = new DebounceService(); + + watcher .on("all", async (event, path) => { // triggers folder deletion as a single event if (event == "unlinkDir") { @@ -85,7 +100,7 @@ export class PortalServeAction { eventQueue.set(eventId, path); }); - await this.debounceService.batchSingleRequest(async () => { + await debounceService.batchSingleRequest(async () => { if (this.isPortalServed) { this.prompts.changesDetected(); } @@ -102,41 +117,33 @@ export class PortalServeAction { } this.isPortalServed = true; } - this.prompts.waitingForChanges(); + this.prompts.promptForExit(); liveReloadServer.refresh(portalDirectory.toString()); - this.clearStandardInput(); }); }) .on("error", async () => { this.prompts.watcherError(); - await shutdown(); }); // Wait for SIGINT or SIGTERM - await this.blockExecution(); + // await this.blockExecution(); - // Clean up. - // end live reload code - const shutdown = async () => { - await this.watcher?.close(); - this.debounceService.close(); - liveReloadServer.close(); - server.close(); - }; + // await watcher.close(); + // liveReloadServer.close(); + // server.close(); + // debounceService.close(); return ActionResult.success(); } - // This clears the standard input to allow interrupts like CTRL+C to work properly. - private clearStandardInput() { + private async blockExecution() { + // This clears the standard input to allow interrupts like CTRL+C to work properly. + if (process.platform !== "darwin" && process.stdin.isTTY) { process.stdin.setRawMode(false); process.stdin.pause(); } - } - - private async blockExecution() { await Promise.race([once(process, "SIGINT"), once(process, "SIGTERM")]); } } diff --git a/src/commands/portal/serve.ts b/src/commands/portal/serve.ts index 55382aec0..e1b97961e 100644 --- a/src/commands/portal/serve.ts +++ b/src/commands/portal/serve.ts @@ -60,7 +60,7 @@ export default class PortalServe extends Command { intro("Portal Serve"); const portalServeAction = new PortalServeAction(this.getConfigDir(), commandMetadata, authKey); - const result = await portalServeAction.execute(buildDirectory, portalDirectory, port, open, noReload); + const result = await portalServeAction.execute(buildDirectory, portalDirectory, port, open, !noReload); outro(result); } diff --git a/src/prompts/portal/serve.ts b/src/prompts/portal/serve.ts index 581e52cb4..463536bf8 100644 --- a/src/prompts/portal/serve.ts +++ b/src/prompts/portal/serve.ts @@ -14,13 +14,13 @@ export class PortalServePrompts { } public portalServed(urlPath: UrlPath) { - const message = `Server running at: ${f.link(urlPath.toString())}`; + const message = `Portal hosted at ${f.link(urlPath.toString())}`; log.message(message); } - public waitingForChanges() { - const message = "Watching for changes... Press CTRL+C to stop."; - log.message(message); + public promptForExit() { + const message = "Press CTRL+C to exit."; + log.info(message); } public changesDetected() { From c3a87e676c2a1ade6c039f47201d1bf1d58f12a8 Mon Sep 17 00:00:00 2001 From: Shield-Jaguar Date: Fri, 29 Aug 2025 18:12:16 +0500 Subject: [PATCH 37/77] feat: update code --- src/actions/portal/serve.ts | 20 ++++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/src/actions/portal/serve.ts b/src/actions/portal/serve.ts index f5b70a69d..5ff95d4ba 100644 --- a/src/actions/portal/serve.ts +++ b/src/actions/portal/serve.ts @@ -61,6 +61,7 @@ export class PortalServeAction { await this.launcherService.openUrlInBrowser(portalUrl); } this.prompts.promptForExit(); + this.clearStandardInput(); await this.blockExecution(); liveReloadServer.close(); server.close(); @@ -120,6 +121,7 @@ export class PortalServeAction { this.prompts.promptForExit(); liveReloadServer.refresh(portalDirectory.toString()); + this.clearStandardInput(); }); }) .on("error", async () => { @@ -127,23 +129,25 @@ export class PortalServeAction { }); // Wait for SIGINT or SIGTERM - // await this.blockExecution(); + await this.blockExecution(); - // await watcher.close(); - // liveReloadServer.close(); - // server.close(); - // debounceService.close(); + await watcher.close(); + liveReloadServer.close(); + server.close(); + debounceService.close(); return ActionResult.success(); } - private async blockExecution() { - // This clears the standard input to allow interrupts like CTRL+C to work properly. - + // This clears the standard input to allow interrupts like CTRL+C to work properly. + private clearStandardInput() { if (process.platform !== "darwin" && process.stdin.isTTY) { process.stdin.setRawMode(false); process.stdin.pause(); } + } + + private async blockExecution() { await Promise.race([once(process, "SIGINT"), once(process, "SIGTERM")]); } } From e9890859c74d62f6fa031be10cd1c9bd6b220a3c Mon Sep 17 00:00:00 2001 From: Ali Asghar <75574550+aliasghar98@users.noreply.github.com> Date: Mon, 1 Sep 2025 11:34:42 +0500 Subject: [PATCH 38/77] refactor: removed unused code --- src/controllers/api/validate.ts | 34 --------------- src/utils/utils.ts | 74 --------------------------------- 2 files changed, 108 deletions(-) delete mode 100644 src/controllers/api/validate.ts diff --git a/src/controllers/api/validate.ts b/src/controllers/api/validate.ts deleted file mode 100644 index 6351ed067..000000000 --- a/src/controllers/api/validate.ts +++ /dev/null @@ -1,34 +0,0 @@ -import fsExtra from "fs-extra"; -import { ApiResponse, ApiValidationExternalApisController, ApiValidationSummary, ContentType, FileWrapper } from "@apimatic/sdk"; -import { GetValidationParams } from "../../types/api/validate.js"; -import { createTempDirectory, deleteFile, zipDirectory } from "../../utils/utils.js"; - -export const getValidationSummary = async ( - { file, url }: GetValidationParams, - apiValidationController: ApiValidationExternalApisController -): Promise => { - let validation: ApiResponse; - - if (file) { - const fileStatus = fsExtra.statSync(file); - if (fileStatus.isDirectory()) { - const tempDir = await createTempDirectory(); - const zipPath = await zipDirectory(file, tempDir); - const zipFile = new FileWrapper(fsExtra.createReadStream(zipPath)); - validation = await apiValidationController.validateApiViaFile(ContentType.EnumMultipartformdata, zipFile); - - await deleteFile(zipPath); - - await fsExtra.remove(tempDir); - } else { - const fileDescriptor = new FileWrapper(fsExtra.createReadStream(file)); - validation = await apiValidationController.validateApiViaFile(ContentType.EnumMultipartformdata, fileDescriptor); - } - } else if (url) { - validation = await apiValidationController.validateApiViaUrl(url); - } else { - throw new Error("Please provide a specification file"); - } - - return validation.result; -}; diff --git a/src/utils/utils.ts b/src/utils/utils.ts index 517f07075..e1a2cb807 100644 --- a/src/utils/utils.ts +++ b/src/utils/utils.ts @@ -1,66 +1,10 @@ import * as path from "path"; -import fs from "fs"; -import fsExtra from "fs-extra"; -import os from "os"; import { Buffer } from "buffer"; -import archiver from "archiver"; import stripTags from "striptags"; import colors from "picocolors"; import { loggers, ValidationMessages } from "../types/utils.js"; -export const createTempDirectory = async () => { - return fs.mkdtempSync(path.join(os.tmpdir(), "apimatic-cli-")); -}; - -export const deleteFile = async (filePath: string) => { - return await fsExtra.remove(filePath); -}; - -export const writeFileUsingReadableStream = (stream: NodeJS.ReadableStream, destinationPath: string) => { - return new Promise((resolve) => { - const writeStream = fs.createWriteStream(destinationPath); - stream.pipe(writeStream); - writeStream.on("close", () => { - resolve("Zipped"); - }); - }); -}; - -/** - * Packages local files into a ZIP archive - * - * @param {docsPortalFolderPath} path to portal directory. - * @param {destinationZipPath} path to generated zip - * return {string} - */ -export const zipDirectory = async (sourcePath: string, destinationPath: string): Promise => { - // Check if the directory exists for the user or not - await fsExtra.ensureDir(sourcePath); - - const zipPath = path.join(destinationPath, ".target.zip"); - const output = fs.createWriteStream(zipPath); - const archive = archiver("zip"); - - return new Promise((resolve, reject) => { - archive.on("error", (err: Error) => { - reject(new Error(err.message)); - }); - - output.on("error", (err) => { - reject(new Error(err.message)); - }); - output.on("close", () => { - resolve(zipPath); - }); - - archive.pipe(output); - - archive.directory(sourcePath, false); - - archive.finalize().catch(reject); - }); -}; export const replaceHTML = (string: string) => { return stripTags(string); @@ -70,24 +14,6 @@ export const getFileNameFromPath = (filePath: string) => { return path.basename(filePath).split(".")[0]; }; -export const printValidationMessages = ( - { warnings, errors, messages }: ValidationMessages, - { log, warn, error }: loggers -) => { - warnings = warnings || []; - messages = messages || []; - const singleError: string = errors.join("\n") || ""; - - messages.forEach((message) => { - log(`\nInfo: ${replaceHTML(message)}`); - }); - warnings.forEach((warning) => { - warn(replaceHTML(warning)); - }); - if (errors.length > 0) { - error(replaceHTML(singleError)); - } -}; export async function parseStreamBodyToJson(body: NodeJS.ReadableStream): Promise { const chunks: Buffer[] = []; From a49310f03b4993b594d15323c3d6d935107b65ba Mon Sep 17 00:00:00 2001 From: Shield-Jaguar Date: Mon, 1 Sep 2025 12:22:13 +0500 Subject: [PATCH 39/77] refactor --- src/actions/portal/quickstart.ts | 13 +++---- src/actions/portal/serve.ts | 9 ++--- src/commands/portal/quickstart.ts | 2 +- src/prompts/portal/quickstart.ts | 60 +++++++------------------------ src/prompts/portal/serve.ts | 7 ++-- 5 files changed, 23 insertions(+), 68 deletions(-) diff --git a/src/actions/portal/quickstart.ts b/src/actions/portal/quickstart.ts index 5deb40fc6..29e27654e 100644 --- a/src/actions/portal/quickstart.ts +++ b/src/actions/portal/quickstart.ts @@ -12,7 +12,6 @@ import { Result } from "../../types/common/result.js"; import { err, ok, Result as ResultEx } from "neverthrow"; import { ActionResult } from "../action-result.js"; import { PortalServeAction } from "./serve.js"; -import { ValidationService } from "../../infrastructure/services/validation-service.js"; import { ResourceContext } from "../../types/resource-context.js"; import { FileName } from "../../types/file/fileName.js"; import { CommandMetadata } from "../../types/common/command-metadata.js"; @@ -29,14 +28,12 @@ export class PortalQuickstartAction { private readonly zipService: ZipService = new ZipService(); private readonly fileService: FileService = new FileService(); private readonly portalScaffoldService: PortalScaffoldService = new PortalScaffoldService(); - private readonly validationService: ValidationService; private readonly configDir: DirectoryPath; private readonly commandMetadata: CommandMetadata; constructor(configDir: DirectoryPath, commandMetadata: CommandMetadata) { this.configDir = configDir; this.commandMetadata = commandMetadata; - this.validationService = new ValidationService(configDir); } public readonly execute = async (): Promise => { @@ -92,13 +89,12 @@ export class PortalQuickstartAction { return ActionResult.failed(); } - const generateAndServePortalResult = await this.servePortal(sourceDirectory, portalDirectory); - if (generateAndServePortalResult.isErr()) { - this.prompts.portalGenerationError(generateAndServePortalResult.error); + const servePortalResult = await this.servePortal(sourceDirectory, portalDirectory); + if (servePortalResult.isErr()) { return ActionResult.failed(); } - this.prompts.nextSteps(sourceDirectory.toString()); + this.prompts.nextSteps(); return ActionResult.success(); }); }; @@ -145,7 +141,6 @@ export class PortalQuickstartAction { return ok(specDirectory); } - // TODO: Needs to be refactored after refactoring validate action. private async validateSpec( tempDirectory: DirectoryPath, specDirectory: DirectoryPath @@ -209,7 +204,7 @@ export class PortalQuickstartAction { selectedLanguages: string[] ): Promise> { const result = await this.prompts.createBuildDirectory( - sourceDirectory.toString(), + sourceDirectory, this.portalScaffoldService.createBuildDirectory(tempDirectory, specDirectory, selectedLanguages) ); if (result.isErr()) { diff --git a/src/actions/portal/serve.ts b/src/actions/portal/serve.ts index 5ff95d4ba..9c46873f8 100644 --- a/src/actions/portal/serve.ts +++ b/src/actions/portal/serve.ts @@ -4,7 +4,6 @@ import express, { Express } from "express"; import chokidar from "chokidar"; import crypto from "crypto"; import { Mutex } from "async-mutex"; -import { once } from "events"; import { PortalServePrompts } from "../../prompts/portal/serve.js"; import { DirectoryPath } from "../../types/file/directoryPath.js"; import { ActionResult } from "../action-result.js"; @@ -62,7 +61,7 @@ export class PortalServeAction { } this.prompts.promptForExit(); this.clearStandardInput(); - await this.blockExecution(); + await this.prompts.blockExecution(); liveReloadServer.close(); server.close(); return ActionResult.success(); @@ -129,7 +128,7 @@ export class PortalServeAction { }); // Wait for SIGINT or SIGTERM - await this.blockExecution(); + await this.prompts.blockExecution(); await watcher.close(); @@ -146,8 +145,4 @@ export class PortalServeAction { process.stdin.pause(); } } - - private async blockExecution() { - await Promise.race([once(process, "SIGINT"), once(process, "SIGTERM")]); - } } diff --git a/src/commands/portal/quickstart.ts b/src/commands/portal/quickstart.ts index ce8defb3f..9c8c7bd5d 100644 --- a/src/commands/portal/quickstart.ts +++ b/src/commands/portal/quickstart.ts @@ -22,11 +22,11 @@ export default class PortalQuickstart extends Command { commandName: PortalQuickstart.id, shell: this.config.shell }; - const action = new PortalQuickstartAction(this.getConfigDir(), commandMetadata); await telemetryService.trackEvent(new QuickstartInitiatedEvent(), commandMetadata.shell); intro("Portal Quickstart"); + const action = new PortalQuickstartAction(this.getConfigDir(), commandMetadata); const result = await action.execute(); outro(result); diff --git a/src/prompts/portal/quickstart.ts b/src/prompts/portal/quickstart.ts index c23a268b9..64e6035d2 100644 --- a/src/prompts/portal/quickstart.ts +++ b/src/prompts/portal/quickstart.ts @@ -2,7 +2,7 @@ import * as path from "path"; import fs from "fs"; import axios from "axios"; import treeify from "treeify"; -import { text, select, multiselect, log, isCancel, spinner } from "@clack/prompts"; +import { text, select, multiselect, log, isCancel, spinner, note } from "@clack/prompts"; import { getMessageInGreenColor, getMessageInRedColor } from "../../utils/utils.js"; import { UrlPath } from "../../types/file/urlPath.js"; import { format as f, withSpinner } from "../format.js"; @@ -12,7 +12,6 @@ import { FileService } from "../../infrastructure/file-service.js"; const vscodeExtensionUrl = "https://marketplace.visualstudio.com/items?itemName=apimatic-developers.apimatic-for-vscode"; -const serverUrl = "http://localhost:3000"; const referenceDocumentationUrl = "https://docs.apimatic.io/cli-getting-started/advanced-portal-setup"; const descriptions: { [key: string]: string } = Object.entries({ "APIMATIC-BUILD.json": "# Defines all configurations for the API portal, including programming languages and themes", @@ -49,11 +48,6 @@ export class PortalQuickstartPrompts { log.error(message); } - public loginSuccessful(email: string) { - const message = `Logged in as: ${email}`; - log.step(message); - } - public importSpecStep() { const message = `Step 1 of 4: Import your OpenAPI Definition`; log.step(message); @@ -113,10 +107,6 @@ export class PortalQuickstartPrompts { } } - public noSpecProvided() { - log.error("Operation cancelled. No API Definition was provided."); - } - public specImportError(error: string) { log.error(error); } @@ -154,26 +144,6 @@ export class PortalQuickstartPrompts { log.step(message); } - // TODO: Add once you have refactored the validate action. - public validateSpec(fn: Promise>) { - return withSpinner( - "Running your API Definition through APIMatic's 1200+ CodeGen Specific validation and linting rules", - "Validation Successful.", - "Something went wrong while validating your API Definition.", - fn - ); - } - - // TODO: Remove after refactoring validate action. - public startProgressIndicator(message: string) { - this.spin.start(message); - } - - // TODO: Remove after refactoring validate action. - public stopProgressIndicator(message: string, statusCode?: number) { - this.spin.stop(message, statusCode); - } - public selectLanguagesStep() { const message = `Step 3 of 4: Select programming languages`; log.step(message); @@ -250,6 +220,8 @@ export class PortalQuickstartPrompts { if (directory === "./") { return defaultPortalDirectoryPath; } else { + // TODO: MOVe this method to string urils + // TODO: return domain class return this.fileService.removeQuotes(String(directory).trim()); } } @@ -258,15 +230,16 @@ export class PortalQuickstartPrompts { log.error("Operation cancelled. No build directory was provided."); } - public createBuildDirectory(sourceDirectory: string, fn: Promise>) { + public createBuildDirectory(sourceDirectory: DirectoryPath, fn: Promise>) { return withSpinner( "Generating build directory", - `Directory created at ${sourceDirectory}`, + `Directory created at ${f.path(sourceDirectory.toString())}`, "Something went wrong while setting up your build directory.", fn ); } + // TODO: remove this message param public buildSetupError(message: string) { log.error(message); } @@ -287,21 +260,12 @@ export class PortalQuickstartPrompts { log.step(coloredLogString); } - public portalGenerationError(error: string) { - log.error(error); - } + public nextSteps(): void { + const message = + `Use the API Playground or an SDK to call your API. +Customize the Portal theme, add API recipes and enable AI features +${f.link(referenceDocumentationUrl)}`; - public nextSteps(buildDirectory: string): void { - const firstMessage = - `Your API Portal is live at: ${f.path(serverUrl)}\n` + - `Hot reload enabled! Edit files in ${f.path(buildDirectory)} to see changes instantly reflected in your API Portal.\n` + - `Press CTRL+C to stop the server.`; - const secondMessage = - `What's next?\n` + - `- Use the API Playground or an SDK to call your API.\n` + - `- Customize the Portal theme, add API recipes and enable AI features: ${f.path(referenceDocumentationUrl)}`; - - log.step(firstMessage); - log.step(secondMessage); + note(message, "Next steps"); } } diff --git a/src/prompts/portal/serve.ts b/src/prompts/portal/serve.ts index 463536bf8..58c3599de 100644 --- a/src/prompts/portal/serve.ts +++ b/src/prompts/portal/serve.ts @@ -2,6 +2,7 @@ import console from "console"; import { log } from "@clack/prompts"; import { format as f } from "../format.js"; import { UrlPath } from "../../types/file/urlPath.js"; +import { once } from "events"; export class PortalServePrompts { constructor(private readonly displayMessages: boolean) {} @@ -35,8 +36,8 @@ export class PortalServePrompts { log.error(message); } - public serverClosed() { - const message = "Server shutdown successfully."; - console.log(message); + + public async blockExecution() { + if (this.displayMessages) await Promise.race([once(process, "SIGINT"), once(process, "SIGTERM")]); } } From fc0f22d031b418928344509adf15d68487661d7e Mon Sep 17 00:00:00 2001 From: Ali Asghar <75574550+aliasghar98@users.noreply.github.com> Date: Mon, 1 Sep 2025 14:49:00 +0500 Subject: [PATCH 40/77] chore: added copilot-instructions to git ignore --- .gitignore | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 250d0fb7d..581cc4cc7 100644 --- a/.gitignore +++ b/.gitignore @@ -179,4 +179,5 @@ tmp/ test-source/ test-destination/ test-portal/ -/.idea/ \ No newline at end of file +/.idea/ +.github/copilot-instructions.md \ No newline at end of file From c2d07c475997053314f9632b3d377cebcbc7a968 Mon Sep 17 00:00:00 2001 From: Ali Asghar <75574550+aliasghar98@users.noreply.github.com> Date: Mon, 1 Sep 2025 14:52:57 +0500 Subject: [PATCH 41/77] fix: improved some areas and fixed serve integration with quickstart --- src/actions/portal/quickstart.ts | 29 ++++++---- src/actions/portal/serve.ts | 29 +++++++--- src/infrastructure/file-service.ts | 11 ---- .../services/portal-scaffold-service.ts | 53 +++++++++---------- src/prompts/portal/quickstart.ts | 36 ++++++------- src/prompts/portal/serve.ts | 2 - src/types/utils.ts | 11 ---- src/utils/string-utils.ts | 10 ++++ src/utils/utils.ts | 4 -- 9 files changed, 92 insertions(+), 93 deletions(-) create mode 100644 src/utils/string-utils.ts diff --git a/src/actions/portal/quickstart.ts b/src/actions/portal/quickstart.ts index 29e27654e..d32c6d7f3 100644 --- a/src/actions/portal/quickstart.ts +++ b/src/actions/portal/quickstart.ts @@ -17,6 +17,7 @@ import { FileName } from "../../types/file/fileName.js"; import { CommandMetadata } from "../../types/common/command-metadata.js"; import { createResourceInput } from "../../types/file/resource-input.js"; import { ValidateAction } from "../api/validate.js"; +import { ServiceError } from "../../infrastructure/api-utils.js"; const defaultSpecUrl: UrlPath = new UrlPath( "https://raw.githubusercontent.com/apimatic/static-portal-workflow/refs/heads/master/spec/openapi.json" @@ -85,7 +86,6 @@ export class PortalQuickstartAction { selectedLanguagesResult.value ); if (buildDirectoryResult.isErr()) { - this.prompts.buildSetupError(buildDirectoryResult.error); return ActionResult.failed(); } @@ -94,12 +94,10 @@ export class PortalQuickstartAction { return ActionResult.failed(); } - this.prompts.nextSteps(); return ActionResult.success(); }); }; - // TODO: LoginAction needs to be fixed to use prompts framework, then we can fix this. private async authenticateUser(): Promise> { const storedAuth = await getAuthInfo(this.configDir.toString()); if (storedAuth?.authKey) { @@ -117,7 +115,6 @@ export class PortalQuickstartAction { ); } - // TODO: create TempSpecContext and then refactor this. private async importSpec(tempDirectory: DirectoryPath): Promise> { this.prompts.importSpecStep(); const inputPath = await this.prompts.specPathPrompt(defaultSpecUrl); @@ -144,7 +141,7 @@ export class PortalQuickstartAction { private async validateSpec( tempDirectory: DirectoryPath, specDirectory: DirectoryPath - ): Promise> { + ): Promise> { this.prompts.validateSpecStep(); const specZipFilePath = new FilePath(tempDirectory, new FileName("spec.zip")); await this.zipService.archive(specDirectory, specZipFilePath); @@ -186,14 +183,13 @@ export class PortalQuickstartAction { > { this.prompts.selectInputDirectoryStep(); - const inputDirectoryPath = await this.prompts.inputDirectoryPathPrompt(); - if (inputDirectoryPath === null) { + const inputDirectory = await this.prompts.inputDirectoryPathPrompt(); + if (inputDirectory === null) { this.prompts.noInputDirectoryProvided(); return err("cancelled"); } - const workingDirectory = new DirectoryPath(inputDirectoryPath); - return ok({ sourceDirectory: workingDirectory.join("src"), portalDirectory: workingDirectory.join("portal") }); + return ok({ sourceDirectory: inputDirectory.join("src"), portalDirectory: inputDirectory.join("portal") }); } //TODO: Remove tempDirectory as param. @@ -202,12 +198,13 @@ export class PortalQuickstartAction { sourceDirectory: DirectoryPath, specDirectory: DirectoryPath, selectedLanguages: string[] - ): Promise> { + ): Promise> { const result = await this.prompts.createBuildDirectory( sourceDirectory, this.portalScaffoldService.createBuildDirectory(tempDirectory, specDirectory, selectedLanguages) ); if (result.isErr()) { + this.prompts.buildSetupError(result.error); return err(result.error); } @@ -222,8 +219,18 @@ export class PortalQuickstartAction { portalDirectory: DirectoryPath ): Promise> { const portalServeAction = new PortalServeAction(this.configDir, this.commandMetadata, null, false); - const result = await portalServeAction.execute(buildDirectory, portalDirectory, defaultPort, true, false); + const result = await portalServeAction.execute( + buildDirectory, + portalDirectory, + defaultPort, + true, + false, + async () => { + this.prompts.nextSteps(); + } + ); + // TODO: Figure out a better way for this. return result.mapAll>( () => { return ok("Generated portal and served it successfully."); diff --git a/src/actions/portal/serve.ts b/src/actions/portal/serve.ts index 9c46873f8..425dd28cf 100644 --- a/src/actions/portal/serve.ts +++ b/src/actions/portal/serve.ts @@ -1,4 +1,4 @@ -import { createServer } from "livereload"; +import { createServer as createLiveReloadServer } from "livereload"; import connectLiveReload from "connect-livereload"; import express, { Express } from "express"; import chokidar from "chokidar"; @@ -25,7 +25,12 @@ export class PortalServeAction { private isPortalServed: boolean = false; - public constructor(configDir: DirectoryPath, commandMetadata: CommandMetadata, authKey: string | null = null, displayMessages: boolean = true) { + public constructor( + configDir: DirectoryPath, + commandMetadata: CommandMetadata, + authKey: string | null = null, + displayMessages: boolean = true + ) { this.configDir = configDir; this.commandMetadata = commandMetadata; this.authKey = authKey; @@ -38,6 +43,7 @@ export class PortalServeAction { port: number, openInBrowser: boolean, hotReload: boolean, + onInterrupt?: () => Promise ): Promise { const generatePortalAction = new GenerateAction(this.configDir, this.commandMetadata, this.authKey); @@ -46,14 +52,14 @@ export class PortalServeAction { this.prompts.usingFallbackPort(port, servePort); } - const liveReloadServer = createServer(); + const liveReloadServer = createLiveReloadServer(); const server = this.application .use(connectLiveReload()) .use(express.static(portalDirectory.toString(), { extensions: ["html"] })) .listen(servePort); - if(!hotReload) { - await generatePortalAction.execute(buildDirectory, portalDirectory, true, false) + if (!hotReload) { + await generatePortalAction.execute(buildDirectory, portalDirectory, true, false); const portalUrl = new UrlPath(`http://localhost:${servePort}`); this.prompts.portalServed(portalUrl); if (openInBrowser) { @@ -61,6 +67,16 @@ export class PortalServeAction { } this.prompts.promptForExit(); this.clearStandardInput(); + + if (onInterrupt) { + await onInterrupt(); + process.once("SIGINT", async () => { + liveReloadServer.close(); + server.close(); + }); + return ActionResult.success(); + } + await this.prompts.blockExecution(); liveReloadServer.close(); server.close(); @@ -79,7 +95,7 @@ export class PortalServeAction { const eventQueue = new Map(); const mutex = new Mutex(); - const debounceService: DebounceService = new DebounceService(); + const debounceService: DebounceService = new DebounceService(); watcher .on("all", async (event, path) => { @@ -130,7 +146,6 @@ export class PortalServeAction { // Wait for SIGINT or SIGTERM await this.prompts.blockExecution(); - await watcher.close(); liveReloadServer.close(); server.close(); diff --git a/src/infrastructure/file-service.ts b/src/infrastructure/file-service.ts index abc47118f..4bc6734e2 100644 --- a/src/infrastructure/file-service.ts +++ b/src/infrastructure/file-service.ts @@ -94,17 +94,6 @@ export class FileService { await fsExtra.copyFile(source.toString(), destination.toString()); } - public removeQuotes(path: string): string { - const quotes = ['"', "'"]; - - for (const quote of quotes) { - if (path.startsWith(quote) && path.endsWith(quote) && path.length > 1) { - return this.removeQuotes(path.slice(1, -1)); // Recursive call - } - } - return path; - } - public convertDirectoryStructureToJson( directoryPath: string, descriptions: { [key: string]: string }, diff --git a/src/infrastructure/services/portal-scaffold-service.ts b/src/infrastructure/services/portal-scaffold-service.ts index 7b0024e03..782f31a0c 100644 --- a/src/infrastructure/services/portal-scaffold-service.ts +++ b/src/infrastructure/services/portal-scaffold-service.ts @@ -7,6 +7,7 @@ import { BuildContext } from "../../types/build-context.js"; import { FilePath } from "../../types/file/filePath.js"; import { UrlPath } from "../../types/file/urlPath.js"; import { ZipService } from "../zip-service.js"; +import { ServiceError } from "../api-utils.js"; export class PortalScaffoldService { private readonly fileService: FileService = new FileService(); @@ -19,36 +20,30 @@ export class PortalScaffoldService { tempDirectory: DirectoryPath, specDirectory: DirectoryPath, selectedLanguages: string[] - ): Promise> { - try { - const resourceContext = new ResourceContext(tempDirectory); - // TODO: repositoryFolderName should be handled - const result = await resourceContext.resolveTo(new UrlPath(this.zipUrl)); - if (result.isErr()) { - return err("Unable to setup the portal, please try again later."); - } - await this.zipService.unArchive(result.value, tempDirectory); - const extractedFolder = tempDirectory.join(this.repositoryFolderName); - await this.fileService.deleteDirectory(extractedFolder.join(".github")); + ): Promise> { + const resourceContext = new ResourceContext(tempDirectory); + // TODO: repositoryFolderName should be handled + const result = await resourceContext.resolveTo(new UrlPath(this.zipUrl)); + if (result.isErr()) { + return err(result.error); + } + await this.zipService.unArchive(result.value, tempDirectory); + const extractedFolder = tempDirectory.join(this.repositoryFolderName); + await this.fileService.deleteDirectory(extractedFolder.join(".github")); - // Setup spec. - const tempSpecDirectory = extractedFolder.join("spec"); - // TODO: Replace this with SpecContext - await this.fileService.deleteFile(new FilePath(tempSpecDirectory, this.defaultSpecFileName)); - await this.fileService.copyDirectory(specDirectory, tempSpecDirectory); - const buildContext = new BuildContext(extractedFolder); - const buildFile = await buildContext.getBuildFileContents(); - buildFile.generatePortal!.languageConfig = selectedLanguages.reduce((config, lang) => { - config[lang] = {}; - return config; - }, {} as { [key: string]: object }); - await buildContext.updateBuildFileContents(buildFile); + // Setup spec. + const tempSpecDirectory = extractedFolder.join("spec"); + // TODO: Replace this with SpecContext + await this.fileService.deleteFile(new FilePath(tempSpecDirectory, this.defaultSpecFileName)); + await this.fileService.copyDirectory(specDirectory, tempSpecDirectory); + const buildContext = new BuildContext(extractedFolder); + const buildFile = await buildContext.getBuildFileContents(); + buildFile.generatePortal!.languageConfig = selectedLanguages.reduce((config, lang) => { + config[lang] = {}; + return config; + }, {} as { [key: string]: object }); + await buildContext.updateBuildFileContents(buildFile); - return ok(extractedFolder); - } catch { - return err( - "There was an error setting up your portal. Please try again later. If the problem persists, please reach out to our team at support@apimatic.io " - ); - } + return ok(extractedFolder); } } diff --git a/src/prompts/portal/quickstart.ts b/src/prompts/portal/quickstart.ts index 64e6035d2..0b3080101 100644 --- a/src/prompts/portal/quickstart.ts +++ b/src/prompts/portal/quickstart.ts @@ -2,17 +2,21 @@ import * as path from "path"; import fs from "fs"; import axios from "axios"; import treeify from "treeify"; +import { Result } from "neverthrow"; import { text, select, multiselect, log, isCancel, spinner, note } from "@clack/prompts"; import { getMessageInGreenColor, getMessageInRedColor } from "../../utils/utils.js"; import { UrlPath } from "../../types/file/urlPath.js"; import { format as f, withSpinner } from "../format.js"; import { DirectoryPath } from "../../types/file/directoryPath.js"; -import { Result } from "neverthrow"; import { FileService } from "../../infrastructure/file-service.js"; +import { removeQuotes } from "../../utils/string-utils.js"; +import { ServiceError } from "../../infrastructure/api-utils.js"; const vscodeExtensionUrl = "https://marketplace.visualstudio.com/items?itemName=apimatic-developers.apimatic-for-vscode"; const referenceDocumentationUrl = "https://docs.apimatic.io/cli-getting-started/advanced-portal-setup"; +const defaultPortalDirectoryPath = process.cwd(); + const descriptions: { [key: string]: string } = Object.entries({ "APIMATIC-BUILD.json": "# Defines all configurations for the API portal, including programming languages and themes", spec: "# Contains all API definition files", @@ -23,7 +27,6 @@ const descriptions: { [key: string]: string } = Object.entries({ acc[path.normalize(key)] = value; return acc; }, {} as { [key: string]: string }); -const defaultPortalDirectoryPath = process.cwd(); export class PortalQuickstartPrompts { private readonly fileService = new FileService(); @@ -63,7 +66,7 @@ export class PortalQuickstartPrompts { validate: (input) => { if (!input) return; - const cleanedPath = this.fileService.removeQuotes(input.trim() ?? ""); + const cleanedPath = removeQuotes(input.trim() ?? ""); if (!UrlPath.create(cleanedPath)) { const dirPath = path.resolve(cleanedPath); @@ -85,7 +88,7 @@ export class PortalQuickstartPrompts { return null; } - const cleanedPath = this.fileService.removeQuotes(String(spec).trim()); + const cleanedPath = removeQuotes(String(spec).trim()); // Async validation for URLs if (UrlPath.create(cleanedPath)) { @@ -181,29 +184,28 @@ export class PortalQuickstartPrompts { log.step(message); } - public async inputDirectoryPathPrompt(): Promise { + public async inputDirectoryPathPrompt(): Promise { const directory = await text({ 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 cleanedPath = this.fileService.removeQuotes(input?.trim() ?? ""); - const inputDirectory = new DirectoryPath(cleanedPath); + const cleanedPath = removeQuotes(input?.trim() ?? ""); - if (!fs.existsSync(inputDirectory.toString()) && inputDirectory.toString() != defaultPortalDirectoryPath) { + if (!fs.existsSync(cleanedPath.toString()) && cleanedPath.toString() != defaultPortalDirectoryPath) { return getMessageInRedColor("Error: The specified directory path does not exist. Please try again."); } - if (inputDirectory.toString() !== defaultPortalDirectoryPath) { - const files = fs.readdirSync(inputDirectory.toString()).filter((item) => !item.startsWith(".")); + if (cleanedPath.toString() !== defaultPortalDirectoryPath) { + const files = fs.readdirSync(cleanedPath.toString()).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." ); } - } else if (fs.existsSync(inputDirectory.toString())) { + } else if (fs.existsSync(cleanedPath.toString())) { // For ignoring hidden files and folders in the current directory in MacOS. - const files = fs.readdirSync(inputDirectory.toString()).filter((item) => !item.startsWith(".")); + const files = fs.readdirSync(cleanedPath.toString()).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." @@ -218,11 +220,10 @@ export class PortalQuickstartPrompts { } if (directory === "./") { - return defaultPortalDirectoryPath; + return new DirectoryPath(defaultPortalDirectoryPath); } else { - // TODO: MOVe this method to string urils // TODO: return domain class - return this.fileService.removeQuotes(String(directory).trim()); + return new DirectoryPath(removeQuotes(directory as string).trim()); } } @@ -230,7 +231,7 @@ export class PortalQuickstartPrompts { log.error("Operation cancelled. No build directory was provided."); } - public createBuildDirectory(sourceDirectory: DirectoryPath, fn: Promise>) { + public createBuildDirectory(sourceDirectory: DirectoryPath, fn: Promise>) { return withSpinner( "Generating build directory", `Directory created at ${f.path(sourceDirectory.toString())}`, @@ -261,8 +262,7 @@ export class PortalQuickstartPrompts { } public nextSteps(): void { - const message = - `Use the API Playground or an SDK to call your API. + const message = `Use the API Playground or an SDK to call your API. Customize the Portal theme, add API recipes and enable AI features ${f.link(referenceDocumentationUrl)}`; diff --git a/src/prompts/portal/serve.ts b/src/prompts/portal/serve.ts index 58c3599de..5d9e21568 100644 --- a/src/prompts/portal/serve.ts +++ b/src/prompts/portal/serve.ts @@ -1,4 +1,3 @@ -import console from "console"; import { log } from "@clack/prompts"; import { format as f } from "../format.js"; import { UrlPath } from "../../types/file/urlPath.js"; @@ -36,7 +35,6 @@ export class PortalServePrompts { log.error(message); } - public async blockExecution() { if (this.displayMessages) await Promise.race([once(process, "SIGINT"), once(process, "SIGTERM")]); } diff --git a/src/types/utils.ts b/src/types/utils.ts index 0618f5c95..9cef57d53 100644 --- a/src/types/utils.ts +++ b/src/types/utils.ts @@ -3,14 +3,3 @@ export type ValidationMessages = { warnings: string[]; errors: string[]; }; - -export type loggers = { - log: (message: string) => void; - warn: (message: string) => void; - error: (message: string) => void; -}; - -export type AuthenticationError = { - statusCode: number; - body: string; -}; diff --git a/src/utils/string-utils.ts b/src/utils/string-utils.ts new file mode 100644 index 000000000..5a067272e --- /dev/null +++ b/src/utils/string-utils.ts @@ -0,0 +1,10 @@ +export const removeQuotes = (path: string): string => { + const quotes = ['"', "'"]; + + for (const quote of quotes) { + if (path.startsWith(quote) && path.endsWith(quote) && path.length > 1) { + return removeQuotes(path.slice(1, -1)); // Recursive call + } + } + return path; +}; diff --git a/src/utils/utils.ts b/src/utils/utils.ts index e1a2cb807..c87d6048d 100644 --- a/src/utils/utils.ts +++ b/src/utils/utils.ts @@ -3,9 +3,6 @@ import { Buffer } from "buffer"; import stripTags from "striptags"; import colors from "picocolors"; -import { loggers, ValidationMessages } from "../types/utils.js"; - - export const replaceHTML = (string: string) => { return stripTags(string); }; @@ -14,7 +11,6 @@ export const getFileNameFromPath = (filePath: string) => { return path.basename(filePath).split(".")[0]; }; - export async function parseStreamBodyToJson(body: NodeJS.ReadableStream): Promise { const chunks: Buffer[] = []; for await (const chunk of body) { From 908cf697d2994390e3def6d7ed1baa8534f587cc Mon Sep 17 00:00:00 2001 From: Shield-Jaguar Date: Mon, 1 Sep 2025 16:17:05 +0500 Subject: [PATCH 42/77] refactor --- src/actions/auth/login.ts | 5 +- src/actions/portal/quickstart.ts | 104 ++++++++---------- src/infrastructure/file-service.ts | 4 +- .../services/portal-scaffold-service.ts | 49 --------- src/prompts/portal/quickstart.ts | 45 ++++---- src/types/build-context.ts | 21 +++- src/types/build/build.ts | 7 ++ src/types/file/directoryNode.ts | 2 +- src/utils/string-utils.ts | 8 +- 9 files changed, 99 insertions(+), 146 deletions(-) delete mode 100644 src/infrastructure/services/portal-scaffold-service.ts diff --git a/src/actions/auth/login.ts b/src/actions/auth/login.ts index 77cbb6717..f2e72601d 100644 --- a/src/actions/auth/login.ts +++ b/src/actions/auth/login.ts @@ -8,9 +8,8 @@ import { LoginPrompts } from "../../prompts/auth/login.js"; import { CommandMetadata } from "../../types/common/command-metadata.js"; import { ActionResult } from "../action-result.js"; import { err, ok, Result } from "neverthrow"; -import { StatusAction } from "./status.js"; -type LoginTimout = "TIMEOUT"; +type LoginTimeout = "TIMEOUT"; export class LoginAction { private readonly authService = new AuthService(); @@ -41,7 +40,7 @@ export class LoginAction { return ActionResult.success(); } - private async pollDeviceToken(apiKey: string | undefined, shell: string): Promise> { + private async pollDeviceToken(apiKey: string | undefined, shell: string): Promise> { if (apiKey) return ok(apiKey); const state = uuid(); this.prompts.openBrowser(); diff --git a/src/actions/portal/quickstart.ts b/src/actions/portal/quickstart.ts index d32c6d7f3..e3eca6bbf 100644 --- a/src/actions/portal/quickstart.ts +++ b/src/actions/portal/quickstart.ts @@ -6,7 +6,6 @@ import { PortalQuickstartPrompts } from "../../prompts/portal/quickstart.js"; import { DirectoryPath } from "../../types/file/directoryPath.js"; import { FilePath } from "../../types/file/filePath.js"; import { UrlPath } from "../../types/file/urlPath.js"; -import { PortalScaffoldService } from "../../infrastructure/services/portal-scaffold-service.js"; import { LoginAction } from "../auth/login.js"; import { Result } from "../../types/common/result.js"; import { err, ok, Result as ResultEx } from "neverthrow"; @@ -18,19 +17,28 @@ import { CommandMetadata } from "../../types/common/command-metadata.js"; import { createResourceInput } from "../../types/file/resource-input.js"; import { ValidateAction } from "../api/validate.js"; import { ServiceError } from "../../infrastructure/api-utils.js"; +import { BuildContext } from "../../types/build-context.js"; +import { TempContext } from "../../types/temp-context.js"; +import { FileDownloadService } from "../../infrastructure/services/file-download-service.js"; +import { getLanguageConfig } from "../../types/build/build.js"; const defaultSpecUrl: UrlPath = new UrlPath( "https://raw.githubusercontent.com/apimatic/static-portal-workflow/refs/heads/master/spec/openapi.json" ); const defaultPort: number = 3000 as const; + export class PortalQuickstartAction { private readonly prompts: PortalQuickstartPrompts = new PortalQuickstartPrompts(); private readonly zipService: ZipService = new ZipService(); private readonly fileService: FileService = new FileService(); - private readonly portalScaffoldService: PortalScaffoldService = new PortalScaffoldService(); private readonly configDir: DirectoryPath; private readonly commandMetadata: CommandMetadata; + private readonly fileDownloadService = new FileDownloadService(); + + + private readonly zipUrl = `https://github.com/apimatic/static-portal-workflow/archive/refs/heads/master.zip` as const; + private readonly repositoryFolderName = "static-portal-workflow-master" as const; constructor(configDir: DirectoryPath, commandMetadata: CommandMetadata) { this.configDir = configDir; @@ -41,8 +49,7 @@ export class PortalQuickstartAction { this.prompts.welcomeMessage(); const authenticateUserResult = await this.authenticateUser(); - if (authenticateUserResult.isFailed()) { - this.prompts.loginFailed(); + if (authenticateUserResult.isErr()) { return ActionResult.failed(); } @@ -67,52 +74,61 @@ export class PortalQuickstartAction { } // Step 3/4 - const selectedLanguagesResult = await this.selectLanguages(); - if (selectedLanguagesResult.isErr()) { + const selectedLanguages = await this.selectLanguages(); + if (selectedLanguages.isErr()) { return ActionResult.cancelled(); } // Step 4/4 - const selectInputDirectoryResult = await this.selectInputDirectory(); - if (selectInputDirectoryResult.isErr()) { + const inputDirectory = await this.selectInputDirectory(); + if (!inputDirectory) { return ActionResult.cancelled(); } - const { sourceDirectory, portalDirectory } = selectInputDirectoryResult.value; - const buildDirectoryResult = await this.setupBuildDirectory( - tempDirectory, - sourceDirectory, - validatedSpecDirectory.value!, - selectedLanguagesResult.value + const masterBuildFile = await this.prompts.downloadBuildDirectory( + this.fileDownloadService.downloadFile(new UrlPath(this.zipUrl)) ); - if (buildDirectoryResult.isErr()) { + + if (masterBuildFile.isErr()) { + this.prompts.serviceError(masterBuildFile.error); return ActionResult.failed(); } + const tempContext = new TempContext(tempDirectory); + const masterBuildFilePath = await tempContext.save(masterBuildFile.value); + await this.zipService.unArchive(masterBuildFilePath, tempDirectory); + const extractedFolder = tempDirectory.join(this.repositoryFolderName); + + const tempBuildContext = new BuildContext(extractedFolder); + await tempBuildContext.replaceDefaultSpec(specDirectory.value); + await tempBuildContext.deleteWorkflowDir(); + + const buildFile = await tempBuildContext.getBuildFileContents(); + buildFile.generatePortal!.languageConfig = getLanguageConfig(selectedLanguages); + await tempBuildContext.updateBuildFileContents(buildFile); + const sourceDirectory = inputDirectory.join("src"); + await this.fileService.copyDirectoryContents(extractedFolder, sourceDirectory); + this.prompts.displayBuildDirectoryAsTree(sourceDirectory); + + const portalDirectory = inputDirectory.join("portal"); const servePortalResult = await this.servePortal(sourceDirectory, portalDirectory); if (servePortalResult.isErr()) { return ActionResult.failed(); } - return ActionResult.success(); }); }; - private async authenticateUser(): Promise> { + private async authenticateUser(): Promise> { const storedAuth = await getAuthInfo(this.configDir.toString()); if (storedAuth?.authKey) { - return Result.success("User is already authenticated."); + return ok(); } - - this.prompts.loginRequired(); const loginResult = await new LoginAction(this.configDir, this.commandMetadata).execute(); - - // TODO: fix error messages after refactoring - return loginResult.mapAll( - () => Result.success("Authentication was successful."), - () => Result.failure("Unable to login, please check your credentials and try again later."), - () => Result.failure("Unable to login, please check your credentials and try again later.") - ); + if (loginResult.isFailed()) { + return err(); + } + return ok(); } private async importSpec(tempDirectory: DirectoryPath): Promise> { @@ -178,40 +194,14 @@ export class PortalQuickstartAction { return ok(languages); } - private async selectInputDirectory(): Promise< - ResultEx<{ sourceDirectory: DirectoryPath; portalDirectory: DirectoryPath }, string> - > { + private async selectInputDirectory(): Promise< DirectoryPath | undefined> { this.prompts.selectInputDirectoryStep(); - const inputDirectory = await this.prompts.inputDirectoryPathPrompt(); - if (inputDirectory === null) { + if (inputDirectory) { + return inputDirectory; + } else { this.prompts.noInputDirectoryProvided(); - return err("cancelled"); - } - - return ok({ sourceDirectory: inputDirectory.join("src"), portalDirectory: inputDirectory.join("portal") }); - } - - //TODO: Remove tempDirectory as param. - private async setupBuildDirectory( - tempDirectory: DirectoryPath, - sourceDirectory: DirectoryPath, - specDirectory: DirectoryPath, - selectedLanguages: string[] - ): Promise> { - const result = await this.prompts.createBuildDirectory( - sourceDirectory, - this.portalScaffoldService.createBuildDirectory(tempDirectory, specDirectory, selectedLanguages) - ); - if (result.isErr()) { - this.prompts.buildSetupError(result.error); - return err(result.error); } - - await this.fileService.copyDirectoryContents(result.value, sourceDirectory); - this.prompts.displayBuildDirectoryAsTree(sourceDirectory); - - return ok(result.value); } private async servePortal( diff --git a/src/infrastructure/file-service.ts b/src/infrastructure/file-service.ts index 4bc6734e2..c79a9c542 100644 --- a/src/infrastructure/file-service.ts +++ b/src/infrastructure/file-service.ts @@ -94,7 +94,7 @@ export class FileService { await fsExtra.copyFile(source.toString(), destination.toString()); } - public convertDirectoryStructureToJson( + public getDirectoryStructure( directoryPath: string, descriptions: { [key: string]: string }, parentPath = "" @@ -110,7 +110,7 @@ export class FileService { const stats = fs.statSync(itemPath); if (stats.isDirectory()) { - const subdirectoryStructure = this.convertDirectoryStructureToJson(itemPath, descriptions, relativePath); + const subdirectoryStructure = this.getDirectoryStructure(itemPath, descriptions, relativePath); const folderName = descriptions[path.normalize(relativePath)] ? `${item} : ${descriptions[path.normalize(relativePath)]}` diff --git a/src/infrastructure/services/portal-scaffold-service.ts b/src/infrastructure/services/portal-scaffold-service.ts deleted file mode 100644 index 782f31a0c..000000000 --- a/src/infrastructure/services/portal-scaffold-service.ts +++ /dev/null @@ -1,49 +0,0 @@ -import { FileName } from "../../types/file/fileName.js"; -import { DirectoryPath } from "../../types/file/directoryPath.js"; -import { FileService } from "../file-service.js"; -import { err, ok, Result } from "neverthrow"; -import { ResourceContext } from "../../types/resource-context.js"; -import { BuildContext } from "../../types/build-context.js"; -import { FilePath } from "../../types/file/filePath.js"; -import { UrlPath } from "../../types/file/urlPath.js"; -import { ZipService } from "../zip-service.js"; -import { ServiceError } from "../api-utils.js"; - -export class PortalScaffoldService { - private readonly fileService: FileService = new FileService(); - private readonly zipService: ZipService = new ZipService(); - private readonly zipUrl = `https://github.com/apimatic/static-portal-workflow/archive/refs/heads/master.zip` as const; - private readonly repositoryFolderName = "static-portal-workflow-master" as const; - private readonly defaultSpecFileName = new FileName("openapi.json"); - - public async createBuildDirectory( - tempDirectory: DirectoryPath, - specDirectory: DirectoryPath, - selectedLanguages: string[] - ): Promise> { - const resourceContext = new ResourceContext(tempDirectory); - // TODO: repositoryFolderName should be handled - const result = await resourceContext.resolveTo(new UrlPath(this.zipUrl)); - if (result.isErr()) { - return err(result.error); - } - await this.zipService.unArchive(result.value, tempDirectory); - const extractedFolder = tempDirectory.join(this.repositoryFolderName); - await this.fileService.deleteDirectory(extractedFolder.join(".github")); - - // Setup spec. - const tempSpecDirectory = extractedFolder.join("spec"); - // TODO: Replace this with SpecContext - await this.fileService.deleteFile(new FilePath(tempSpecDirectory, this.defaultSpecFileName)); - await this.fileService.copyDirectory(specDirectory, tempSpecDirectory); - const buildContext = new BuildContext(extractedFolder); - const buildFile = await buildContext.getBuildFileContents(); - buildFile.generatePortal!.languageConfig = selectedLanguages.reduce((config, lang) => { - config[lang] = {}; - return config; - }, {} as { [key: string]: object }); - await buildContext.updateBuildFileContents(buildFile); - - return ok(extractedFolder); - } -} diff --git a/src/prompts/portal/quickstart.ts b/src/prompts/portal/quickstart.ts index 0b3080101..c9445f292 100644 --- a/src/prompts/portal/quickstart.ts +++ b/src/prompts/portal/quickstart.ts @@ -10,7 +10,7 @@ import { format as f, withSpinner } from "../format.js"; import { DirectoryPath } from "../../types/file/directoryPath.js"; import { FileService } from "../../infrastructure/file-service.js"; import { removeQuotes } from "../../utils/string-utils.js"; -import { ServiceError } from "../../infrastructure/api-utils.js"; +import { getErrorMessage, ServiceError } from "../../infrastructure/api-utils.js"; const vscodeExtensionUrl = "https://marketplace.visualstudio.com/items?itemName=apimatic-developers.apimatic-for-vscode"; @@ -34,29 +34,19 @@ export class PortalQuickstartPrompts { private readonly spin = spinner(); public welcomeMessage() { - log.message(`Hello there.`); + log.info(`Hello there.`); log.message( `This wizard will help you set up an API Portal via APIMatic's Docs as Code workflow in 4 simple steps.` ); log.message(`Let's get started!`); } - public loginRequired() { - const message = `You need to be logged in to continue.`; - log.step(message); - } - - public loginFailed() { - const message = `Unable to login, please check your credentials and try again later.`; - log.error(message); - } - public importSpecStep() { const message = `Step 1 of 4: Import your OpenAPI Definition`; log.step(message); } - //TODO: Very complex validation, needs to be improved. + // TODO: Very complex validation, needs to be improved. public async specPathPrompt(defaultSpecUrl: UrlPath): Promise { while (true) { const spec = await text({ @@ -165,7 +155,8 @@ export class PortalQuickstartPrompts { { label: "PHP", value: "php" }, { label: "Go", value: "go" } ], - initialValues: ["typescript", "ruby", "python", "java", "csharp", "php", "go"] + initialValues: ["typescript", "ruby", "python", "java", "csharp", "php", "go"], + required: false, })) as string[]; if (isCancel(languages)) { @@ -184,7 +175,7 @@ export class PortalQuickstartPrompts { log.step(message); } - public async inputDirectoryPathPrompt(): Promise { + public async inputDirectoryPathPrompt(): Promise { const directory = await text({ 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.", @@ -216,37 +207,35 @@ export class PortalQuickstartPrompts { }); if (isCancel(directory)) { - return null; + return undefined; } if (directory === "./") { return new DirectoryPath(defaultPortalDirectoryPath); } else { - // TODO: return domain class return new DirectoryPath(removeQuotes(directory as string).trim()); } } public noInputDirectoryProvided() { - log.error("Operation cancelled. No build directory was provided."); + log.error("No build directory was provided."); } - public createBuildDirectory(sourceDirectory: DirectoryPath, fn: Promise>) { + public downloadBuildDirectory(fn: Promise>) { return withSpinner( - "Generating build directory", - `Directory created at ${f.path(sourceDirectory.toString())}`, - "Something went wrong while setting up your build directory.", + "Downloading Build directory", + `Build directory downloaded successfully`, + "Unable to download Build directory", fn ); } - // TODO: remove this message param - public buildSetupError(message: string) { - log.error(message); + public buildSetupError(serviceError: ServiceError) { + log.error(getErrorMessage(serviceError)); } public displayBuildDirectoryAsTree(buildDirectory: DirectoryPath): void { - const structuredBuildDirectory = this.fileService.convertDirectoryStructureToJson( + const structuredBuildDirectory = this.fileService.getDirectoryStructure( buildDirectory.toString(), descriptions ) as treeify.TreeObject; @@ -268,4 +257,8 @@ ${f.link(referenceDocumentationUrl)}`; note(message, "Next steps"); } + + serviceError(error: ServiceError) { + log.error(getErrorMessage(error)); + } } diff --git a/src/types/build-context.ts b/src/types/build-context.ts index 0a79d266f..bf09396cb 100644 --- a/src/types/build-context.ts +++ b/src/types/build-context.ts @@ -12,16 +12,20 @@ export class BuildContext { this.buildDirectory = buildDirectory; } - private get BuildFile(): FilePath { + private get buildFile(): FilePath { // TODO: add checks for build file path return new FilePath(this.buildDirectory, new FileName("APIMATIC-BUILD.json")); } + private get specDirectory(): DirectoryPath { + return this.buildDirectory.join("spec") + } + public async validate(): Promise { // TODO: add more checks here if (!(await this.fileService.directoryExists(this.buildDirectory))) return false; - return await this.fileService.fileExists(this.BuildFile); + return await this.fileService.fileExists(this.buildFile); } public async exists(): Promise { @@ -29,12 +33,21 @@ export class BuildContext { } public async getBuildFileContents(): Promise { - const buildFileContent = await this.fileService.getContents(this.BuildFile); + const buildFileContent = await this.fileService.getContents(this.buildFile); return JSON.parse(buildFileContent) as BuildConfig; } public async updateBuildFileContents(buildJson: BuildConfig) { - await this.fileService.writeContents(this.BuildFile, JSON.stringify(buildJson, null, 2)); + await this.fileService.writeContents(this.buildFile, JSON.stringify(buildJson, null, 2)); + } + + public async replaceDefaultSpec(newSpecDirectory: DirectoryPath) { + await this.fileService.deleteFile(new FilePath(this.specDirectory, new FileName('openapi.json'))); + await this.fileService.copyDirectory(newSpecDirectory, this.specDirectory); + } + + public async deleteWorkflowDir() { + await this.fileService.deleteDirectory(this.buildDirectory.join(".github")); } } diff --git a/src/types/build/build.ts b/src/types/build/build.ts index 8edad27f8..aa6cee7d0 100644 --- a/src/types/build/build.ts +++ b/src/types/build/build.ts @@ -15,3 +15,10 @@ export interface CopilotConfig { key: string; welcomeMessage: string; } + +export function getLanguageConfig(selectedLanguages: { value: string[] }) { + return selectedLanguages.value.reduce((config, lang) => { + config[lang] = {}; + return config; + }, {} as { [key: string]: object }); +} diff --git a/src/types/file/directoryNode.ts b/src/types/file/directoryNode.ts index 2dd6df3e4..3d50da43e 100644 --- a/src/types/file/directoryNode.ts +++ b/src/types/file/directoryNode.ts @@ -1,3 +1,3 @@ export interface DirectoryNode { [key: string]: DirectoryNode | string | null | undefined; -} \ No newline at end of file +} diff --git a/src/utils/string-utils.ts b/src/utils/string-utils.ts index 5a067272e..65ea078ee 100644 --- a/src/utils/string-utils.ts +++ b/src/utils/string-utils.ts @@ -1,10 +1,10 @@ -export const removeQuotes = (path: string): string => { +export const removeQuotes = (input: string): string => { const quotes = ['"', "'"]; for (const quote of quotes) { - if (path.startsWith(quote) && path.endsWith(quote) && path.length > 1) { - return removeQuotes(path.slice(1, -1)); // Recursive call + if (input.startsWith(quote) && input.endsWith(quote) && input.length > 1) { + return removeQuotes(input.slice(1, -1)); // Recursive call } } - return path; + return input; }; From db9aa6322de7f5d704e73a7c73949faaa99733e0 Mon Sep 17 00:00:00 2001 From: Ali Asghar <75574550+aliasghar98@users.noreply.github.com> Date: Mon, 1 Sep 2025 18:38:55 +0500 Subject: [PATCH 43/77] fix: improved code and resolved blocking issue in quickstart --- src/actions/portal/quickstart.ts | 10 ++++---- src/actions/portal/serve.ts | 9 ++----- src/infrastructure/file-service.ts | 20 +++++---------- src/prompts/portal/quickstart.ts | 39 ++++++++++++++++++------------ src/prompts/portal/serve.ts | 4 +-- 5 files changed, 39 insertions(+), 43 deletions(-) diff --git a/src/actions/portal/quickstart.ts b/src/actions/portal/quickstart.ts index e3eca6bbf..0c68ed273 100644 --- a/src/actions/portal/quickstart.ts +++ b/src/actions/portal/quickstart.ts @@ -134,7 +134,7 @@ export class PortalQuickstartAction { private async importSpec(tempDirectory: DirectoryPath): Promise> { this.prompts.importSpecStep(); const inputPath = await this.prompts.specPathPrompt(defaultSpecUrl); - if (inputPath === null) { + if (!inputPath) { return err("Operation cancelled. No API Definition was provided."); } @@ -182,13 +182,13 @@ export class PortalQuickstartAction { return Result.success(specDirectory); } - private async selectLanguages(): Promise> { + private async selectLanguages(): Promise> { this.prompts.selectLanguagesStep(); const languages = await this.prompts.selectLanguagesPrompt(); - if (languages === null) { + if (!languages) { this.prompts.noLanguagesSelected(); - return err("cancelled"); + return err(); } return ok(languages); @@ -215,7 +215,7 @@ export class PortalQuickstartAction { defaultPort, true, false, - async () => { + () => { this.prompts.nextSteps(); } ); diff --git a/src/actions/portal/serve.ts b/src/actions/portal/serve.ts index 425dd28cf..44207c40a 100644 --- a/src/actions/portal/serve.ts +++ b/src/actions/portal/serve.ts @@ -43,7 +43,7 @@ export class PortalServeAction { port: number, openInBrowser: boolean, hotReload: boolean, - onInterrupt?: () => Promise + onInterrupt?: () => void ): Promise { const generatePortalAction = new GenerateAction(this.configDir, this.commandMetadata, this.authKey); @@ -69,12 +69,7 @@ export class PortalServeAction { this.clearStandardInput(); if (onInterrupt) { - await onInterrupt(); - process.once("SIGINT", async () => { - liveReloadServer.close(); - server.close(); - }); - return ActionResult.success(); + onInterrupt(); } await this.prompts.blockExecution(); diff --git a/src/infrastructure/file-service.ts b/src/infrastructure/file-service.ts index c79a9c542..901eb2012 100644 --- a/src/infrastructure/file-service.ts +++ b/src/infrastructure/file-service.ts @@ -95,32 +95,24 @@ export class FileService { } public getDirectoryStructure( - directoryPath: string, - descriptions: { [key: string]: string }, + directoryPath: DirectoryPath, parentPath = "" ): { [key: string]: DirectoryNode | string | null | undefined } { const directoryStructure: { [key: string]: DirectoryNode | string | null | undefined } = {}; - const items = fs.readdirSync(directoryPath); + const items = fs.readdirSync(directoryPath.toString()); items.forEach((item) => { if (item === ".git") return; // Skip .git directory - const itemPath = path.join(directoryPath, item); + const itemPath = path.join(directoryPath.toString(), item); const relativePath = path.join(parentPath, item); const stats = fs.statSync(itemPath); if (stats.isDirectory()) { - const subdirectoryStructure = this.getDirectoryStructure(itemPath, descriptions, relativePath); - - const folderName = descriptions[path.normalize(relativePath)] - ? `${item} : ${descriptions[path.normalize(relativePath)]}` - : item; - - directoryStructure[folderName] = subdirectoryStructure; + const subdirectoryStructure = this.getDirectoryStructure(new DirectoryPath(itemPath), relativePath); + directoryStructure[item] = subdirectoryStructure; } else { - directoryStructure[ - descriptions[path.normalize(relativePath)] ? `${item} : ${descriptions[path.normalize(relativePath)]}` : item - ] = null; + directoryStructure[item] = null; } }); diff --git a/src/prompts/portal/quickstart.ts b/src/prompts/portal/quickstart.ts index c9445f292..14f5910d9 100644 --- a/src/prompts/portal/quickstart.ts +++ b/src/prompts/portal/quickstart.ts @@ -47,7 +47,7 @@ export class PortalQuickstartPrompts { } // TODO: Very complex validation, needs to be improved. - public async specPathPrompt(defaultSpecUrl: UrlPath): Promise { + public async specPathPrompt(defaultSpecUrl: UrlPath): Promise { while (true) { const spec = await text({ message: `Provide a local path or a public URL for your OpenAPI definition file:`, @@ -75,7 +75,7 @@ export class PortalQuickstartPrompts { }); if (isCancel(spec)) { - return null; + return undefined; } const cleanedPath = removeQuotes(String(spec).trim()); @@ -142,7 +142,7 @@ export class PortalQuickstartPrompts { log.step(message); } - public async selectLanguagesPrompt(): Promise { + public async selectLanguagesPrompt(): Promise { const languages = (await multiselect({ message: "Your API Portal will contain SDKs and SDK Documentation in the following Languages. Press enter to continue with all languages, or use the arrow keys and spacebar to customize your selection:", @@ -155,12 +155,11 @@ export class PortalQuickstartPrompts { { label: "PHP", value: "php" }, { label: "Go", value: "go" } ], - initialValues: ["typescript", "ruby", "python", "java", "csharp", "php", "go"], - required: false, + initialValues: ["typescript", "ruby", "python", "java", "csharp", "php", "go"] })) as string[]; if (isCancel(languages)) { - return null; + return undefined; } return ["http", ...languages]; @@ -235,18 +234,13 @@ export class PortalQuickstartPrompts { } public displayBuildDirectoryAsTree(buildDirectory: DirectoryPath): void { - const structuredBuildDirectory = this.fileService.getDirectoryStructure( - buildDirectory.toString(), - descriptions - ) as treeify.TreeObject; - - const tree = treeify.asTree(structuredBuildDirectory, true, true); - + const buildDirectoryStructure = this.fileService.getDirectoryStructure(buildDirectory) as treeify.TreeObject; + const buildDirectoryTreeStructure = this.createTreeStructure(buildDirectoryStructure); + const tree = treeify.asTree(buildDirectoryTreeStructure, true, true); const coloredLogString = tree .split("\n") .map((line) => line.replace(/#.*/, (match) => getMessageInGreenColor(match))) .join("\n"); - log.step(coloredLogString); } @@ -258,7 +252,22 @@ ${f.link(referenceDocumentationUrl)}`; note(message, "Next steps"); } - serviceError(error: ServiceError) { + public serviceError(error: ServiceError) { log.error(getErrorMessage(error)); } + + private createTreeStructure(directoryTreeStructure: treeify.TreeObject, parentPath = ""): treeify.TreeObject { + const result: treeify.TreeObject = {}; + for (const key in directoryTreeStructure) { + const relPath = path.join(parentPath, key); + const desc = descriptions[path.normalize(relPath)]; + const displayKey = desc ? `${key} : ${desc}` : key; + if (directoryTreeStructure[key] && typeof directoryTreeStructure[key] === "object") { + result[displayKey] = this.createTreeStructure(directoryTreeStructure[key] as treeify.TreeObject, relPath); + } else { + result[displayKey] = ""; + } + } + return result; + } } diff --git a/src/prompts/portal/serve.ts b/src/prompts/portal/serve.ts index 5d9e21568..e9017941a 100644 --- a/src/prompts/portal/serve.ts +++ b/src/prompts/portal/serve.ts @@ -20,7 +20,7 @@ export class PortalServePrompts { public promptForExit() { const message = "Press CTRL+C to exit."; - log.info(message); + log.message(message); } public changesDetected() { @@ -36,6 +36,6 @@ export class PortalServePrompts { } public async blockExecution() { - if (this.displayMessages) await Promise.race([once(process, "SIGINT"), once(process, "SIGTERM")]); + await Promise.race([once(process, "SIGINT"), once(process, "SIGTERM")]); } } From 0bef14e3f8ec8fb621c3c365f8464892ccf90529 Mon Sep 17 00:00:00 2001 From: Ali Asghar <75574550+aliasghar98@users.noreply.github.com> Date: Tue, 2 Sep 2025 10:37:10 +0500 Subject: [PATCH 44/77] fix: fixed minor issues and compelted refactoring --- src/actions/api/validate.ts | 6 +- src/actions/portal/quickstart.ts | 34 +++----- src/actions/portal/serve.ts | 9 ++- src/infrastructure/entities/file-watcher.ts | 89 --------------------- src/infrastructure/entities/live-server.ts | 52 ------------ src/infrastructure/file-service.ts | 7 +- src/infrastructure/network-service.ts | 17 ---- src/prompts/portal/quickstart.ts | 39 +++------ src/types/build/build.ts | 2 +- src/utils/utils.ts | 18 +---- 10 files changed, 39 insertions(+), 234 deletions(-) delete mode 100644 src/infrastructure/entities/file-watcher.ts delete mode 100644 src/infrastructure/entities/live-server.ts diff --git a/src/actions/api/validate.ts b/src/actions/api/validate.ts index e4b7069be..4af0ee70f 100644 --- a/src/actions/api/validate.ts +++ b/src/actions/api/validate.ts @@ -46,11 +46,11 @@ export class ValidateAction { if (displayValidationSummary) { this.prompts.displayValidationMessages(validationSummary); } - if (validationSummary?.success) { - return ActionResult.success(); + if (!validationSummary.success) { + return ActionResult.failed(); } - return ActionResult.failed(); + return ActionResult.success(); }); }; } diff --git a/src/actions/portal/quickstart.ts b/src/actions/portal/quickstart.ts index 0c68ed273..75271edf2 100644 --- a/src/actions/portal/quickstart.ts +++ b/src/actions/portal/quickstart.ts @@ -20,14 +20,13 @@ import { ServiceError } from "../../infrastructure/api-utils.js"; import { BuildContext } from "../../types/build-context.js"; import { TempContext } from "../../types/temp-context.js"; import { FileDownloadService } from "../../infrastructure/services/file-download-service.js"; -import { getLanguageConfig } from "../../types/build/build.js"; +import { getLanguagesConfig } from "../../types/build/build.js"; const defaultSpecUrl: UrlPath = new UrlPath( "https://raw.githubusercontent.com/apimatic/static-portal-workflow/refs/heads/master/spec/openapi.json" ); const defaultPort: number = 3000 as const; - export class PortalQuickstartAction { private readonly prompts: PortalQuickstartPrompts = new PortalQuickstartPrompts(); private readonly zipService: ZipService = new ZipService(); @@ -35,8 +34,6 @@ export class PortalQuickstartAction { private readonly configDir: DirectoryPath; private readonly commandMetadata: CommandMetadata; private readonly fileDownloadService = new FileDownloadService(); - - private readonly zipUrl = `https://github.com/apimatic/static-portal-workflow/archive/refs/heads/master.zip` as const; private readonly repositoryFolderName = "static-portal-workflow-master" as const; @@ -57,15 +54,12 @@ export class PortalQuickstartAction { // Step 1/4 const specDirectory = await this.importSpec(tempDirectory); if (specDirectory.isErr()) { - this.prompts.specImportError(specDirectory.error); - if (specDirectory.error === "cancelled") return ActionResult.cancelled(); return ActionResult.failed(); } // Step 2/4 const validatedSpecDirectory = await this.validateSpec(tempDirectory, specDirectory.value); if (validatedSpecDirectory.isFailed()) { - this.prompts.specValidationError(validatedSpecDirectory.error!); return ActionResult.failed(); } if (validatedSpecDirectory.isCancelled()) { @@ -103,7 +97,7 @@ export class PortalQuickstartAction { await tempBuildContext.deleteWorkflowDir(); const buildFile = await tempBuildContext.getBuildFileContents(); - buildFile.generatePortal!.languageConfig = getLanguageConfig(selectedLanguages); + buildFile.generatePortal!.languageConfig = getLanguagesConfig(selectedLanguages); await tempBuildContext.updateBuildFileContents(buildFile); const sourceDirectory = inputDirectory.join("src"); @@ -131,11 +125,12 @@ export class PortalQuickstartAction { return ok(); } - private async importSpec(tempDirectory: DirectoryPath): Promise> { + private async importSpec(tempDirectory: DirectoryPath): Promise> { this.prompts.importSpecStep(); const inputPath = await this.prompts.specPathPrompt(defaultSpecUrl); if (!inputPath) { - return err("Operation cancelled. No API Definition was provided."); + this.prompts.noSpecSpecified(); + return err(); } const urlPath = UrlPath.create(inputPath); @@ -145,7 +140,8 @@ export class PortalQuickstartAction { ? await resourceContext.resolveTo(createResourceInput(inputPath)) : await resourceContext.resolveTo(urlPath); if (result.isErr()) { - return err(result.error); + this.prompts.serviceError(result.error); + return err(); } const specDirectory = tempDirectory.join("spec"); @@ -173,6 +169,7 @@ export class PortalQuickstartAction { const resourceContext = new ResourceContext(tempDirectory); const result = await resourceContext.resolveTo(defaultSpecUrl); if (result.isErr()) { + this.prompts.serviceError(result.error); return Result.failure(result.error); } await this.fileService.cleanDirectory(specDirectory); @@ -194,7 +191,7 @@ export class PortalQuickstartAction { return ok(languages); } - private async selectInputDirectory(): Promise< DirectoryPath | undefined> { + private async selectInputDirectory(): Promise { this.prompts.selectInputDirectoryStep(); const inputDirectory = await this.prompts.inputDirectoryPathPrompt(); if (inputDirectory) { @@ -209,16 +206,9 @@ export class PortalQuickstartAction { portalDirectory: DirectoryPath ): Promise> { const portalServeAction = new PortalServeAction(this.configDir, this.commandMetadata, null, false); - const result = await portalServeAction.execute( - buildDirectory, - portalDirectory, - defaultPort, - true, - false, - () => { - this.prompts.nextSteps(); - } - ); + const result = await portalServeAction.execute(buildDirectory, portalDirectory, defaultPort, true, false, () => { + this.prompts.nextSteps(); + }); // TODO: Figure out a better way for this. return result.mapAll>( diff --git a/src/actions/portal/serve.ts b/src/actions/portal/serve.ts index 44207c40a..a3b71e701 100644 --- a/src/actions/portal/serve.ts +++ b/src/actions/portal/serve.ts @@ -66,12 +66,12 @@ export class PortalServeAction { await this.launcherService.openUrlInBrowser(portalUrl); } this.prompts.promptForExit(); - this.clearStandardInput(); if (onInterrupt) { onInterrupt(); } + this.clearStandardInput(); await this.prompts.blockExecution(); liveReloadServer.close(); server.close(); @@ -87,6 +87,7 @@ export class PortalServeAction { }); const deletedDirectories = new Set(); + // TODO: Verify if we need mutex and eventQueue after refactoring. const eventQueue = new Map(); const mutex = new Mutex(); @@ -116,8 +117,12 @@ export class PortalServeAction { this.prompts.changesDetected(); } + // TODO: Verify if this is needed. + if (!eventQueue.has(eventId)) { + return; + } + await generatePortalAction.execute(buildDirectory, portalDirectory, true, false); - // toDO: check for success const portalUrl = new UrlPath(`http://localhost:${servePort}`); if (!this.isPortalServed) { diff --git a/src/infrastructure/entities/file-watcher.ts b/src/infrastructure/entities/file-watcher.ts deleted file mode 100644 index 38bf73c3e..000000000 --- a/src/infrastructure/entities/file-watcher.ts +++ /dev/null @@ -1,89 +0,0 @@ -import chokidar from "chokidar"; -import crypto from "crypto"; -import { Mutex } from "async-mutex"; -import { DirectoryPath } from "../../types/file/directoryPath.js"; - -interface FileWatcherOptions { - ignored?: RegExp[]; - ignoreInitial?: boolean; - persistent?: boolean; - awaitWriteFinish?: boolean; - atomic?: boolean; -} - -interface FileChangeEvent { - event: string; - path: string; - eventId: string; -} - -type FileChangeHandler = (event: FileChangeEvent) => Promise; -type ErrorHandler = () => Promise; - -export class FileWatcher { - private readonly watcher: chokidar.FSWatcher; - private readonly deletedDirectories = new Set(); - private readonly eventQueue = new Map(); - private readonly mutex = new Mutex(); - - constructor(options: FileWatcherOptions = {}) { - this.watcher = new chokidar.FSWatcher({ - ...options - }); - } - - public watch(directory: DirectoryPath): void { - this.watcher.add(directory.toString()); - } - - public async stopWatching(): Promise { - this.watcher.close(); - this.deletedDirectories.clear(); - this.eventQueue.clear(); - } - - public onFileChange(handler: FileChangeHandler): void { - if (!this.watcher) { - return; - } - - this.watcher.on("all", async (event: string, path: string) => { - // Handle folder deletion events - if (event === "unlinkDir") { - this.deletedDirectories.add(path); - } - - // Skip file deletion events for deleted directories - if (event === "unlink") { - for (const dir of this.deletedDirectories) { - if (path.startsWith(dir)) { - return; - } - } - } - - const eventId = `${Date.now()}-${crypto.randomUUID()}`; - const fileChangeEvent: FileChangeEvent = { event, path, eventId }; - - // Use mutex to handle concurrent events - await this.mutex.runExclusive(async () => { - this.eventQueue.clear(); - this.eventQueue.set(eventId, path); - }); - - await handler(fileChangeEvent); - }); - } - - public onError(handler: ErrorHandler): void { - if (!this.watcher) { - return; - } - - this.watcher.on("error", handler); - } - - public isWatching(): boolean { - return this.watcher !== null; - } -} diff --git a/src/infrastructure/entities/live-server.ts b/src/infrastructure/entities/live-server.ts deleted file mode 100644 index 61367edc2..000000000 --- a/src/infrastructure/entities/live-server.ts +++ /dev/null @@ -1,52 +0,0 @@ -import express, { Express } from "express"; -import { createServer as createLiveReloadServer, LiveReloadServer } from "livereload"; -import connectLiveReload from "connect-livereload"; -import { Server } from "http"; -import { DirectoryPath } from "../../types/file/directoryPath.js"; -import { UrlPath } from "../../types/file/urlPath.js"; -import { LauncherService } from "../launcher-service.js"; - -export class LiveServer { - private readonly launcherService: LauncherService = new LauncherService(); - private readonly application: Express = express(); - private liveReloadServer: LiveReloadServer | null = null; - private server: Server | null = null; - - constructor() { - this.application = express(); - } - - public start(directory: DirectoryPath, port: number, openInBrowser: boolean, hotReloadEnabled: boolean): UrlPath { - if (hotReloadEnabled) { - this.liveReloadServer = createLiveReloadServer(); - this.application.use(connectLiveReload()); - } - this.application.use(express.static(directory.toString(), { extensions: ["html"] })); - this.server = this.application.listen(port); - - const portalUrl = new UrlPath(`http://localhost:${port}`); - - if (openInBrowser) { - this.launcherService.openUrlInBrowser(portalUrl); - } - - return portalUrl; - } - - public stop(): void { - if (this.server) { - this.server.close(); - this.server = null; - } - if (this.liveReloadServer) { - this.liveReloadServer.close(); - this.liveReloadServer = null; - } - } - - public refresh(directory: DirectoryPath) { - if (this.liveReloadServer) { - this.liveReloadServer.refresh(directory.toString()); - } - } -} diff --git a/src/infrastructure/file-service.ts b/src/infrastructure/file-service.ts index 901eb2012..d7862b53e 100644 --- a/src/infrastructure/file-service.ts +++ b/src/infrastructure/file-service.ts @@ -94,11 +94,8 @@ export class FileService { await fsExtra.copyFile(source.toString(), destination.toString()); } - public getDirectoryStructure( - directoryPath: DirectoryPath, - parentPath = "" - ): { [key: string]: DirectoryNode | string | null | undefined } { - const directoryStructure: { [key: string]: DirectoryNode | string | null | undefined } = {}; + public getDirectoryStructure(directoryPath: DirectoryPath, parentPath = ""): DirectoryNode { + const directoryStructure: DirectoryNode = {}; const items = fs.readdirSync(directoryPath.toString()); items.forEach((item) => { diff --git a/src/infrastructure/network-service.ts b/src/infrastructure/network-service.ts index 07fb8a0ba..2f0050d33 100644 --- a/src/infrastructure/network-service.ts +++ b/src/infrastructure/network-service.ts @@ -1,23 +1,6 @@ import getPort from "get-port"; -import net from "net"; export class NetworkService { - - public async isPortAvailable(port: number): Promise { - return new Promise((resolve) => { - const server = net - .createServer() - .once("error", () => { - resolve(false); // Port is in use - }) - .once("listening", () => { - server.close(); - resolve(true); // Port is available - }) - .listen(port); - }); - } - public async getServerPort(preferredPorts: number[]): Promise { return await getPort({ port: preferredPorts }); } diff --git a/src/prompts/portal/quickstart.ts b/src/prompts/portal/quickstart.ts index 14f5910d9..7e6221adf 100644 --- a/src/prompts/portal/quickstart.ts +++ b/src/prompts/portal/quickstart.ts @@ -3,8 +3,8 @@ import fs from "fs"; import axios from "axios"; import treeify from "treeify"; import { Result } from "neverthrow"; -import { text, select, multiselect, log, isCancel, spinner, note } from "@clack/prompts"; -import { getMessageInGreenColor, getMessageInRedColor } from "../../utils/utils.js"; +import { text, select, multiselect, log, isCancel, note } from "@clack/prompts"; +import { getMessageInGreenColor } from "../../utils/utils.js"; import { UrlPath } from "../../types/file/urlPath.js"; import { format as f, withSpinner } from "../format.js"; import { DirectoryPath } from "../../types/file/directoryPath.js"; @@ -30,8 +30,6 @@ const descriptions: { [key: string]: string } = Object.entries({ export class PortalQuickstartPrompts { private readonly fileService = new FileService(); - // TODO: Remove after refactoring validate action. - private readonly spin = spinner(); public welcomeMessage() { log.info(`Hello there.`); @@ -100,12 +98,8 @@ export class PortalQuickstartPrompts { } } - public specImportError(error: string) { - log.error(error); - } - - public specValidationError(error: string) { - log.error(error); + public noSpecSpecified() { + log.error("No API definition was provided."); } public async useDefaultSpecPrompt(): Promise { @@ -166,7 +160,7 @@ export class PortalQuickstartPrompts { } public noLanguagesSelected() { - log.error("Operation cancelled. No programming languages were selected."); + log.error("No programming languages were selected."); } public selectInputDirectoryStep() { @@ -183,23 +177,19 @@ export class PortalQuickstartPrompts { const cleanedPath = removeQuotes(input?.trim() ?? ""); if (!fs.existsSync(cleanedPath.toString()) && cleanedPath.toString() != defaultPortalDirectoryPath) { - return getMessageInRedColor("Error: The specified directory path does not exist. Please try again."); + return "Error: The specified directory path does not exist. Please try again."; } if (cleanedPath.toString() !== defaultPortalDirectoryPath) { const files = fs.readdirSync(cleanedPath.toString()).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." - ); + return "Error: The target directory is not empty. Please provide a path to an empty directory or clear its contents."; } } else if (fs.existsSync(cleanedPath.toString())) { // For ignoring hidden files and folders in the current directory in MacOS. const files = fs.readdirSync(cleanedPath.toString()).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." - ); + return "Error: The target directory is not empty. Please provide a path to an empty directory or clear its contents."; } } } @@ -222,17 +212,13 @@ export class PortalQuickstartPrompts { public downloadBuildDirectory(fn: Promise>) { return withSpinner( - "Downloading Build directory", - `Build directory downloaded successfully`, - "Unable to download Build directory", + "Downloading build directory", + `Build directory downloaded successfully.`, + "Unable to download build directory.", fn ); } - public buildSetupError(serviceError: ServiceError) { - log.error(getErrorMessage(serviceError)); - } - public displayBuildDirectoryAsTree(buildDirectory: DirectoryPath): void { const buildDirectoryStructure = this.fileService.getDirectoryStructure(buildDirectory) as treeify.TreeObject; const buildDirectoryTreeStructure = this.createTreeStructure(buildDirectoryStructure); @@ -262,10 +248,11 @@ ${f.link(referenceDocumentationUrl)}`; const relPath = path.join(parentPath, key); const desc = descriptions[path.normalize(relPath)]; const displayKey = desc ? `${key} : ${desc}` : key; + if (directoryTreeStructure[key] && typeof directoryTreeStructure[key] === "object") { result[displayKey] = this.createTreeStructure(directoryTreeStructure[key] as treeify.TreeObject, relPath); } else { - result[displayKey] = ""; + result[displayKey] = null as unknown as string; // keep null, not "" } } return result; diff --git a/src/types/build/build.ts b/src/types/build/build.ts index aa6cee7d0..6ada191ef 100644 --- a/src/types/build/build.ts +++ b/src/types/build/build.ts @@ -16,7 +16,7 @@ export interface CopilotConfig { welcomeMessage: string; } -export function getLanguageConfig(selectedLanguages: { value: string[] }) { +export function getLanguagesConfig(selectedLanguages: { value: string[] }) { return selectedLanguages.value.reduce((config, lang) => { config[lang] = {}; return config; diff --git a/src/utils/utils.ts b/src/utils/utils.ts index c87d6048d..6df496d4a 100644 --- a/src/utils/utils.ts +++ b/src/utils/utils.ts @@ -11,7 +11,7 @@ export const getFileNameFromPath = (filePath: string) => { return path.basename(filePath).split(".")[0]; }; -export async function parseStreamBodyToJson(body: NodeJS.ReadableStream): Promise { +export async function parseStreamBodyToJson(body: NodeJS.ReadableStream): Promise { const chunks: Buffer[] = []; for await (const chunk of body) { chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); @@ -20,26 +20,10 @@ export async function parseStreamBodyToJson(body: NodeJS.ReadableStream): Promis return JSON.parse(text); } -export const getMessageInOrangeColor = (message: string) => { - return colors.yellow(message); -}; - -export const getMessageInBlueColor = (message: string) => { - return colors.blueBright(message); -}; - -export const getMessageInCyanColor = (message: string) => { - return colors.cyan(message); -}; - export const getMessageInGreenColor = (message: string) => { return colors.greenBright(message); }; -export const getMessageInMagentaColor = (message: string) => { - return colors.magentaBright(message); -}; - export const getMessageInRedColor = (message: string) => { return colors.redBright(message); }; From 3dc044eb9372d6c19108b86d6e48b666662099b8 Mon Sep 17 00:00:00 2001 From: Ali Asghar <75574550+aliasghar98@users.noreply.github.com> Date: Tue, 2 Sep 2025 10:51:24 +0500 Subject: [PATCH 45/77] refactor: improved callback function name in serve --- src/actions/portal/serve.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/actions/portal/serve.ts b/src/actions/portal/serve.ts index a3b71e701..7c8cecd6d 100644 --- a/src/actions/portal/serve.ts +++ b/src/actions/portal/serve.ts @@ -43,7 +43,7 @@ export class PortalServeAction { port: number, openInBrowser: boolean, hotReload: boolean, - onInterrupt?: () => void + onAfterServe?: () => void ): Promise { const generatePortalAction = new GenerateAction(this.configDir, this.commandMetadata, this.authKey); @@ -67,8 +67,8 @@ export class PortalServeAction { } this.prompts.promptForExit(); - if (onInterrupt) { - onInterrupt(); + if (onAfterServe) { + onAfterServe(); } this.clearStandardInput(); From 05b304a92bf940397c6e108da742cddb932ec05d Mon Sep 17 00:00:00 2001 From: Ali Asghar <75574550+aliasghar98@users.noreply.github.com> Date: Tue, 2 Sep 2025 11:12:38 +0500 Subject: [PATCH 46/77] refactor: removed extra indentation --- src/actions/portal/serve.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/actions/portal/serve.ts b/src/actions/portal/serve.ts index 7c8cecd6d..0ff0c934f 100644 --- a/src/actions/portal/serve.ts +++ b/src/actions/portal/serve.ts @@ -22,7 +22,6 @@ export class PortalServeAction { private readonly configDir: DirectoryPath; private readonly commandMetadata: CommandMetadata; private readonly authKey: string | null; - private isPortalServed: boolean = false; public constructor( From 982bd161f2ce9b2111a5358d6551d03582d28f6a Mon Sep 17 00:00:00 2001 From: Shield-Jaguar Date: Tue, 2 Sep 2025 11:43:59 +0500 Subject: [PATCH 47/77] refactor portal serve --- src/actions/portal/quickstart.ts | 2 +- src/actions/portal/serve.ts | 13 ++++--------- src/prompts/portal/serve.ts | 3 --- 3 files changed, 5 insertions(+), 13 deletions(-) diff --git a/src/actions/portal/quickstart.ts b/src/actions/portal/quickstart.ts index 75271edf2..6ca1c67c9 100644 --- a/src/actions/portal/quickstart.ts +++ b/src/actions/portal/quickstart.ts @@ -205,7 +205,7 @@ export class PortalQuickstartAction { buildDirectory: DirectoryPath, portalDirectory: DirectoryPath ): Promise> { - const portalServeAction = new PortalServeAction(this.configDir, this.commandMetadata, null, false); + const portalServeAction = new PortalServeAction(this.configDir, this.commandMetadata, null); const result = await portalServeAction.execute(buildDirectory, portalDirectory, defaultPort, true, false, () => { this.prompts.nextSteps(); }); diff --git a/src/actions/portal/serve.ts b/src/actions/portal/serve.ts index 0ff0c934f..2de361323 100644 --- a/src/actions/portal/serve.ts +++ b/src/actions/portal/serve.ts @@ -15,7 +15,7 @@ import { LauncherService } from "../../infrastructure/launcher-service.js"; import { DebounceService } from "../../infrastructure/debounce-service.js"; export class PortalServeAction { - private readonly prompts: PortalServePrompts; + private readonly prompts: PortalServePrompts = new PortalServePrompts(); private readonly networkService: NetworkService = new NetworkService(); private readonly launcherService: LauncherService = new LauncherService(); private readonly application: Express = express(); @@ -24,16 +24,10 @@ export class PortalServeAction { private readonly authKey: string | null; private isPortalServed: boolean = false; - public constructor( - configDir: DirectoryPath, - commandMetadata: CommandMetadata, - authKey: string | null = null, - displayMessages: boolean = true - ) { + public constructor(configDir: DirectoryPath, commandMetadata: CommandMetadata, authKey: string | null = null) { this.configDir = configDir; this.commandMetadata = commandMetadata; this.authKey = authKey; - this.prompts = new PortalServePrompts(displayMessages); } public async execute( @@ -146,9 +140,10 @@ export class PortalServeAction { await this.prompts.blockExecution(); await watcher.close(); + debounceService.close(); + liveReloadServer.close(); server.close(); - debounceService.close(); return ActionResult.success(); } diff --git a/src/prompts/portal/serve.ts b/src/prompts/portal/serve.ts index e9017941a..e7aad9f2e 100644 --- a/src/prompts/portal/serve.ts +++ b/src/prompts/portal/serve.ts @@ -4,8 +4,6 @@ import { UrlPath } from "../../types/file/urlPath.js"; import { once } from "events"; export class PortalServePrompts { - constructor(private readonly displayMessages: boolean) {} - public usingFallbackPort(currentPort: number, availablePort: number) { const message = `Port ${f.var(currentPort.toString())} is already in use. Available port ${f.var( availablePort.toString() @@ -31,7 +29,6 @@ export class PortalServePrompts { public watcherError() { const message = "An unexpected error occurred while watching your build folder for changes. Please try again later. If the issue persists, contact our team at support@apimatic.io"; - log.error(message); } From 6dad26865939e825df9ce224372146104b21e40f Mon Sep 17 00:00:00 2001 From: Ali Asghar <75574550+aliasghar98@users.noreply.github.com> Date: Tue, 2 Sep 2025 12:41:40 +0500 Subject: [PATCH 48/77] refactor: removed directory node and used directory instead --- src/infrastructure/file-service.ts | 41 ++++++++++++------------------ src/prompts/portal/quickstart.ts | 28 ++++++++++++-------- src/types/file/directory.ts | 14 ++++++++++ src/types/file/directoryNode.ts | 3 --- 4 files changed, 48 insertions(+), 38 deletions(-) create mode 100644 src/types/file/directory.ts delete mode 100644 src/types/file/directoryNode.ts diff --git a/src/infrastructure/file-service.ts b/src/infrastructure/file-service.ts index d7862b53e..1d4170ac8 100644 --- a/src/infrastructure/file-service.ts +++ b/src/infrastructure/file-service.ts @@ -1,11 +1,12 @@ import fs from "fs"; import fsExtra from "fs-extra"; import * as path from "path"; -import { FilePath } from "../types/file/filePath.js"; -import { DirectoryPath } from "../types/file/directoryPath.js"; import { pipeline } from "stream"; import { promisify } from "util"; -import { DirectoryNode } from "../types/file/directoryNode.js"; +import { FilePath } from "../types/file/filePath.js"; +import { DirectoryPath } from "../types/file/directoryPath.js"; +import { Directory } from "../types/file/directory.js"; +import { FileName } from "../types/file/fileName.js"; export class FileService { public async fileExists(file: FilePath): Promise { @@ -48,6 +49,18 @@ export class FileService { await fsExtra.copy(source.toString(), destination.toString()); } + public async getDirectory(directoryPath: DirectoryPath): Promise { + const entries = (await fsExtra.readdir(directoryPath.toString())).filter((entry) => entry !== ".git"); + const results = await Promise.all( + entries.map(async (entry) => { + const fullPath = path.join(directoryPath.toString(), entry); + const stat = await fsExtra.stat(fullPath); + return stat.isDirectory() ? await this.getDirectory(new DirectoryPath(fullPath)) : new FileName(entry); + }) + ); + return new Directory(directoryPath, results); + } + public async copyDirectoryContents(source: DirectoryPath, destination: DirectoryPath) { const entries = await fsExtra.readdir(source.toString()); await Promise.all( @@ -93,28 +106,6 @@ export class FileService { public async copy(source: FilePath, destination: FilePath) { await fsExtra.copyFile(source.toString(), destination.toString()); } - - public getDirectoryStructure(directoryPath: DirectoryPath, parentPath = ""): DirectoryNode { - const directoryStructure: DirectoryNode = {}; - - const items = fs.readdirSync(directoryPath.toString()); - items.forEach((item) => { - if (item === ".git") return; // Skip .git directory - - const itemPath = path.join(directoryPath.toString(), item); - const relativePath = path.join(parentPath, item); - const stats = fs.statSync(itemPath); - - if (stats.isDirectory()) { - const subdirectoryStructure = this.getDirectoryStructure(new DirectoryPath(itemPath), relativePath); - directoryStructure[item] = subdirectoryStructure; - } else { - directoryStructure[item] = null; - } - }); - - return directoryStructure; - } } const streamPipeline = promisify(pipeline); diff --git a/src/prompts/portal/quickstart.ts b/src/prompts/portal/quickstart.ts index 7e6221adf..22c42fc09 100644 --- a/src/prompts/portal/quickstart.ts +++ b/src/prompts/portal/quickstart.ts @@ -11,6 +11,7 @@ import { DirectoryPath } from "../../types/file/directoryPath.js"; import { FileService } from "../../infrastructure/file-service.js"; import { removeQuotes } from "../../utils/string-utils.js"; import { getErrorMessage, ServiceError } from "../../infrastructure/api-utils.js"; +import { Directory } from "../../types/file/directory.js"; const vscodeExtensionUrl = "https://marketplace.visualstudio.com/items?itemName=apimatic-developers.apimatic-for-vscode"; @@ -219,8 +220,8 @@ export class PortalQuickstartPrompts { ); } - public displayBuildDirectoryAsTree(buildDirectory: DirectoryPath): void { - const buildDirectoryStructure = this.fileService.getDirectoryStructure(buildDirectory) as treeify.TreeObject; + public async displayBuildDirectoryAsTree(buildDirectory: DirectoryPath): Promise { + const buildDirectoryStructure = await this.fileService.getDirectory(buildDirectory); const buildDirectoryTreeStructure = this.createTreeStructure(buildDirectoryStructure); const tree = treeify.asTree(buildDirectoryTreeStructure, true, true); const coloredLogString = tree @@ -242,17 +243,24 @@ ${f.link(referenceDocumentationUrl)}`; log.error(getErrorMessage(error)); } - private createTreeStructure(directoryTreeStructure: treeify.TreeObject, parentPath = ""): treeify.TreeObject { + private createTreeStructure(directoryTreeStructure: Directory, parentPath = ""): treeify.TreeObject { const result: treeify.TreeObject = {}; - for (const key in directoryTreeStructure) { - const relPath = path.join(parentPath, key); + for (const item of directoryTreeStructure.items) { + let name: string; + let relPath: string; + if (item instanceof Directory) { + name = path.basename(item.directoryPath.toString()); + relPath = path.join(parentPath, name); + } else { + name = item.toString(); + relPath = path.join(parentPath, name); + } const desc = descriptions[path.normalize(relPath)]; - const displayKey = desc ? `${key} : ${desc}` : key; - - if (directoryTreeStructure[key] && typeof directoryTreeStructure[key] === "object") { - result[displayKey] = this.createTreeStructure(directoryTreeStructure[key] as treeify.TreeObject, relPath); + const displayKey = desc ? `${name} : ${desc}` : name; + if (item instanceof Directory) { + result[displayKey] = this.createTreeStructure(item, relPath); } else { - result[displayKey] = null as unknown as string; // keep null, not "" + result[displayKey] = null as unknown as string; } } return result; diff --git a/src/types/file/directory.ts b/src/types/file/directory.ts new file mode 100644 index 000000000..31dfa1c00 --- /dev/null +++ b/src/types/file/directory.ts @@ -0,0 +1,14 @@ +import { DirectoryPath } from "./directoryPath.js"; +import { FileName } from "./fileName.js"; + +export type DirectoryItem = FileName | Directory; + +export class Directory { + public readonly directoryPath: DirectoryPath; + public readonly items: DirectoryItem[]; + + public constructor(directoryPath: DirectoryPath, filePaths: DirectoryItem[]) { + this.directoryPath = directoryPath; + this.items = filePaths; + } +} diff --git a/src/types/file/directoryNode.ts b/src/types/file/directoryNode.ts deleted file mode 100644 index 3d50da43e..000000000 --- a/src/types/file/directoryNode.ts +++ /dev/null @@ -1,3 +0,0 @@ -export interface DirectoryNode { - [key: string]: DirectoryNode | string | null | undefined; -} From 23feb8689f32c8cc513472f24c0710344af6fac9 Mon Sep 17 00:00:00 2001 From: Ali Asghar <75574550+aliasghar98@users.noreply.github.com> Date: Tue, 2 Sep 2025 14:48:17 +0500 Subject: [PATCH 49/77] refactor: removed infra dependencies from quickstart prompts --- .../services/file-metadata-service.ts | 8 ++ src/prompts/portal/quickstart.ts | 127 +++++++++--------- 2 files changed, 71 insertions(+), 64 deletions(-) create mode 100644 src/infrastructure/services/file-metadata-service.ts diff --git a/src/infrastructure/services/file-metadata-service.ts b/src/infrastructure/services/file-metadata-service.ts new file mode 100644 index 000000000..5b6f715c9 --- /dev/null +++ b/src/infrastructure/services/file-metadata-service.ts @@ -0,0 +1,8 @@ +import axios from "axios"; + +export class FileMetadataService { + public async contentType(path: string): Promise { + const response = await axios.head(path); + return response.headers["content-type"]; + } +} diff --git a/src/prompts/portal/quickstart.ts b/src/prompts/portal/quickstart.ts index 22c42fc09..ab7d5c81a 100644 --- a/src/prompts/portal/quickstart.ts +++ b/src/prompts/portal/quickstart.ts @@ -1,6 +1,4 @@ import * as path from "path"; -import fs from "fs"; -import axios from "axios"; import treeify from "treeify"; import { Result } from "neverthrow"; import { text, select, multiselect, log, isCancel, note } from "@clack/prompts"; @@ -8,10 +6,13 @@ import { getMessageInGreenColor } from "../../utils/utils.js"; import { UrlPath } from "../../types/file/urlPath.js"; import { format as f, withSpinner } from "../format.js"; import { DirectoryPath } from "../../types/file/directoryPath.js"; +import { FilePath } from "../../types/file/filePath.js"; import { FileService } from "../../infrastructure/file-service.js"; import { removeQuotes } from "../../utils/string-utils.js"; import { getErrorMessage, ServiceError } from "../../infrastructure/api-utils.js"; import { Directory } from "../../types/file/directory.js"; +import { FileName } from "../../types/file/fileName.js"; +import { FileMetadataService } from "../../infrastructure/services/file-metadata-service.js"; const vscodeExtensionUrl = "https://marketplace.visualstudio.com/items?itemName=apimatic-developers.apimatic-for-vscode"; @@ -31,6 +32,7 @@ const descriptions: { [key: string]: string } = Object.entries({ export class PortalQuickstartPrompts { private readonly fileService = new FileService(); + private readonly fileMetadataService = new FileMetadataService(); public welcomeMessage() { log.info(`Hello there.`); @@ -51,48 +53,40 @@ export class PortalQuickstartPrompts { const spec = await text({ 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 a sample OpenAPI file from APIMatic.", - defaultValue: defaultSpecUrl.toString(), - validate: (input) => { - if (!input) return; - - const cleanedPath = removeQuotes(input.trim() ?? ""); + defaultValue: defaultSpecUrl.toString() + }); - if (!UrlPath.create(cleanedPath)) { - const dirPath = path.resolve(cleanedPath); + if (isCancel(spec)) { + return undefined; + } - if (!fs.existsSync(dirPath)) { - return "The specified file does not exist. Please enter a valid file path."; - } + const cleanedPath = removeQuotes((spec as string).trim() ?? ""); - if (!fs.statSync(dirPath).isFile()) { - return "The specified path does not point to a valid API Definition file or a zip archive containing API definition files. Please try again."; - } - } + if (!UrlPath.create(cleanedPath)) { + const dirName = path.dirname(cleanedPath); + const baseName = path.basename(cleanedPath); + const directoryPath = new DirectoryPath(dirName); + const fileName = new FileName(baseName); + const filePath = new FilePath(directoryPath, fileName); - return; // pass sync validation + if (!(await this.fileService.fileExists(filePath))) { + log.error("The specified file does not exist or is not a valid file. Please enter a valid file path."); + continue; // re-prompt } - }); - if (isCancel(spec)) { - return undefined; + return cleanedPath; } - const cleanedPath = removeQuotes(String(spec).trim()); - - // Async validation for URLs - if (UrlPath.create(cleanedPath)) { - try { - const response = await axios.head(cleanedPath); - const contentType = response.headers["content-type"]; + try { + const contentType = await this.fileMetadataService.contentType(cleanedPath); - if (contentType?.includes("text/html")) { - log.error(`Invalid URL. Please check the URL and ensure it points to a valid OpenAPI definition.`); - continue; // re-prompt - } - } catch { - log.error(`Failed to reach the URL. Please check your internet connection or the URL.`); + if (contentType?.includes("text/html")) { + log.error(`Invalid URL. Please check the URL and ensure it points to a valid OpenAPI definition.`); continue; // re-prompt } + } catch { + log.error(`Failed to reach the URL. Please check your internet connection or the URL.`); + continue; // re-prompt } return cleanedPath; // valid local file or valid URL @@ -170,40 +164,41 @@ export class PortalQuickstartPrompts { } public async inputDirectoryPathPrompt(): Promise { - const directory = await text({ - 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 cleanedPath = removeQuotes(input?.trim() ?? ""); - - if (!fs.existsSync(cleanedPath.toString()) && cleanedPath.toString() != defaultPortalDirectoryPath) { - return "Error: The specified directory path does not exist. Please try again."; - } + while (true) { + const inputDirectory = await text({ + 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: "./" + }); - if (cleanedPath.toString() !== defaultPortalDirectoryPath) { - const files = fs.readdirSync(cleanedPath.toString()).filter((item) => !item.startsWith(".")); - if (files.length > 0) { - return "Error: The target directory is not empty. Please provide a path to an empty directory or clear its contents."; - } - } else if (fs.existsSync(cleanedPath.toString())) { - // For ignoring hidden files and folders in the current directory in MacOS. - const files = fs.readdirSync(cleanedPath.toString()).filter((item) => !item.startsWith(".")); - if (files.length > 0) { - return "Error: The target directory is not empty. Please provide a path to an empty directory or clear its contents."; - } - } + if (isCancel(inputDirectory)) { + return undefined; } - }); - if (isCancel(directory)) { - return undefined; - } + const cleanedPath = removeQuotes((inputDirectory as string)?.trim() ?? ""); + const directoryPath = new DirectoryPath(cleanedPath); + + if ( + !(await this.fileService.directoryExists(directoryPath)) && + directoryPath.toString() !== defaultPortalDirectoryPath + ) { + log.error("Error: The specified directory path does not exist. Please try again."); + continue; + } + + const directory = await this.fileService.getDirectory(directoryPath); + if (directory.items.length > 0) { + log.error( + "Error: The target directory is not empty. Please provide a path to an empty directory or clear its contents." + ); + continue; + } - if (directory === "./") { - return new DirectoryPath(defaultPortalDirectoryPath); - } else { - return new DirectoryPath(removeQuotes(directory as string).trim()); + if (directory.toString() === "./") { + return new DirectoryPath(defaultPortalDirectoryPath); + } else { + return directoryPath; + } } } @@ -236,7 +231,11 @@ export class PortalQuickstartPrompts { Customize the Portal theme, add API recipes and enable AI features ${f.link(referenceDocumentationUrl)}`; - note(message, "Next steps"); + note(message, "Next steps", { + format(line) { + return f.success(line); + }, + }); } public serviceError(error: ServiceError) { From 7ab40ba40c410cdbb16d1f83766f571412dbe0cf Mon Sep 17 00:00:00 2001 From: Ali Asghar <75574550+aliasghar98@users.noreply.github.com> Date: Tue, 2 Sep 2025 15:27:02 +0500 Subject: [PATCH 50/77] refactor: removed usage of path and used resource input instead --- src/prompts/portal/quickstart.ts | 26 +++++++++++++------------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/src/prompts/portal/quickstart.ts b/src/prompts/portal/quickstart.ts index ab7d5c81a..eb823c16c 100644 --- a/src/prompts/portal/quickstart.ts +++ b/src/prompts/portal/quickstart.ts @@ -11,8 +11,8 @@ import { FileService } from "../../infrastructure/file-service.js"; import { removeQuotes } from "../../utils/string-utils.js"; import { getErrorMessage, ServiceError } from "../../infrastructure/api-utils.js"; import { Directory } from "../../types/file/directory.js"; -import { FileName } from "../../types/file/fileName.js"; import { FileMetadataService } from "../../infrastructure/services/file-metadata-service.js"; +import { createResourceInput } from "../../types/file/resource-input.js"; const vscodeExtensionUrl = "https://marketplace.visualstudio.com/items?itemName=apimatic-developers.apimatic-for-vscode"; @@ -63,18 +63,18 @@ export class PortalQuickstartPrompts { const cleanedPath = removeQuotes((spec as string).trim() ?? ""); if (!UrlPath.create(cleanedPath)) { - const dirName = path.dirname(cleanedPath); - const baseName = path.basename(cleanedPath); - const directoryPath = new DirectoryPath(dirName); - const fileName = new FileName(baseName); - const filePath = new FilePath(directoryPath, fileName); - - if (!(await this.fileService.fileExists(filePath))) { - log.error("The specified file does not exist or is not a valid file. Please enter a valid file path."); - continue; // re-prompt - } + try { + const resourcePath = createResourceInput(cleanedPath); + + if (!(await this.fileService.fileExists(resourcePath as FilePath))) { + log.error("The specified file does not exist or is not a valid file. Please enter a valid file path."); + continue; // re-prompt + } - return cleanedPath; + return cleanedPath; + } catch { + log.error("Invalid file path provided. Please enter a valid file path."); + } } try { @@ -234,7 +234,7 @@ ${f.link(referenceDocumentationUrl)}`; note(message, "Next steps", { format(line) { return f.success(line); - }, + } }); } From e94ecfab24a96c7c1c816b79823f7439b55375be Mon Sep 17 00:00:00 2001 From: Ali Asghar <75574550+aliasghar98@users.noreply.github.com> Date: Tue, 2 Sep 2025 15:34:32 +0500 Subject: [PATCH 51/77] refactor: removed redundant todo and comment --- src/prompts/portal/quickstart.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/prompts/portal/quickstart.ts b/src/prompts/portal/quickstart.ts index eb823c16c..769d2a582 100644 --- a/src/prompts/portal/quickstart.ts +++ b/src/prompts/portal/quickstart.ts @@ -47,7 +47,6 @@ export class PortalQuickstartPrompts { log.step(message); } - // TODO: Very complex validation, needs to be improved. public async specPathPrompt(defaultSpecUrl: UrlPath): Promise { while (true) { const spec = await text({ @@ -89,7 +88,7 @@ export class PortalQuickstartPrompts { continue; // re-prompt } - return cleanedPath; // valid local file or valid URL + return cleanedPath; } } From 8f92c22045ebd50ebd25a4a04e949e3768f4eb82 Mon Sep 17 00:00:00 2001 From: Shield-Jaguar Date: Tue, 2 Sep 2025 17:16:25 +0500 Subject: [PATCH 52/77] feat: remove tree --- package-lock.json | 8 --- package.json | 1 - src/actions/portal/quickstart.ts | 4 +- src/actions/portal/recipe/new-recipe.ts | 2 - src/prompts/format.ts | 4 +- src/prompts/portal/quickstart.ts | 87 +++++++++++-------------- src/prompts/portal/recipe/new-recipe.ts | 17 +---- src/types/file/directoryPath.ts | 3 + 8 files changed, 50 insertions(+), 76 deletions(-) diff --git a/package-lock.json b/package-lock.json index b5bab3d4f..94b079761 100644 --- a/package-lock.json +++ b/package-lock.json @@ -32,7 +32,6 @@ "prettier": "^2.8.8", "striptags": "^3.2.0", "tmp-promise": "^3.0.3", - "treeify": "^1.1.0", "tslib": "^2.8.1", "uuid": "^11.1.0", "yaml": "^2.8.0" @@ -13518,13 +13517,6 @@ "node": ">=0.6" } }, - "node_modules/treeify": { - "version": "1.1.0", - "license": "MIT", - "engines": { - "node": ">=0.6" - } - }, "node_modules/trim-newlines": { "version": "3.0.1", "dev": true, diff --git a/package.json b/package.json index f875207a9..1aeff2bfa 100644 --- a/package.json +++ b/package.json @@ -70,7 +70,6 @@ "prettier": "^2.8.8", "striptags": "^3.2.0", "tmp-promise": "^3.0.3", - "treeify": "^1.1.0", "tslib": "^2.8.1", "uuid": "^11.1.0", "yaml": "^2.8.0" diff --git a/src/actions/portal/quickstart.ts b/src/actions/portal/quickstart.ts index 6ca1c67c9..0c7fffd4c 100644 --- a/src/actions/portal/quickstart.ts +++ b/src/actions/portal/quickstart.ts @@ -102,7 +102,9 @@ export class PortalQuickstartAction { const sourceDirectory = inputDirectory.join("src"); await this.fileService.copyDirectoryContents(extractedFolder, sourceDirectory); - this.prompts.displayBuildDirectoryAsTree(sourceDirectory); + + const buildDirectoryStructure = await this.fileService.getDirectory(sourceDirectory); + this.prompts.printDirectoryStructure(buildDirectoryStructure); const portalDirectory = inputDirectory.join("portal"); const servePortalResult = await this.servePortal(sourceDirectory, portalDirectory); diff --git a/src/actions/portal/recipe/new-recipe.ts b/src/actions/portal/recipe/new-recipe.ts index 4731f2812..1e7ea8304 100644 --- a/src/actions/portal/recipe/new-recipe.ts +++ b/src/actions/portal/recipe/new-recipe.ts @@ -75,9 +75,7 @@ export class PortalRecipeAction { contentFolderPath ); - const buildDirectoryStructure = await this.getBuildDirectoryStructure(recipeFileName); - this.prompts.displayBuildDirectoryStructureAsTree(buildDirectoryStructure as TreeObject); this.prompts.displayRecipeGenerationSuccessMessage(contentFolderPath); return Result.success("Generated recipe successfully."); } diff --git a/src/prompts/format.ts b/src/prompts/format.ts index 031f9c018..652bb721b 100644 --- a/src/prompts/format.ts +++ b/src/prompts/format.ts @@ -8,7 +8,9 @@ export const format = { // Core element types var: (text: string) => pc.magenta(`'${text}'`), path: (text: string) => pc.cyan(`'${text}'`), - link: (text: string) => pc.cyan(`'${text}'`), // TODO: merge with Saeed's implementation + bullet: (text: string) => pc.dim(text), + link: (text: string) => pc.blue(`${text}`), // TODO: merge with Saeed's implementation + description: (text: string) => pc.greenBright(`${text}`), // TODO: merge with Saeed's implementation cmd: (cmd: string, topic: string, action: string) => `${pc.blue(cmd)} ${pc.dim(topic)} ${pc.dim(action)}`, flag: (name: string, value: string | undefined = undefined) => { if (value) { diff --git a/src/prompts/portal/quickstart.ts b/src/prompts/portal/quickstart.ts index 769d2a582..7520ef797 100644 --- a/src/prompts/portal/quickstart.ts +++ b/src/prompts/portal/quickstart.ts @@ -1,8 +1,5 @@ -import * as path from "path"; -import treeify from "treeify"; import { Result } from "neverthrow"; import { text, select, multiselect, log, isCancel, note } from "@clack/prompts"; -import { getMessageInGreenColor } from "../../utils/utils.js"; import { UrlPath } from "../../types/file/urlPath.js"; import { format as f, withSpinner } from "../format.js"; import { DirectoryPath } from "../../types/file/directoryPath.js"; @@ -19,17 +16,6 @@ const vscodeExtensionUrl = const referenceDocumentationUrl = "https://docs.apimatic.io/cli-getting-started/advanced-portal-setup"; const defaultPortalDirectoryPath = process.cwd(); -const descriptions: { [key: string]: string } = Object.entries({ - "APIMATIC-BUILD.json": "# Defines all configurations for the API portal, including programming languages and themes", - spec: "# Contains all API definition files", - content: "# Includes custom documentation pages in Markdown", - "content/toc.yml": "# Controls the structure of the side navigation bar in the API portal", - static: "# Includes all static files, such as images, GIFs, and PDFs" -}).reduce((acc, [key, value]) => { - acc[path.normalize(key)] = value; - return acc; -}, {} as { [key: string]: string }); - export class PortalQuickstartPrompts { private readonly fileService = new FileService(); private readonly fileMetadataService = new FileMetadataService(); @@ -214,53 +200,58 @@ export class PortalQuickstartPrompts { ); } - public async displayBuildDirectoryAsTree(buildDirectory: DirectoryPath): Promise { - const buildDirectoryStructure = await this.fileService.getDirectory(buildDirectory); - const buildDirectoryTreeStructure = this.createTreeStructure(buildDirectoryStructure); - const tree = treeify.asTree(buildDirectoryTreeStructure, true, true); - const coloredLogString = tree - .split("\n") - .map((line) => line.replace(/#.*/, (match) => getMessageInGreenColor(match))) - .join("\n"); - log.step(coloredLogString); - } - public nextSteps(): void { const message = `Use the API Playground or an SDK to call your API. Customize the Portal theme, add API recipes and enable AI features ${f.link(referenceDocumentationUrl)}`; - note(message, "Next steps", { - format(line) { - return f.success(line); - } - }); + note(message, 'Next steps'); } public serviceError(error: ServiceError) { log.error(getErrorMessage(error)); } - private createTreeStructure(directoryTreeStructure: Directory, parentPath = ""): treeify.TreeObject { - const result: treeify.TreeObject = {}; - for (const item of directoryTreeStructure.items) { - let name: string; - let relPath: string; - if (item instanceof Directory) { - name = path.basename(item.directoryPath.toString()); - relPath = path.join(parentPath, name); - } else { - name = item.toString(); - relPath = path.join(parentPath, name); - } - const desc = descriptions[path.normalize(relPath)]; - const displayKey = desc ? `${name} : ${desc}` : name; + public printDirectoryStructure(directory: Directory) { + const tree = this.getDirectoryTree(directory); + log.info(tree); + } + + private getDirectoryTree(dir: Directory, prefix: string = "", isLast: boolean = true): string { + const folderDescription: Record = { + "src/APIMATIC-BUILD.json": + "# Defines all configurations for the API portal, including programming languages and themes", + "src/spec": "# Contains all API definition files", + content: "# Includes custom documentation pages in Markdown", + static: "# Includes all static files, such as images, GIFs, and PDFs" + }; + + const fileDescriptions: Record = { + "content/toc.yml": "# Controls the structure of the side navigation bar in the API portal" + }; + + // Root line + const pointer = f.bullet(isLast ? "└─ " : "├─ "); + + const description = f.description(folderDescription[dir.directoryPath.leafName()]) ?? ""; + let output = `${prefix}${pointer}${dir.directoryPath.leafName()} ${description}\n`; + + const items = dir.items; + const newPrefix = prefix + (isLast ? " " : f.bullet("| ")); + + items.forEach((item, index) => { + const last = index === items.length - 1; + if (item instanceof Directory) { - result[displayKey] = this.createTreeStructure(item, relPath); + output += this.getDirectoryTree(item, newPrefix, last); } else { - result[displayKey] = null as unknown as string; + const filePointer = f.bullet(last ? "└─ " : "├─ "); + const fileDescription = + (fileDescriptions[item.toString()] ? f.description(fileDescriptions[item.toString()]) : undefined) ?? ""; + output += `${newPrefix}${filePointer}${item.toString()} ${fileDescription}\n`; } - } - return result; + }); + + return output; } } diff --git a/src/prompts/portal/recipe/new-recipe.ts b/src/prompts/portal/recipe/new-recipe.ts index e9bc161dd..bbe35ecdd 100644 --- a/src/prompts/portal/recipe/new-recipe.ts +++ b/src/prompts/portal/recipe/new-recipe.ts @@ -1,8 +1,6 @@ import * as fs from "fs"; import * as path from "path"; -import treeify from "treeify"; import { intro, spinner, select, text, cancel, isCancel, outro, log, autocomplete } from "@clack/prompts"; -import { getMessageInGreenColor } from "../../../utils/utils.js"; import { SdlEndpoint } from "../../../types/sdl/sdl.js"; export class PortalRecipePrompts { @@ -58,12 +56,12 @@ export class PortalRecipePrompts { } const cleanedPath = this.removeQuotes(filePath.trim()); const resolvedPath = path.resolve(buildDirectoryPath, cleanedPath); - + if (!resolvedPath.endsWith(".json")) { return "The content file must be a JSON (.json) file. Please provide a valid file path."; } - + if (fs.existsSync(resolvedPath) && fs.statSync(resolvedPath).isFile()) { return; } @@ -235,17 +233,6 @@ export class PortalRecipePrompts { this.spin.stop(message); } - public displayBuildDirectoryStructureAsTree(buildDirectoryTreeObject: treeify.TreeObject) { - const tree = treeify.asTree(buildDirectoryTreeObject, true, true); - - const coloredLogString = tree - .split("\n") - .map((line: string) => line.replace(/#.*/, (match: string) => getMessageInGreenColor(match))) - .join("\n"); - - log.step(`🛠️ You can edit the following files to customize your API Recipe :\n\n` + coloredLogString); - } - public logError(error: string): void { outro(error); } diff --git a/src/types/file/directoryPath.ts b/src/types/file/directoryPath.ts index 09c5e3cf8..e07ed6412 100644 --- a/src/types/file/directoryPath.ts +++ b/src/types/file/directoryPath.ts @@ -19,4 +19,7 @@ export class DirectoryPath { public isEqual(other: DirectoryPath) { return this.directoryPath === other.directoryPath; } + public leafName() { + return path.basename(this.directoryPath); + } } From 6538e38aac87147ceb6a485338048e7b8fdfac0b Mon Sep 17 00:00:00 2001 From: Ali Asghar <75574550+aliasghar98@users.noreply.github.com> Date: Tue, 2 Sep 2025 18:18:34 +0500 Subject: [PATCH 53/77] refactor: moved all infra services from quickstart prompts to action and removed bullet format --- src/actions/portal/quickstart.ts | 77 ++++++++++++++++---- src/prompts/format.ts | 1 - src/prompts/portal/quickstart.ts | 121 ++++++++++++------------------- 3 files changed, 111 insertions(+), 88 deletions(-) diff --git a/src/actions/portal/quickstart.ts b/src/actions/portal/quickstart.ts index 0c7fffd4c..eec0d862a 100644 --- a/src/actions/portal/quickstart.ts +++ b/src/actions/portal/quickstart.ts @@ -21,6 +21,7 @@ import { BuildContext } from "../../types/build-context.js"; import { TempContext } from "../../types/temp-context.js"; import { FileDownloadService } from "../../infrastructure/services/file-download-service.js"; import { getLanguagesConfig } from "../../types/build/build.js"; +import { FileMetadataService } from "../../infrastructure/services/file-metadata-service.js"; const defaultSpecUrl: UrlPath = new UrlPath( "https://raw.githubusercontent.com/apimatic/static-portal-workflow/refs/heads/master/spec/openapi.json" @@ -31,6 +32,7 @@ export class PortalQuickstartAction { private readonly prompts: PortalQuickstartPrompts = new PortalQuickstartPrompts(); private readonly zipService: ZipService = new ZipService(); private readonly fileService: FileService = new FileService(); + private readonly fileMetadataService: FileMetadataService = new FileMetadataService(); private readonly configDir: DirectoryPath; private readonly commandMetadata: CommandMetadata; private readonly fileDownloadService = new FileDownloadService(); @@ -75,7 +77,7 @@ export class PortalQuickstartAction { // Step 4/4 const inputDirectory = await this.selectInputDirectory(); - if (!inputDirectory) { + if (inputDirectory.isErr()) { return ActionResult.cancelled(); } @@ -100,13 +102,13 @@ export class PortalQuickstartAction { buildFile.generatePortal!.languageConfig = getLanguagesConfig(selectedLanguages); await tempBuildContext.updateBuildFileContents(buildFile); - const sourceDirectory = inputDirectory.join("src"); + const sourceDirectory = inputDirectory.value.join("src"); await this.fileService.copyDirectoryContents(extractedFolder, sourceDirectory); const buildDirectoryStructure = await this.fileService.getDirectory(sourceDirectory); this.prompts.printDirectoryStructure(buildDirectoryStructure); - const portalDirectory = inputDirectory.join("portal"); + const portalDirectory = inputDirectory.value.join("portal"); const servePortalResult = await this.servePortal(sourceDirectory, portalDirectory); if (servePortalResult.isErr()) { return ActionResult.failed(); @@ -129,10 +131,42 @@ export class PortalQuickstartAction { private async importSpec(tempDirectory: DirectoryPath): Promise> { this.prompts.importSpecStep(); - const inputPath = await this.prompts.specPathPrompt(defaultSpecUrl); - if (!inputPath) { - this.prompts.noSpecSpecified(); - return err(); + let inputPath: string | undefined; + while (true) { + inputPath = await this.prompts.specPathPrompt(defaultSpecUrl); + if (!inputPath) { + this.prompts.noSpecSpecified(); + return err(); + } + + if (!UrlPath.create(inputPath)) { + try { + const resourcePath = createResourceInput(inputPath); + if (!(await this.fileService.fileExists(resourcePath as FilePath))) { + this.prompts.specFileDoesNotExist(); + continue; // re-prompt + } + + break; // valid file path. + } catch { + this.prompts.invalidSpecFilePath(); + continue; // re-prompt + } + } + + try { + const contentType = await this.fileMetadataService.contentType(inputPath); + + if (contentType?.includes("text/html")) { + this.prompts.invalidSpecUrl(); + continue; // re-prompt + } + } catch { + this.prompts.unreachableUrl(); + continue; // re-prompt + } + + break; // valid URL. } const urlPath = UrlPath.create(inputPath); @@ -193,14 +227,31 @@ export class PortalQuickstartAction { return ok(languages); } - private async selectInputDirectory(): Promise { + private async selectInputDirectory(): Promise> { this.prompts.selectInputDirectoryStep(); - const inputDirectory = await this.prompts.inputDirectoryPathPrompt(); - if (inputDirectory) { - return inputDirectory; - } else { - this.prompts.noInputDirectoryProvided(); + let inputDirectory: DirectoryPath | undefined; + while (true) { + inputDirectory = await this.prompts.inputDirectoryPathPrompt(); + if (!inputDirectory) { + this.prompts.noInputDirectoryProvided(); + return err(); + } + + if (!(await this.fileService.directoryExists(inputDirectory)) && inputDirectory.toString() !== "./") { + this.prompts.inputDirectoryPathDoesNotExist(); + continue; + } + + const directory = await this.fileService.getDirectory(inputDirectory); + if (directory.items.length > 0) { + this.prompts.inputDirectoryNotEmpty(); + continue; + } + + break; } + + return ok(inputDirectory); } private async servePortal( diff --git a/src/prompts/format.ts b/src/prompts/format.ts index 652bb721b..51172fb37 100644 --- a/src/prompts/format.ts +++ b/src/prompts/format.ts @@ -8,7 +8,6 @@ export const format = { // Core element types var: (text: string) => pc.magenta(`'${text}'`), path: (text: string) => pc.cyan(`'${text}'`), - bullet: (text: string) => pc.dim(text), link: (text: string) => pc.blue(`${text}`), // TODO: merge with Saeed's implementation description: (text: string) => pc.greenBright(`${text}`), // TODO: merge with Saeed's implementation cmd: (cmd: string, topic: string, action: string) => `${pc.blue(cmd)} ${pc.dim(topic)} ${pc.dim(action)}`, diff --git a/src/prompts/portal/quickstart.ts b/src/prompts/portal/quickstart.ts index 7520ef797..cffc33c9a 100644 --- a/src/prompts/portal/quickstart.ts +++ b/src/prompts/portal/quickstart.ts @@ -3,13 +3,9 @@ import { text, select, multiselect, log, isCancel, note } from "@clack/prompts"; import { UrlPath } from "../../types/file/urlPath.js"; import { format as f, withSpinner } from "../format.js"; import { DirectoryPath } from "../../types/file/directoryPath.js"; -import { FilePath } from "../../types/file/filePath.js"; -import { FileService } from "../../infrastructure/file-service.js"; import { removeQuotes } from "../../utils/string-utils.js"; import { getErrorMessage, ServiceError } from "../../infrastructure/api-utils.js"; import { Directory } from "../../types/file/directory.js"; -import { FileMetadataService } from "../../infrastructure/services/file-metadata-service.js"; -import { createResourceInput } from "../../types/file/resource-input.js"; const vscodeExtensionUrl = "https://marketplace.visualstudio.com/items?itemName=apimatic-developers.apimatic-for-vscode"; @@ -17,9 +13,6 @@ const referenceDocumentationUrl = "https://docs.apimatic.io/cli-getting-started/ const defaultPortalDirectoryPath = process.cwd(); export class PortalQuickstartPrompts { - private readonly fileService = new FileService(); - private readonly fileMetadataService = new FileMetadataService(); - public welcomeMessage() { log.info(`Hello there.`); log.message( @@ -34,48 +27,35 @@ export class PortalQuickstartPrompts { } public async specPathPrompt(defaultSpecUrl: UrlPath): Promise { - while (true) { - const spec = await text({ - 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 a sample OpenAPI file from APIMatic.", - defaultValue: defaultSpecUrl.toString() - }); - - if (isCancel(spec)) { - return undefined; - } + const spec = await text({ + 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 a sample OpenAPI file from APIMatic.", + defaultValue: defaultSpecUrl.toString() + }); - const cleanedPath = removeQuotes((spec as string).trim() ?? ""); + if (isCancel(spec)) { + return undefined; + } - if (!UrlPath.create(cleanedPath)) { - try { - const resourcePath = createResourceInput(cleanedPath); + const cleanedPath = removeQuotes(spec.trim() ?? ""); - if (!(await this.fileService.fileExists(resourcePath as FilePath))) { - log.error("The specified file does not exist or is not a valid file. Please enter a valid file path."); - continue; // re-prompt - } + return cleanedPath; + } - return cleanedPath; - } catch { - log.error("Invalid file path provided. Please enter a valid file path."); - } - } + public specFileDoesNotExist() { + log.error("The specified file does not exist or is not a valid file. Please enter a valid file path."); + } - try { - const contentType = await this.fileMetadataService.contentType(cleanedPath); + public invalidSpecFilePath() { + log.error("Invalid file path provided. Please enter a valid file path."); + } - if (contentType?.includes("text/html")) { - log.error(`Invalid URL. Please check the URL and ensure it points to a valid OpenAPI definition.`); - continue; // re-prompt - } - } catch { - log.error(`Failed to reach the URL. Please check your internet connection or the URL.`); - continue; // re-prompt - } + public invalidSpecUrl() { + log.error(`Invalid URL. Please check the URL and ensure it points to a valid OpenAPI definition.`); + } - return cleanedPath; - } + public unreachableUrl() { + log.error(`Failed to reach the URL. Please check your internet connection or the URL.`); } public noSpecSpecified() { @@ -163,23 +143,7 @@ export class PortalQuickstartPrompts { const cleanedPath = removeQuotes((inputDirectory as string)?.trim() ?? ""); const directoryPath = new DirectoryPath(cleanedPath); - if ( - !(await this.fileService.directoryExists(directoryPath)) && - directoryPath.toString() !== defaultPortalDirectoryPath - ) { - log.error("Error: The specified directory path does not exist. Please try again."); - continue; - } - - const directory = await this.fileService.getDirectory(directoryPath); - if (directory.items.length > 0) { - log.error( - "Error: The target directory is not empty. Please provide a path to an empty directory or clear its contents." - ); - continue; - } - - if (directory.toString() === "./") { + if ((inputDirectory as string) === "./") { return new DirectoryPath(defaultPortalDirectoryPath); } else { return directoryPath; @@ -187,6 +151,16 @@ export class PortalQuickstartPrompts { } } + public inputDirectoryPathDoesNotExist() { + log.error("Error: The specified directory path does not exist. Please try again."); + } + + public inputDirectoryNotEmpty() { + log.error( + "Error: The target directory is not empty. Please provide a path to an empty directory or clear its contents." + ); + } + public noInputDirectoryProvided() { log.error("No build directory was provided."); } @@ -205,7 +179,7 @@ export class PortalQuickstartPrompts { Customize the Portal theme, add API recipes and enable AI features ${f.link(referenceDocumentationUrl)}`; - note(message, 'Next steps'); + note(message, "Next steps"); } public serviceError(error: ServiceError) { @@ -219,25 +193,24 @@ ${f.link(referenceDocumentationUrl)}`; private getDirectoryTree(dir: Directory, prefix: string = "", isLast: boolean = true): string { const folderDescription: Record = { - "src/APIMATIC-BUILD.json": - "# Defines all configurations for the API portal, including programming languages and themes", - "src/spec": "# Contains all API definition files", + spec: "# Contains all API definition files", content: "# Includes custom documentation pages in Markdown", static: "# Includes all static files, such as images, GIFs, and PDFs" }; const fileDescriptions: Record = { - "content/toc.yml": "# Controls the structure of the side navigation bar in the API portal" + "toc.yml": "# Controls the structure of the side navigation bar in the API portal", + "APIMATIC-BUILD.json": + "# Defines all configurations for the API portal, including programming languages and themes" }; - // Root line - const pointer = f.bullet(isLast ? "└─ " : "├─ "); - - const description = f.description(folderDescription[dir.directoryPath.leafName()]) ?? ""; - let output = `${prefix}${pointer}${dir.directoryPath.leafName()} ${description}\n`; + const pointer = isLast ? "└─ " : "├─ "; + const folderName = dir.directoryPath.leafName(); + const description = folderDescription[folderName] ? f.description(folderDescription[folderName]) : ""; + let output = `${prefix}${pointer}${folderName}${description ? " " + description : ""}\n`; const items = dir.items; - const newPrefix = prefix + (isLast ? " " : f.bullet("| ")); + const newPrefix = prefix + (isLast ? " " : "| "); items.forEach((item, index) => { const last = index === items.length - 1; @@ -245,10 +218,10 @@ ${f.link(referenceDocumentationUrl)}`; if (item instanceof Directory) { output += this.getDirectoryTree(item, newPrefix, last); } else { - const filePointer = f.bullet(last ? "└─ " : "├─ "); - const fileDescription = - (fileDescriptions[item.toString()] ? f.description(fileDescriptions[item.toString()]) : undefined) ?? ""; - output += `${newPrefix}${filePointer}${item.toString()} ${fileDescription}\n`; + const filePointer = last ? "└─ " : "├─ "; + const fileName = item.toString(); + const fileDescription = fileDescriptions[fileName] ? f.description(fileDescriptions[fileName]) : ""; + output += `${newPrefix}${filePointer}${fileName}${fileDescription ? " " + fileDescription : ""}\n`; } }); From cc17b1bdaac028e74d46c89c20dd204a7af71e00 Mon Sep 17 00:00:00 2001 From: Ali Asghar Date: Wed, 3 Sep 2025 00:10:27 +0500 Subject: [PATCH 54/77] fix: removed redundant loop --- src/prompts/portal/quickstart.ts | 32 +++++++++++++++----------------- 1 file changed, 15 insertions(+), 17 deletions(-) diff --git a/src/prompts/portal/quickstart.ts b/src/prompts/portal/quickstart.ts index cffc33c9a..18f915e2f 100644 --- a/src/prompts/portal/quickstart.ts +++ b/src/prompts/portal/quickstart.ts @@ -129,25 +129,23 @@ export class PortalQuickstartPrompts { } public async inputDirectoryPathPrompt(): Promise { - while (true) { - const inputDirectory = await text({ - 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: "./" - }); - - if (isCancel(inputDirectory)) { - return undefined; - } + const inputDirectory = await text({ + 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: "./" + }); - const cleanedPath = removeQuotes((inputDirectory as string)?.trim() ?? ""); - const directoryPath = new DirectoryPath(cleanedPath); + if (isCancel(inputDirectory)) { + return undefined; + } - if ((inputDirectory as string) === "./") { - return new DirectoryPath(defaultPortalDirectoryPath); - } else { - return directoryPath; - } + const cleanedPath = removeQuotes((inputDirectory as string)?.trim() ?? ""); + const directoryPath = new DirectoryPath(cleanedPath); + + if ((inputDirectory as string) === "./") { + return new DirectoryPath(defaultPortalDirectoryPath); + } else { + return directoryPath; } } From 57e9bf90c61dcdb0df00d1598ed6e15dfd90ac19 Mon Sep 17 00:00:00 2001 From: Shield-Jaguar Date: Wed, 3 Sep 2025 10:04:52 +0500 Subject: [PATCH 55/77] stash --- src/actions/portal/quickstart.ts | 116 +++++++++++++++---------------- src/prompts/portal/quickstart.ts | 16 +++-- src/types/file/filePath.ts | 19 ++++- src/types/file/resource-input.ts | 14 ++++ 4 files changed, 100 insertions(+), 65 deletions(-) diff --git a/src/actions/portal/quickstart.ts b/src/actions/portal/quickstart.ts index eec0d862a..aeb8876dd 100644 --- a/src/actions/portal/quickstart.ts +++ b/src/actions/portal/quickstart.ts @@ -52,8 +52,65 @@ export class PortalQuickstartAction { return ActionResult.failed(); } - return await withDirPath(async (tempDirectory: DirectoryPath) => { + return await withDirPath(async (tempDirectory: DirectoryPath): Promise => { // Step 1/4 + + this.prompts.importSpecStep(); + let inputPath = await this.prompts.specPathPrompt(defaultSpecUrl); + if (!inputPath) { + this.prompts.noSpecSpecified(); + return ActionResult.cancelled(); + } + const resourceContext = new ResourceContext(tempDirectory); + const specPath = await resourceContext.resolveTo(inputPath); + if (specPath.isErr()) { + this.prompts.serviceError(specPath.error); + } + + + + while (true) { + + + + if (!UrlPath.create(inputPath)) { + try { + const resourcePath = createResourceInput(inputPath); + if (!(await this.fileService.fileExists(resourcePath as FilePath))) { + this.prompts.specFileDoesNotExist(); + continue; // re-prompt + } + + break; // valid file path. + } catch { + this.prompts.invalidSpecFilePath(); + continue; // re-prompt + } + } + + try { + const contentType = await this.fileMetadataService.contentType(inputPath); + + if (contentType?.includes("text/html")) { + this.prompts.invalidSpecUrl(); + continue; // re-prompt + } + } catch { + this.prompts.unreachableUrl(); + continue; // re-prompt + } + + break; // valid URL. + } + + + + const specDirectory = tempDirectory.join("spec"); + await this.fileService.createDirectoryIfNotExists(specDirectory); + await this.fileService.copy(result.value, result.value.replaceDirectory(specDirectory)); + return ok(specDirectory); + + const specDirectory = await this.importSpec(tempDirectory); if (specDirectory.isErr()) { return ActionResult.failed(); @@ -129,63 +186,6 @@ export class PortalQuickstartAction { return ok(); } - private async importSpec(tempDirectory: DirectoryPath): Promise> { - this.prompts.importSpecStep(); - let inputPath: string | undefined; - while (true) { - inputPath = await this.prompts.specPathPrompt(defaultSpecUrl); - if (!inputPath) { - this.prompts.noSpecSpecified(); - return err(); - } - - if (!UrlPath.create(inputPath)) { - try { - const resourcePath = createResourceInput(inputPath); - if (!(await this.fileService.fileExists(resourcePath as FilePath))) { - this.prompts.specFileDoesNotExist(); - continue; // re-prompt - } - - break; // valid file path. - } catch { - this.prompts.invalidSpecFilePath(); - continue; // re-prompt - } - } - - try { - const contentType = await this.fileMetadataService.contentType(inputPath); - - if (contentType?.includes("text/html")) { - this.prompts.invalidSpecUrl(); - continue; // re-prompt - } - } catch { - this.prompts.unreachableUrl(); - continue; // re-prompt - } - - break; // valid URL. - } - - const urlPath = UrlPath.create(inputPath); - const resourceContext = new ResourceContext(tempDirectory); - const result = - urlPath === undefined - ? await resourceContext.resolveTo(createResourceInput(inputPath)) - : await resourceContext.resolveTo(urlPath); - if (result.isErr()) { - this.prompts.serviceError(result.error); - return err(); - } - - const specDirectory = tempDirectory.join("spec"); - await this.fileService.createDirectoryIfNotExists(specDirectory); - await this.fileService.copy(result.value, result.value.replaceDirectory(specDirectory)); - return ok(specDirectory); - } - private async validateSpec( tempDirectory: DirectoryPath, specDirectory: DirectoryPath diff --git a/src/prompts/portal/quickstart.ts b/src/prompts/portal/quickstart.ts index 18f915e2f..f9e2f46ff 100644 --- a/src/prompts/portal/quickstart.ts +++ b/src/prompts/portal/quickstart.ts @@ -1,11 +1,12 @@ import { Result } from "neverthrow"; -import { text, select, multiselect, log, isCancel, note } from "@clack/prompts"; +import { isCancel, log, multiselect, note, select, text } from "@clack/prompts"; import { UrlPath } from "../../types/file/urlPath.js"; import { format as f, withSpinner } from "../format.js"; import { DirectoryPath } from "../../types/file/directoryPath.js"; import { removeQuotes } from "../../utils/string-utils.js"; import { getErrorMessage, ServiceError } from "../../infrastructure/api-utils.js"; import { Directory } from "../../types/file/directory.js"; +import { createResourceInputFromInput, ResourceInput } from "../../types/file/resource-input.js"; const vscodeExtensionUrl = "https://marketplace.visualstudio.com/items?itemName=apimatic-developers.apimatic-for-vscode"; @@ -26,20 +27,23 @@ export class PortalQuickstartPrompts { log.step(message); } - public async specPathPrompt(defaultSpecUrl: UrlPath): Promise { + public async specPathPrompt(defaultSpecUrl: UrlPath): Promise { const spec = await text({ 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 a sample OpenAPI file from APIMatic.", - defaultValue: defaultSpecUrl.toString() + defaultValue: defaultSpecUrl.toString(), + validate: value => { + if (!value || !createResourceInputFromInput(value)) { + return "Please enter a valid file path or URL."; + } + } }); if (isCancel(spec)) { return undefined; } - const cleanedPath = removeQuotes(spec.trim() ?? ""); - - return cleanedPath; + return createResourceInputFromInput(spec); } public specFileDoesNotExist() { diff --git a/src/types/file/filePath.ts b/src/types/file/filePath.ts index edffb7b95..c7b8fdbf8 100644 --- a/src/types/file/filePath.ts +++ b/src/types/file/filePath.ts @@ -18,4 +18,21 @@ export class FilePath { public toString(): string { return path.join(this.directoryPath.toString(), this.fileName.toString()); } -} \ No newline at end of file + + public static create(filePath: string): FilePath | undefined { + if (!filePath) { + return undefined; + } + + try { + const normalizedPath = path.normalize(filePath); + const directory = path.dirname(normalizedPath); + const filename = path.basename(normalizedPath); + const directoryPath = new DirectoryPath(directory); + const fileName = new FileName(filename); + return new FilePath(directoryPath, fileName); + } catch { + return undefined; + } + } +} diff --git a/src/types/file/resource-input.ts b/src/types/file/resource-input.ts index e108bb5b6..2096c3ab4 100644 --- a/src/types/file/resource-input.ts +++ b/src/types/file/resource-input.ts @@ -3,6 +3,7 @@ import { DirectoryPath } from "./directoryPath.js"; import { FileName } from "./fileName.js"; import { FilePath } from "./filePath.js"; import { UrlPath } from "./urlPath.js"; +import { removeQuotes } from "../../utils/string-utils.js"; export type ResourceInput = FilePath | UrlPath; @@ -29,3 +30,16 @@ export const createResourceInput = (file?: string, url?: string): ResourceInput } throw new Error("Must specify either file or url."); }; + +export function createResourceInputFromInput(path: string): ResourceInput | undefined { + const sanitizedPath = removeQuotes(path.trim() ?? "") + const urlPath = UrlPath.create(sanitizedPath); + if (urlPath) { + return urlPath; + } + const filePath = FilePath.create(sanitizedPath); + if (filePath) { + return filePath; + } + return undefined; +} From 72241c387d329ff238069c15244f62a20bb57559 Mon Sep 17 00:00:00 2001 From: Muhammad Sohail <62895181+sohail2721@users.noreply.github.com> Date: Wed, 3 Sep 2025 14:06:23 +0500 Subject: [PATCH 56/77] feat: prompt framework for toc and recipe (#187) Co-authored-by: Shield-Jaguar --- src/actions/portal/recipe/new-recipe.ts | 423 +++++++----------- src/actions/portal/toc/new-toc.ts | 270 +++++------ .../portal/recipe/portal-recipe.ts | 26 +- .../portal/recipe/recipe-generator.ts | 69 +-- src/application/portal/toc/sdl-parser.ts | 134 ------ src/commands/portal/recipe/new.ts | 81 ++-- src/commands/portal/toc/new.ts | 99 ++-- src/infrastructure/file-service.ts | 19 + src/infrastructure/services/portal-service.ts | 24 +- src/prompts/format.ts | 3 +- src/prompts/portal/copilot.ts | 2 +- src/prompts/portal/recipe/new-recipe.ts | 210 ++++----- src/prompts/portal/toc/new-toc.ts | 73 ++- src/types/build-context.ts | 3 +- src/types/build/build.ts | 3 + src/types/file/directory.ts | 52 +++ src/types/file/directoryPath.ts | 8 +- src/types/file/file.ts | 21 + src/types/file/fileName.ts | 14 + src/types/recipe-context.ts | 30 ++ src/types/recipe/recipe.ts | 21 +- src/types/sdl/sdl.ts | 56 ++- src/types/spec-context.ts | 8 +- src/types/temp-context.ts | 27 +- src/types/toc-context.ts | 35 ++ src/utils/utils.ts | 95 ++++ 26 files changed, 920 insertions(+), 886 deletions(-) delete mode 100644 src/application/portal/toc/sdl-parser.ts create mode 100644 src/types/file/directory.ts create mode 100644 src/types/file/file.ts create mode 100644 src/types/recipe-context.ts create mode 100644 src/types/toc-context.ts diff --git a/src/actions/portal/recipe/new-recipe.ts b/src/actions/portal/recipe/new-recipe.ts index 4731f2812..616ba4b3e 100644 --- a/src/actions/portal/recipe/new-recipe.ts +++ b/src/actions/portal/recipe/new-recipe.ts @@ -1,269 +1,175 @@ -import * as path from "path"; -import fs from "fs"; -import fsExtra from "fs-extra"; -import { parse } from "yaml"; -import { TreeObject } from "treeify"; -import { tmpdir } from "os"; -import { execa } from "execa"; import { PortalRecipePrompts } from "../../../prompts/portal/recipe/new-recipe.js"; -import { SerializableRecipe, StepType, DirectoryNode } from "../../../types/recipe/recipe.js"; -import { Result } from "../../../types/common/result.js"; -import { PortalRecipe } from "../../../application/portal/recipe/portal-recipe.js"; -import { PortalRecipeGenerator } from "../../../application/portal/recipe/recipe-generator.js"; -import { SdlParser } from "../../../application/portal/toc/sdl-parser.js"; +import { DirectoryNode, StepType } from "../../../types/recipe/recipe.js"; import { PortalService } from "../../../infrastructure/services/portal-service.js"; -import { SdlEndpoint } from "../../../types/sdl/sdl.js"; +import { Sdl, SdlEndpoint } from "../../../types/sdl/sdl.js"; import { DirectoryPath } from "../../../types/file/directoryPath.js"; import { CommandMetadata } from "../../../types/common/command-metadata.js"; +import { ActionResult } from "../../action-result.js"; +import { TocContext } from "../../../types/toc-context.js"; +import { FileName } from "../../../types/file/fileName.js"; +import { PortalRecipe } from "../../../application/portal/recipe/portal-recipe.js"; +import { PortalRecipeGenerator } from "../../../application/portal/recipe/recipe-generator.js"; +import { TreeObject } from "treeify"; +import { BuildContext } from "../../../types/build-context.js"; +import { ContentContext } from "../toc/new-toc.js"; +import { SpecContext } from "../../../types/spec-context.js"; +import { LauncherService } from "../../../infrastructure/launcher-service.js"; +import { FileService } from "../../../infrastructure/file-service.js"; +import { FilePath } from "../../../types/file/filePath.js"; +import { withDirPath } from "../../../infrastructure/tmp-extensions.js"; +import { TempContext } from "../../../types/temp-context.js"; +import { RecipeContext } from "../../../types/recipe-context.js"; export class PortalRecipeAction { private readonly prompts: PortalRecipePrompts = new PortalRecipePrompts(); - private readonly sdlParser: SdlParser; - private readonly BUILD_FILE_NAME: string = "APIMATIC-BUILD.json" as const; + private readonly launcherService = new LauncherService(); + private readonly fileService = new FileService(); + private readonly portalService = new PortalService(); - constructor(configDirectory: DirectoryPath, commandMetadata: CommandMetadata) { - this.sdlParser = new SdlParser(new PortalService(), configDirectory, commandMetadata); - } + constructor(private readonly configDirectory: DirectoryPath, private readonly commandMetadata: CommandMetadata) {} - public async createRecipe(buildDirectoryPath: DirectoryPath, name?: string): Promise> { + public async execute(buildDirectory: DirectoryPath, name?: string): Promise { this.prompts.displayWelcomeMessage(); - const recipeName = name ?? (await this.prompts.recipeNamePrompt()); - const recipeFileName = this.createRecipeFileName(recipeName); - - const validateBuildDirectoryPathResult = await this.validateBuildDirectoryPath(buildDirectoryPath); - if (validateBuildDirectoryPathResult.isFailed()) { - return Result.failure( - `Unable to locate a valid "src" directory. Navigate to the directory containing your APIMatic Portal source or set up a new project by running \`apimatic portal quickstart\`.` - ); + const buildContext = new BuildContext(buildDirectory); + if (!(await buildContext.validate())) { + this.prompts.invalidBuildDirectory(buildDirectory); + return ActionResult.failed(); } - //TODO: Create a type for the build config and use that here instead of any. - const buildConfigFilePath = await this.getBuildConfigFilePath(buildDirectoryPath); - const buildConfigResult = await this.parseBuildConfig(buildConfigFilePath); - if (buildConfigResult.isFailed()) { - return Result.failure(`Unable to generate API Recipe: ${buildConfigResult.error!}`); + const recipeName = name ?? (await this.prompts.recipeNamePrompt()); + if (!recipeName) { + this.prompts.recipeNameEmpty(); + return ActionResult.cancelled(); } - const contentFolderPath = this.getContentFolderPath(buildConfigResult.value, buildDirectoryPath); - const tocFilePath = path.join(contentFolderPath, "content", "toc.yml"); - //TODO: Replace any type of tocFileResult.value to concrete type. - const tocFileResult = await this.parseTocFile(tocFilePath); - if (tocFileResult.isFailed()) { - return Result.failure(`Unable to generate API Recipe: ${tocFileResult.error!}`); - } + const contentDirectory = buildDirectory.join("content"); + const contentContext = new ContentContext(contentDirectory); - const recipeAlreadyExists = this.checkRecipeAlreadyExists(tocFileResult.value, recipeName, recipeFileName); - if (recipeAlreadyExists && !(await this.prompts.overwriteApiRecipeInTocPrompt())) { - return Result.cancelled("Operation was cancelled by the user."); + if (!(await contentContext.exists())) { + this.prompts.contentFolderNotFound(); + return ActionResult.failed(); } - const recipeResult = await this.promptUserAndBuildNewRecipe(buildConfigResult.value, contentFolderPath, recipeName); - if (recipeResult.isFailed()) { - return Result.failure(`Unable to generate API Recipe: ${recipeResult.error!}`); - } + const specDirectory = buildDirectory.join("spec"); + const specContext = new SpecContext(specDirectory); - const recipeGenerator = new PortalRecipeGenerator(); - await recipeGenerator.createRecipe( - recipeResult.value!, - buildConfigResult.value!, - tocFileResult.value, - tocFilePath, - recipeName, - recipeFileName, - buildConfigFilePath, - contentFolderPath - ); + if (!(await specContext.validate())) { + this.prompts.specFileEmptyInvalid(); + return ActionResult.failed(); + } - const buildDirectoryStructure = await this.getBuildDirectoryStructure(recipeFileName); + // Setup TOC context + const tocContext = new TocContext(contentDirectory); + const tocData = await tocContext.parseTocData(); - this.prompts.displayBuildDirectoryStructureAsTree(buildDirectoryStructure as TreeObject); - this.prompts.displayRecipeGenerationSuccessMessage(contentFolderPath); - return Result.success("Generated recipe successfully."); - } + // Setup recipe context + const recipeContext = new RecipeContext(recipeName); + const recipeFileName = recipeContext.getRecipeName(); - private createRecipeFileName(recipeName: string): string { - return this.toPascalCase(recipeName.trim()); - } + // Check if the recipe already exists + const recipeAlreadyExists = recipeContext.exists(tocData, recipeName, recipeFileName); + if (recipeAlreadyExists && !(await this.prompts.overwriteApiRecipeInTocPrompt(recipeName))) { + return ActionResult.cancelled(); + } - private toPascalCase(str: string): string { - return str - .split(" ") - .map((word) => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase()) - .join(""); - } + // Build the recipe + const recipe = new PortalRecipe(recipeName); - private async validateBuildDirectoryPath(buildDirectoryPath: DirectoryPath): Promise> { - if (!(await fsExtra.pathExists(buildDirectoryPath.toString()))) { - return Result.failure(`Portal build input folder ${buildDirectoryPath.toString()} does not exist.`); - } + let stepIndex: number = 0; - return Result.success("Portal build input folder path validated successfully."); - } + this.prompts.displayStepsInformation(); - //TODO: Figure out a better way to do this without the while loop. - private async promptUserAndBuildNewRecipe( - buildConfig: any, - contentFolderPath: string, - recipeName: string - ): Promise> { - const recipe = new PortalRecipe(recipeName); let endpointGroups: Map | undefined; - let idx: number = 1; - let addAnotherStep = true; - this.prompts.displayStepsInformation(); - while (addAnotherStep) { + + do { const stepType = await this.prompts.stepTypeSelectionPrompt(); - const stepName = await this.prompts.stepNamePrompt("Step " + idx); + if (!stepType) return ActionResult.cancelled(); + + const stepName = await this.prompts.stepNamePrompt(`Step ${++stepIndex}`); + if (!stepName) return ActionResult.cancelled(); + switch (stepType) { case StepType.Content: { - const addContentStepResult = await this.promptUserAndAddContentStepToRecipe(recipe, stepName); - if (addContentStepResult.isFailed()) { - return Result.failure(addContentStepResult.error!); - } + const contentResult = await this.promptForContent(); + recipe.addContentStep(stepName, contentResult); + this.prompts.displayStepAddedSuccessfullyMessage(); break; } + case StepType.Endpoint: { if (!endpointGroups) { - const extractEndpointGroupsFromSdlResult = await this.extractEndpointGroupsFromSdl( - buildConfig, - contentFolderPath - ); - if (extractEndpointGroupsFromSdlResult.isFailed()) { - return Result.failure(`${extractEndpointGroupsFromSdlResult.error!}`); + const sdlResult = await withDirPath(async (tempDirectory) => { + const tempContext = new TempContext(tempDirectory); + const specZipPath = await tempContext.zip(specDirectory); + + const specFileStream = await this.fileService.getStream(specZipPath); + + try { + const sdlResult = await this.portalService.generateSdl( + specFileStream, + this.configDirectory, + this.commandMetadata + ); + + return sdlResult; + } finally { + specFileStream.close(); + } + }); + + if (sdlResult.isErr()) { + this.prompts.serviceError(sdlResult.error); + return ActionResult.failed(); } - endpointGroups = extractEndpointGroupsFromSdlResult.value!; + endpointGroups = PortalRecipeAction.getEndpointGroupsFromSdl(sdlResult.value); } - await this.promptUserAndAddEndpointStepToRecipe(recipe, endpointGroups, stepName); - break; - } - } - addAnotherStep = await this.prompts.addAnotherStepSelectionPrompt(); - idx++; - } - return Result.success(recipe.toSerializableRecipe()); - } - - private async promptUserAndAddContentStepToRecipe( - recipe: PortalRecipe, - stepName: string - ): Promise> { - this.prompts.displayContentStepInfo(); - let editor = process.env.EDITOR; - let editorArgs: string[] = []; - const tempFilePath = path.join(tmpdir(), `recipe-markdown-content-${Date.now()}.txt`); - const template = `# The Heading Goes Here\n\nThis is placeholder text for your API Recipe content step. Feel free to edit this. Save your changes and then close the file once you're done.`; - await fsExtra.writeFile(tempFilePath, template, "utf-8"); - - try { - if (!editor) { - if (process.platform === "win32") { - await execa("cmd", ["/c", "start", "/wait", "notepad", tempFilePath], { stdio: "ignore" }); - } else if (process.platform === "darwin" || process.platform === "linux") { - editor = "vim"; - try { - await execa(editor, [tempFilePath], { stdio: "inherit" }); - } catch { - // User exiting vim can throw a non-zero exit code leading to exception, ignore it. - } - } - } else { - if (editor === "code" || editor.endsWith("code.cmd") || editor.endsWith("code.exe")) { - editorArgs.push("--wait"); + const endpointGroupName = await this.prompts.endpointGroupNamePrompt(endpointGroups); + if (!endpointGroupName) return ActionResult.cancelled(); + const endpointName = await this.prompts.endpointNamePrompt(endpointGroups, endpointGroupName); + if (!endpointName) return ActionResult.cancelled(); + + const defaultDescription = PortalRecipeAction.getEndpointDescription( + endpointGroups, + endpointGroupName, + endpointName + ); + const description = await this.prompts.endpointDescriptionPrompt(defaultDescription); + if (!description) return ActionResult.cancelled(); + + recipe.addEndpointStep(stepName, description, endpointGroupName, endpointName); + this.prompts.displayStepAddedSuccessfullyMessage(); + break; } - editorArgs.push(tempFilePath); - await execa(editor, editorArgs, { stdio: "ignore" }); } + } while (await this.prompts.addAnotherStepSelectionPrompt()); - const fileContent = await fsExtra.readFile(tempFilePath, "utf-8"); - recipe.addContentStep(stepName, stepName, fileContent); + const serializableRecipe = recipe.toSerializableRecipe(); + const buildConfig = await buildContext.getBuildFileContents(); - this.prompts.displayStepAddedSuccessfullyMessage(); - return Result.success("Added content step successfully."); - } catch { - return Result.failure(`Unable to add content step. Please try again later.`); - } finally { - await fsExtra.unlink(tempFilePath); - } - } - - private async promptUserAndAddEndpointStepToRecipe( - recipe: PortalRecipe, - endpointGroups: Map, - stepName: string - ): Promise { - const endpointGroupName = await this.prompts.endpointGroupNamePrompt(endpointGroups); - const endpointName = await this.prompts.endpointNamePrompt(endpointGroups, endpointGroupName); - const description = await this.prompts.endpointDescriptionPrompt(endpointGroups, endpointGroupName, endpointName); - const endpointPermalink = await this.createPermalink([endpointGroupName, endpointName]); - recipe.addEndpointStep(stepName, stepName, description, endpointPermalink); - this.prompts.displayStepAddedSuccessfullyMessage(); - } - - //TODO: Replace any with concrete toc file object. - private async parseTocFile(tocFilePath: string): Promise> { - // Check if the file exists - if (!fs.existsSync(tocFilePath)) { - return Result.failure( - `toc.yml file not found at ${tocFilePath}. Please run 'apimatic:toc:new' to create your toc.yml file first.` - ); - } - - try { - const tocContent = await fs.promises.readFile(tocFilePath, "utf-8"); - return Result.success(parse(tocContent)); - } catch { - return Result.failure( - `Unable to parse the toc.yml file located at ${tocFilePath}. Please make sure that the toc.yml is a valid YAML file.` - ); - } - } - - private async createPermalink(pathPieces: string[]): Promise { - return `$e/${pathPieces.map(encodeURIComponent).join("/")}`; - } - - private async getBuildConfigFilePath(buildDirectoryPath: DirectoryPath): Promise { - const files = await fs.promises.readdir(buildDirectoryPath.toString()); - const buildFileExists = files.find((file) => file === this.BUILD_FILE_NAME); - if (!buildFileExists) { - return await this.prompts.buildConfigFilePathPrompt(buildDirectoryPath.toString()); - } - - return path.join(buildDirectoryPath.toString(), this.BUILD_FILE_NAME); - } - - //TODO: Create a type for the build config and use that here instead of any. - private async parseBuildConfig(buildConfigFilePath: string): Promise> { - try { - const fileData = await fs.promises.readFile(buildConfigFilePath, "utf-8"); - return Result.success(JSON.parse(fileData)); - } catch { - return Result.failure( - `There was an error parsing the build config file located at "${buildConfigFilePath}". Please check your build config file and try again later.` - ); - } - } - - private checkRecipeAlreadyExists(tocData: any, recipeName: string, recipeFileName: string): boolean { - let apiRecipesGroup = tocData.toc?.find((item: any) => item.group === "API Recipes"); - if (!apiRecipesGroup) { - return false; - } - - // Check if recipe name or file name already exists - const existingRecipe = apiRecipesGroup.items.find( - (item: any) => item.page === recipeName || item.file === `recipes/${recipeFileName}.md` + // Generate the recipe + const recipeGenerator = new PortalRecipeGenerator(); + await recipeGenerator.createRecipe( + serializableRecipe, + buildConfig, + tocData, + tocContext.tocPath, + recipeName, + recipeFileName, + buildContext.BuildFile, + contentDirectory ); - if (existingRecipe) { - return true; - } - return false; + // Display build directory structure + const buildDirectoryStructure = this.getBuildDirectoryStructure(recipeFileName.toString()); + this.prompts.displayBuildDirectoryStructureAsTree(buildDirectoryStructure as TreeObject); + + return ActionResult.success(); } - private async getBuildDirectoryStructure(recipeFileName: string): Promise { + // TODO: Refactor this after quick start merge + private getBuildDirectoryStructure(recipeFileName: string): DirectoryNode { return { content: { "toc.yml : # Contains the API Recipes group with a new page for your API recipe": null @@ -278,50 +184,43 @@ export class PortalRecipeAction { }; } - //TODO: Replace type of buildConfig from any to actual BuildConfig type after creating it. - private async extractEndpointGroupsFromSdl( - buildConfig: any, - contentFolderPath: string - ): Promise, string>> { - const specFolderPath = this.getSpecFolderPath(buildConfig, contentFolderPath); - if (!(await fsExtra.pathExists(specFolderPath))) { - return Result.failure(`API specification file not found at ${specFolderPath}.`); - } - - this.prompts.startProgressIndicatorWithMessage( - "Extracting endpoint groups and endpoints from the API specification." - ); - - const endpointGroupsResult = await this.sdlParser.getEndpointGroupsFromSdl(specFolderPath); - - if (endpointGroupsResult.isFailed()) { - this.prompts.stopProgressIndicatorWithMessage("Unable to extract endpoints from your API specification."); - return Result.failure(`${endpointGroupsResult.error!}`); - } - - this.prompts.stopProgressIndicatorWithMessage( - "Successfully extracted endpoint groups and endpoints from the API specification." - ); - return Result.success(endpointGroupsResult.value!); - } + private static getEndpointGroupsFromSdl(sdl: Sdl): Map { + const endpointGroups = new Map(); + for (const endpoint of sdl.Endpoints) { + if (!endpointGroups.has(endpoint.Group)) { + endpointGroups.set(endpoint.Group, []); + } - //TODO: Replace type of buildConfig from any to actual BuildConfig type after creating it. - private getSpecFolderPath(buildConfig: any, contentFolderPath: string): string { - const apiSpecPath = buildConfig.generatePortal?.apiSpecPath; - if (apiSpecPath) { - return path.join(contentFolderPath, apiSpecPath); + endpointGroups.get(endpoint.Group)!.push({ + Name: endpoint.Name, + Description: endpoint.Description, + Group: endpoint.Group + }); } - - return path.join(contentFolderPath, "spec"); + return endpointGroups; } - //TODO: Replace type of buildConfig from any to actual BuildConfig type after creating it. - private getContentFolderPath(buildConfig: any, buildDirectoryPath: DirectoryPath): string { - const contentFolder = buildConfig.generatePortal?.contentFolder; - if (contentFolder) { - return path.join(buildDirectoryPath.toString(), contentFolder); - } - - return buildDirectoryPath.toString(); + private static getEndpointDescription( + endpointGroups: Map, + endpointGroupName: string, + endpointName: string + ): string { + return endpointGroups.get(endpointGroupName)!.find((e) => e.Name === endpointName)!.Description; + } + + private async promptForContent(): Promise { + return await withDirPath(async (tempDir) => { + const tempFile = new FilePath(tempDir, new FileName(`recipe-markdown-content.md`)); + const defaultContent = + "# The Heading Goes Here\n\n" + + "This is placeholder text for your API Recipe content step. " + + "Feel free to edit this. Save your changes and then close the file once you're done."; + + await this.fileService.writeContents(tempFile, defaultContent); + await this.launcherService.openInEditor(tempFile); + this.prompts.openRecipeMarkdownEditor(); + const fileContent = await this.fileService.getContents(tempFile); + return fileContent.replace(/\r\n|\r/g, "\n"); + }); } } diff --git a/src/actions/portal/toc/new-toc.ts b/src/actions/portal/toc/new-toc.ts index 9ba21d25d..50f8c92ea 100644 --- a/src/actions/portal/toc/new-toc.ts +++ b/src/actions/portal/toc/new-toc.ts @@ -1,182 +1,154 @@ -import * as path from "path"; -import fsExtra from "fs-extra"; +import { err, ok, Result } from "neverthrow"; import { PortalNewTocPrompts } from "../../../prompts/portal/toc/new-toc.js"; -import { Result } from "../../../types/common/result.js"; -import { getMessageInRedColor } from "../../../utils/utils.js"; -import { SdlParser } from "../../../application/portal/toc/sdl-parser.js"; import { TocStructureGenerator } from "../../../application/portal/toc/toc-structure-generator.js"; -import { TocContentParser } from "../../../application/portal/toc/toc-content-parser.js"; import { TocEndpoint, TocGroup, TocModel } from "../../../types/toc/toc.js"; -import { PortalService } from "../../../infrastructure/services/portal-service.js"; import { DirectoryPath } from "../../../types/file/directoryPath.js"; +import { CommandMetadata } from "../../../types/common/command-metadata.js"; +import { ActionResult } from "../../action-result.js"; +import { TocContext } from "../../../types/toc-context.js"; +import { FileService } from "../../../infrastructure/file-service.js"; import { FilePath } from "../../../types/file/filePath.js"; -import { FileName } from "../../../types/file/fileName.js"; import { BuildContext } from "../../../types/build-context.js"; -import { CommandMetadata } from "../../../types/common/command-metadata.js"; - -export class PortalNewTocAction { - private readonly prompts: PortalNewTocPrompts; - private readonly sdlParser: SdlParser; - private readonly tocGenerator: TocStructureGenerator; - private readonly contentParser: TocContentParser; - private readonly DEFAULT_TOC_FILENAME: string = "toc.yml" as const; - private readonly APIMATIC_BUILD_FILENAME: string = "APIMATIC-BUILD.json" as const; - - constructor(configDirectory: DirectoryPath, commandMetadata: CommandMetadata) { - this.prompts = new PortalNewTocPrompts(); - this.sdlParser = new SdlParser(new PortalService(), configDirectory, commandMetadata); - this.tocGenerator = new TocStructureGenerator(); - this.contentParser = new TocContentParser(); - } - - public async createToc( - buildDirectory: DirectoryPath, - tocDirectory?: DirectoryPath, - force: boolean = false, - expandEndpoints: boolean = false, - expandModels: boolean = false - ): Promise> { - try { - const tocDir = await this.getDestinationPath(buildDirectory, tocDirectory); - const tocPath = new FilePath(tocDir, new FileName(this.DEFAULT_TOC_FILENAME)); - const tocCheckResult = await this.handleExistingToc(tocPath, force); - if (!tocCheckResult.isSuccess()) { - return Result.cancelled(tocCheckResult.value!); - } - - const { endpointGroups, models } = await this.extractSdlComponents(buildDirectory, expandEndpoints, expandModels); - - const contentGroups = await this.extractContentGroups(buildDirectory); - - const toc = this.tocGenerator.createTocStructure( - endpointGroups, - models, - expandEndpoints, - expandModels, - contentGroups - ); - const yamlString = this.tocGenerator.transformToYaml(toc); - await this.writeToc(tocPath.toString(), yamlString, "utf8"); +import { getEndpointGroupsAndModels } from "../../../types/sdl/sdl.js"; +import { withDirPath } from "../../../infrastructure/tmp-extensions.js"; +import { TempContext } from "../../../types/temp-context.js"; +import { PortalService } from "../../../infrastructure/services/portal-service.js"; - this.prompts.displayOutroMessage(tocPath); - return Result.success(tocPath.toString()); - } catch (error) { - this.prompts.logError(getMessageInRedColor(`${(error as Error).message}`)); - return Result.failure(`An unexpected error occurred while generating the TOC file.`); - } - } +export class ContentContext { + private readonly fileService = new FileService(); - private async writeToc(path: string, content: string, encoding: string) { - await fsExtra.ensureFile(path); - await fsExtra.writeFile(path, content, encoding); - } + constructor(private readonly contentDirectory: DirectoryPath) {} - private async handleExistingToc(tocPath: FilePath, force: boolean): Promise> { - const shouldContinue = await this.checkExistingToc(tocPath, force); - if (!shouldContinue) { - return Result.cancelled("Operation was cancelled by the user."); - } - return Result.success("TOC check passed."); + public async exists(): Promise { + return this.fileService.directoryExists(this.contentDirectory); } - private async extractSdlComponents( - buildDirectory: DirectoryPath, - expandEndpoints: boolean, - expandModels: boolean - ): Promise<{ endpointGroups: Map; models: TocModel[] }> { - if (!expandEndpoints && !expandModels) { - return { endpointGroups: new Map(), models: [] }; - } - - this.prompts.startProgressIndicatorWithMessage("Extracting endpoints and/or models from the API specification..."); - const specFolderPath = await this.getSpecFolderPath(buildDirectory); - - if (!(await fsExtra.pathExists(specFolderPath))) { - this.prompts.stopProgressIndicatorWithMessage(`⚠️ Could not find the specification folder at: ${specFolderPath}`); - this.prompts.displayInfo("Falling back to default TOC structure without expanded endpoints or models..."); - return { endpointGroups: new Map(), models: [] }; - } - - const sdlResult = await this.sdlParser.getTocComponentsFromSdl(specFolderPath); - - if (!sdlResult.isSuccess()) { - this.prompts.stopProgressIndicatorWithMessage(`⚠️ ${sdlResult.error!}`); - this.prompts.displayInfo("Falling back to default TOC structure without expanded endpoints or models..."); - return { endpointGroups: new Map(), models: [] }; - } - - this.prompts.stopProgressIndicatorWithMessage( - "Successfully extracted endpoints and/or models from the specification." - ); - return sdlResult.value!; + public async extractContentGroups(): Promise { + const directory = await this.fileService.getDirectory(this.contentDirectory); + return await directory.parseContentFolder(this.contentDirectory); } +} - private async extractContentGroups(buildDirectory: DirectoryPath): Promise { - const contentFolderPath = await this.getContentFolderPath(buildDirectory); +type SdlComponents = { + endpointGroups: Map; + models: TocModel[]; +}; - if (!(await fsExtra.pathExists(contentFolderPath.toString()))) { - this.prompts.displayInfo(`⚠️ Could not locate the content folder at: ${contentFolderPath}`); - this.prompts.displayInfo("Skipping custom content addition in TOC..."); - return []; - } +export class PortalNewTocAction { + private readonly prompts: PortalNewTocPrompts = new PortalNewTocPrompts(); + private readonly tocGenerator: TocStructureGenerator = new TocStructureGenerator(); + private readonly fileService = new FileService(); + private readonly portalService = new PortalService(); - return await this.contentParser.parseContentFolder(contentFolderPath.toString(), contentFolderPath.toString()); - } + constructor(private readonly configDirectory: DirectoryPath, private readonly commandMetadata: CommandMetadata) {} - private async getDestinationPath( + public async execute( buildDirectory: DirectoryPath, - providedTocDirectory?: DirectoryPath - ): Promise { - if (providedTocDirectory === undefined) { - const inferredDestination = await this.getContentFolderPath(buildDirectory); - return inferredDestination; - } - return providedTocDirectory; - } - - private async checkExistingToc(tocPath: FilePath, force: boolean): Promise { - if ((await fsExtra.pathExists(tocPath.toString())) && !force) { - return await this.prompts.overwriteExistingTocPrompt(tocPath); + tocDirectory?: DirectoryPath, + force: boolean = false, + expandEndpoints: boolean = false, + expandModels: boolean = false + ): Promise { + // Validate build directory + const buildContext = new BuildContext(buildDirectory); + if (!(await buildContext.validate())) { + this.prompts.invalidBuildDirectory(buildDirectory); + return ActionResult.failed(); } - return true; - } + const buildConfig = await buildContext.getBuildFileContents(); + const contentDirectory = buildDirectory.join(buildConfig.generatePortal?.contentFolder ?? "content"); - private async getContentFolderPath(buildDirectory: DirectoryPath): Promise { - const buildContext = new BuildContext(buildDirectory); - const defaultContentFolder = buildDirectory.join("content"); + const tocDir = tocDirectory ?? contentDirectory; + const tocContext = new TocContext(tocDir); - if (!(await buildContext.validate())) { - return defaultContentFolder; + if (!force && (await tocContext.exists()) && !(await this.prompts.overwriteToc(tocContext.tocPath))) { + this.prompts.tocFileAlreadyExists(); + return ActionResult.cancelled(); } - try { - const buildConfig = await buildContext.getBuildFileContents(); - if (buildConfig.generatePortal?.contentFolder == null) { - return defaultContentFolder; + let sdlComponents: SdlComponents = { endpointGroups: new Map(), models: [] }; + if (expandEndpoints || expandModels) { + const specDirectory = buildDirectory.join("spec"); + + if (!(await this.fileService.directoryExists(specDirectory))) { + this.prompts.fallingBackToDefault(); + } else { + const sdlResult = await withDirPath(async (tempDirectory) => { + const tempContext = new TempContext(tempDirectory); + const specZipPath = await tempContext.zip(specDirectory); + const specFileStream = await this.fileService.getStream(specZipPath); + try { + const result = await this.prompts.extractModels( + this.portalService.generateSdl(specFileStream, this.configDirectory, this.commandMetadata) + ); + if (result.isErr()) { + return err(result.error); + } + return ok(getEndpointGroupsAndModels(result.value)); + } finally { + specFileStream.close(); + } + }); + + if (sdlResult.isErr()) { + this.prompts.logError(sdlResult.error); + return ActionResult.failed(); + } + sdlComponents = sdlResult.value; } - return buildDirectory.join(buildConfig.generatePortal.contentFolder).join("content"); - } catch { - return defaultContentFolder; } - } + const contentContext = new ContentContext(contentDirectory); + const contentExists = await contentContext.exists(); + + let contentGroups: TocGroup[]; + if (!contentExists) { + this.prompts.contentDirectoryNotFound(contentDirectory); + contentGroups = []; + } else { + contentGroups = await contentContext.extractContentGroups(); + } - private async getSpecFolderPath(buildDirectory: DirectoryPath): Promise { - const buildFilePath = path.join(buildDirectory.toString(), this.APIMATIC_BUILD_FILENAME); - const defaultSpecFolder = path.join(buildDirectory.toString(), "spec"); + const tocResult = await this.prompts.generateTOC( + this.generateToc( + tocContext, + sdlComponents.endpointGroups, + sdlComponents.models, + expandEndpoints, + expandModels, + contentGroups + ) + ); - if (!(await fsExtra.pathExists(buildFilePath))) { - return defaultSpecFolder; + if (tocResult.isErr()) { + this.prompts.logError(tocResult.error); + return ActionResult.failed(); } + return ActionResult.success(); + } + private async generateToc( + tocContext: TocContext, + endpointGroups: Map, + models: TocModel[], + expandEndpoints: boolean, + expandModels: boolean, + contentGroups: TocGroup[] + ): Promise> { try { - const buildConfig = await fsExtra.readJson(buildFilePath, "utf8"); + const toc = this.tocGenerator.createTocStructure( + endpointGroups, + models, + expandEndpoints, + expandModels, + contentGroups + ); - if (buildConfig.generatePortal?.apiSpecPath == null) { - return defaultSpecFolder; - } - return path.join(buildDirectory.toString(), buildConfig.generatePortal.apiSpecPath); - } catch { - return defaultSpecFolder; + const yamlString = this.tocGenerator.transformToYaml(toc); + const tocFilePath = await tocContext.save(yamlString); + + return ok(tocFilePath); + } catch (error) { + return err(`Failed to generate TOC: ${(error as Error).message}`); } } } diff --git a/src/application/portal/recipe/portal-recipe.ts b/src/application/portal/recipe/portal-recipe.ts index 46e7e7ba0..b34c0a3b7 100644 --- a/src/application/portal/recipe/portal-recipe.ts +++ b/src/application/portal/recipe/portal-recipe.ts @@ -1,4 +1,4 @@ -import { SerializableRecipe } from "../../../types/recipe/recipe.js"; +import { SerializableRecipe, StepType } from "../../../types/recipe/recipe.js"; export class PortalRecipe { private readonly recipe: SerializableRecipe; @@ -10,35 +10,29 @@ export class PortalRecipe { }; } - addContentStep(key: string, name: string, content: string): this { + addContentStep(key: string, content: string) { this.recipe.steps.push({ key, - name, - type: 'content', + name: key, //TODO: Check if key is required + type: StepType.Content, config: { content } }); - return this; } - addEndpointStep( - key: string, - name: string, - description: string, - endpointPermalink: string - ): this { + addEndpointStep(key: string, description: string, endpointGroupName: string, endpointName: string) { + const endpointPermalink = `$e/${[endpointGroupName, endpointName].map(encodeURIComponent).join("/")}`; this.recipe.steps.push({ key, - name, - type: 'endpoint', + name: key, //TODO: Check if key is required + type: StepType.Endpoint, config: { description, - endpointPermalink, + endpointPermalink } }); - return this; } toSerializableRecipe(): SerializableRecipe { return this.recipe; } -} \ No newline at end of file +} diff --git a/src/application/portal/recipe/recipe-generator.ts b/src/application/portal/recipe/recipe-generator.ts index c2f64d8b9..59b73c4d0 100644 --- a/src/application/portal/recipe/recipe-generator.ts +++ b/src/application/portal/recipe/recipe-generator.ts @@ -1,27 +1,33 @@ -import * as path from "path"; -import fs from "fs"; import prettier from "prettier"; import { stringify } from "yaml"; import { SerializableRecipe, ContentStepConfig, EndpointStepConfig } from "../../../types/recipe/recipe.js"; +import { DirectoryPath } from "../../../types/file/directoryPath.js"; +import { FileService } from "../../../infrastructure/file-service.js"; +import { BuildConfig } from "../../../types/build/build.js"; +import { Toc } from "../../../types/toc/toc.js"; +import { FilePath } from "../../../types/file/filePath.js"; +import { FileName } from "../../../types/file/fileName.js"; export class PortalRecipeGenerator { //TODO: Replace tocFileContent any type with concrete type. + private readonly fileService: FileService = new FileService(); + public async createRecipe( recipe: SerializableRecipe, - buildConfig: any, - tocFileContent: any, - tocFilePath: string, + buildConfig: BuildConfig, + tocFileContent: Toc, + tocFilePath: FilePath, recipeName: string, - recipeFileName: string, - buildConfigFilePath: string, - contentFolderPath: string + recipeFileName: FileName, + buildConfigFilePath: FilePath, + contentFolderPath: DirectoryPath ) { await this.addRecipeToToc(tocFileContent, tocFilePath, recipeName, recipeFileName); await this.registerRecipeInBuildConfigFile(buildConfig, recipeName, recipeFileName, buildConfigFilePath); await this.createMarkdownFile(recipeFileName, contentFolderPath); const generatedRecipeScript = await this.createScriptFromRecipe(recipe); - const generatedRecipeScriptsDirectoryPath = path.join(contentFolderPath, "static", "scripts", "recipes"); + const generatedRecipeScriptsDirectoryPath = contentFolderPath.join("static", "scripts", "recipes"); await this.saveGeneratedRecipeScriptToBuildDirectory( generatedRecipeScript, generatedRecipeScriptsDirectoryPath, @@ -30,12 +36,12 @@ export class PortalRecipeGenerator { } private async addRecipeToToc( - tocData: any, - tocFilePath: string, + tocData: Toc, + tocFilePath: FilePath, recipeName: string, - recipeFileName: string + recipeFileName: FileName ): Promise { - let toc = tocData.toc; + let toc = tocData.toc as any; let apiRecipesGroup = toc.find((item: any) => item.group === "API Recipes"); if (!apiRecipesGroup) { @@ -61,20 +67,20 @@ export class PortalRecipeGenerator { page: recipeName, file: `recipes/${recipeFileName}.md` }); - await fs.promises.writeFile(tocFilePath, stringify(tocData)); + await this.fileService.writeContents(tocFilePath, stringify(tocData)); } } private async registerRecipeInBuildConfigFile( - buildConfig: any, + buildConfig: BuildConfig, recipeName: string, - recipeFileName: string, - buildConfigFilePath: string + recipeFileName: FileName, + buildConfigFilePath: FilePath ): Promise { if (!buildConfig.recipes) { buildConfig.recipes = {}; } - const recipesConfig = buildConfig.recipes; + const recipesConfig = buildConfig.recipes as any; if (!recipesConfig.workflows) { recipesConfig.workflows = []; @@ -101,21 +107,22 @@ export class PortalRecipeGenerator { await this.writeRecipesConfigToBuildConfigFile(recipesConfig, buildConfigFilePath); } - private async writeRecipesConfigToBuildConfigFile(recipesConfig: string, buildConfigFilePath: string): Promise { - const fileData = await fs.promises.readFile(buildConfigFilePath, "utf-8"); + private async writeRecipesConfigToBuildConfigFile(recipesConfig: string, buildConfigFilePath: FilePath): Promise { + const fileData = await this.fileService.getContents(buildConfigFilePath); const buildConfig = JSON.parse(fileData); buildConfig.recipes = recipesConfig; - await fs.promises.writeFile(buildConfigFilePath, JSON.stringify(buildConfig, null, 2)); + await this.fileService.writeContents(buildConfigFilePath, JSON.stringify(buildConfig, null, 2)); } - private async createMarkdownFile(recipeFileName: string, contentFolder: string): Promise { - const directory = path.join(contentFolder, "content", "recipes"); + private async createMarkdownFile(recipeFileName: FileName, contentFolder: DirectoryPath): Promise { + const directory = contentFolder.join("recipes"); const markdownFileContent = this.getMarkdownFileContent(); - fs.mkdirSync(directory, { recursive: true }); - fs.writeFileSync(`${directory}/${recipeFileName}.md`, markdownFileContent); + await this.fileService.directoryExists(directory); + this.fileService.writeContents(new FilePath(directory, new FileName(`${recipeFileName}.md`)), markdownFileContent); + } private async createScriptFromRecipe(recipe: SerializableRecipe): Promise { @@ -149,20 +156,16 @@ export class PortalRecipeGenerator { private async saveGeneratedRecipeScriptToBuildDirectory( generatedRecipeScript: string, - generatedRecipeScriptsDirectoryPath: string, - recipeFileName: string, + generatedRecipeScriptsDirectoryPath: DirectoryPath, + recipeFileName: FileName, format: boolean = false ): Promise { if (format) { generatedRecipeScript = await this.formatScript(generatedRecipeScript); } - fs.mkdirSync(generatedRecipeScriptsDirectoryPath, { recursive: true }); - await fs.promises.writeFile( - `${generatedRecipeScriptsDirectoryPath}/${recipeFileName}.js`, - generatedRecipeScript, - "utf8" - ); + this.fileService.createDirectoryIfNotExists(generatedRecipeScriptsDirectoryPath); + await this.fileService.writeContents(new FilePath(generatedRecipeScriptsDirectoryPath, new FileName(`${recipeFileName}.js`)), generatedRecipeScript); } private generateVerifyFunction(): string { diff --git a/src/application/portal/toc/sdl-parser.ts b/src/application/portal/toc/sdl-parser.ts deleted file mode 100644 index d068bc1d9..000000000 --- a/src/application/portal/toc/sdl-parser.ts +++ /dev/null @@ -1,134 +0,0 @@ -import { PortalService } from "../../../infrastructure/services/portal-service.js"; -import { TocEndpoint, TocModel } from "../../../types/toc/toc.js"; -import { Result } from "../../../types/common/result.js"; -import { Sdl, SdlEndpoint, SdlModel } from "../../../types/sdl/sdl.js"; -import { DirectoryPath } from "../../../types/file/directoryPath.js"; -import { withDirPath } from "../../../infrastructure/tmp-extensions.js"; -import { FileName } from "../../../types/file/fileName.js"; -import { ZipService } from "../../../infrastructure/zip-service.js"; -import { FilePath } from "../../../types/file/filePath.js"; -import { FileService } from "../../../infrastructure/file-service.js"; -import { CommandMetadata } from "../../../types/common/command-metadata.js"; - -export class SdlParser { - private readonly zipArchiver: ZipService = new ZipService(); - private readonly fileService = new FileService(); - private readonly configDirectory: DirectoryPath; - private readonly commandMetadata: CommandMetadata; - - constructor( - private readonly portalService: PortalService, - configDirectory: DirectoryPath, - commandMetadata: CommandMetadata - ) { - this.configDirectory = configDirectory; - this.commandMetadata = commandMetadata; - } - - public async getTocComponentsFromSdl( - specFolderPath: string - ): Promise; models: TocModel[] }, string>> { - const sdlResult = await this.generateSdlFromSpec(specFolderPath); - - if (!sdlResult.isSuccess()) { - return Result.failure( - "Failed to extract endpoints/models from the specification. Please validate your spec using APIMatic's interactive VS Code Extension and then try again." - ); - } - - const sdl: Sdl = sdlResult.value!; - const endpointGroups = this.extractEndpointGroupsForToc(sdl); - const models = this.extractModelsForToc(sdl); - - return Result.success({ endpointGroups, models }); - } - - public async getEndpointGroupsFromSdl(specFolderPath: string): Promise, string>> { - const sdlResult = await this.generateSdlFromSpec(specFolderPath); - - if (!sdlResult.isSuccess()) { - return Result.failure( - "Failed to extract endpoints from the API specification. Please validate your spec using APIMatic's interactive VS Code Extension and then try again." - ); - } - - const sdl: Sdl = sdlResult.value!; - const endpointGroups = this.extractEndpointGroupsForRecipe(sdl); - - return Result.success(endpointGroups); - } - - private async generateSdlFromSpec(specFolderPath: string): Promise> { - return await withDirPath(async (tempDirectory) => { - const specZipPath = new FilePath(tempDirectory, new FileName("spec.zip")); - await this.zipArchiver.archive(new DirectoryPath(specFolderPath), specZipPath); - - const specFileStream = await this.fileService.getStream(specZipPath); - let result: Result; - - try { - result = await this.portalService.generateSdl(specFileStream, this.configDirectory, this.commandMetadata); - } finally { - specFileStream.close(); - } - - if (!result.isSuccess()) { - return Result.failure( - "Failed to generate SDL from the API specification. Please validate your spec using APIMatic's interactive VS Code Extension." - ); - } - - return Result.success(result.value!); - }); - } - - private extractEndpointGroupsForRecipe(sdl: Sdl): Map { - const endpointGroups = new Map(); - for (const endpoint of sdl.Endpoints) { - if (!endpointGroups.has(endpoint.Group)) { - endpointGroups.set(endpoint.Group, []); - } - - endpointGroups.get(endpoint.Group)!.push({ - Name: endpoint.Name, - Description: endpoint.Description, - Group: endpoint.Group - }); - } - - return endpointGroups; - } - - private extractEndpointGroupsForToc(sdl: Sdl): Map { - const endpointGroups = new Map(); - - const endpoints = sdl.Endpoints.map( - (e: SdlEndpoint): TocEndpoint => ({ - generate: null, - from: "endpoint", - endpointName: e.Name, - endpointGroup: e.Group - }) - ); - - endpoints.forEach((endpoint: TocEndpoint) => { - const group = endpoint.endpointGroup; - if (!endpointGroups.has(group)) { - endpointGroups.set(group, []); - } - endpointGroups.get(group)!.push(endpoint); - }); - - return endpointGroups; - } - - private extractModelsForToc(sdl: Sdl): TocModel[] { - return sdl.CustomTypes.map( - (e: SdlModel): TocModel => ({ - generate: null, - from: "model", - modelName: e.Name - }) - ); - } -} diff --git a/src/commands/portal/recipe/new.ts b/src/commands/portal/recipe/new.ts index 4193257b0..00879b9b7 100644 --- a/src/commands/portal/recipe/new.ts +++ b/src/commands/portal/recipe/new.ts @@ -1,59 +1,68 @@ import { Command, Flags } from "@oclif/core"; import { PortalRecipeAction } from "../../../actions/portal/recipe/new-recipe.js"; -import { PortalRecipePrompts } from "../../../prompts/portal/recipe/new-recipe.js"; -import { getMessageInRedColor } from "../../../utils/utils.js"; import { TelemetryService } from "../../../infrastructure/services/telemetry-service.js"; import { RecipeCreationFailedEvent } from "../../../types/events/recipe-creation-failed.js"; import { DirectoryPath } from "../../../types/file/directoryPath.js"; import { FlagsProvider } from "../../../types/flags-provider.js"; import { CommandMetadata } from "../../../types/common/command-metadata.js"; +import { format, intro, outro } from "../../../prompts/format.js"; const DEFAULT_WORKING_DIRECTORY = "./"; export default class PortalRecipeNew extends Command { - static override summary = "Add an API Recipe to your API Documentation portal."; + static summary = "Add an API Recipe to your API documentation portal."; - static override description = - "To learn more about API Recipes, visit: https://docs.apimatic.io/platform-api/#/http/guides/generating-on-prem-api-portal/api-recipes"; + static description = `This command adds a new API Recipe file to your documentation portal. - static override examples = [ - `apimatic portal recipe new`, - `apimatic portal recipe new --name="My API Recipe" --input="./"` - ]; - static override flags = { +To learn more about API Recipes, visit: +${format.link( + "https://docs.apimatic.io/platform-api/#/http/guides/generating-on-prem-api-portal/api-recipes" +)}`; + + static flags = { name: Flags.string({ description: "name for the recipe" }), - ...FlagsProvider.input + ...FlagsProvider.input, + ...FlagsProvider.force }; - public async run(): Promise { - const { flags } = await this.parse(PortalRecipeNew); + static readonly cmdTxt = format.cmd("apimatic", "portal", "recipe", "new"); + static readonly examples = [ + `${this.cmdTxt}`, + `${this.cmdTxt} ${format.flag("name", '"My API Recipe"')} ${format.flag("input", '"./"')}` + ]; + + async run(): Promise { + const { + flags: { name, input, force } + } = await this.parse(PortalRecipeNew); + + const workingDirectory = new DirectoryPath(input ?? DEFAULT_WORKING_DIRECTORY); + const buildDirectory = input ? new DirectoryPath(input, "src") : workingDirectory.join("src"); + const commandMetadata: CommandMetadata = { commandName: PortalRecipeNew.id, shell: this.config.shell }; - const telemetryService = new TelemetryService(this.getConfigDir()); - const portalRecipeAction = new PortalRecipeAction(this.getConfigDir(), commandMetadata); - const portalRecipePrompts = new PortalRecipePrompts(); - - const workingDirectory = new DirectoryPath(flags.input ?? DEFAULT_WORKING_DIRECTORY); - const buildDirectory = flags.input ? new DirectoryPath(flags.input, "src") : workingDirectory.join("src"); - - const createRecipeResult = await portalRecipeAction.createRecipe(buildDirectory, flags.name); - - //TODO: Add a mapper for automatically mapping events to logger and telemetry service. - if (createRecipeResult.isFailed()) { - await telemetryService.trackEvent( - new RecipeCreationFailedEvent(createRecipeResult.error!, commandMetadata.commandName, flags), - commandMetadata.shell - ); - portalRecipePrompts.logError(getMessageInRedColor(createRecipeResult.error!)); - } - if (createRecipeResult.isCancelled()) { - portalRecipePrompts.logError(getMessageInRedColor(createRecipeResult.value!)); - } - } - private getConfigDir(): DirectoryPath { - return new DirectoryPath(this.config.configDir); + intro("New Recipe"); + const action = new PortalRecipeAction(new DirectoryPath(this.config.configDir), commandMetadata); + const result = await action.execute(buildDirectory, name); + outro(result); + + result.mapAll( + () => {}, + async () => { + const telemetryService = new TelemetryService(new DirectoryPath(this.config.configDir)); + await telemetryService.trackEvent( + new RecipeCreationFailedEvent("error", PortalRecipeNew.id, { + name, + input, + force + }), + commandMetadata.shell + ); + }, + () => {} + ); } } diff --git a/src/commands/portal/toc/new.ts b/src/commands/portal/toc/new.ts index 708274532..fa10f4eaf 100644 --- a/src/commands/portal/toc/new.ts +++ b/src/commands/portal/toc/new.ts @@ -1,10 +1,11 @@ -import { Command, Config, Flags } from "@oclif/core"; +import { Command, Flags } from "@oclif/core"; import { PortalNewTocAction } from "../../../actions/portal/toc/new-toc.js"; import { TelemetryService } from "../../../infrastructure/services/telemetry-service.js"; import { TocCreationFailedEvent } from "../../../types/events/toc-creation-failed.js"; import { DirectoryPath } from "../../../types/file/directoryPath.js"; import { FlagsProvider } from "../../../types/flags-provider.js"; import { CommandMetadata } from "../../../types/common/command-metadata.js"; +import { format, intro, outro } from "../../../prompts/format.js"; const DEFAULT_WORKING_DIRECTORY = "./"; @@ -17,72 +18,84 @@ generation of your API documentation portal. The output is a YAML file with the .yml extension. To learn more about the TOC file and APIMatic build directory structure, visit: -https://docs.apimatic.io/platform-api/#/http/guides/generating-on-prem-api-portal/overview-generating-api-portal`; +${format.link( + "https://docs.apimatic.io/platform-api/#/http/guides/generating-on-prem-api-portal/overview-generating-api-portal" +)}`; static flags = { - ...FlagsProvider.destination("src/content", `'toc.yml'`), + ...FlagsProvider.destination("src/content", `toc.yml`), ...FlagsProvider.input, ...FlagsProvider.force, "expand-endpoints": Flags.boolean({ default: false, - description: - "include individual entries for each endpoint in the generated 'toc.yml'. Requires a valid API specification in the working directory." + description: `include individual entries for each endpoint in the generated ${format.path( + "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.yml'. Requires a valid API specification in the working directory." + description: `include individual entries for each model in the generated ${format.path( + "toc.yml" + )}. Requires a valid API specification in the working directory.` }) }; + static cmdTxt = format.cmd("apimatic", "portal", "toc", "new"); static examples = [ - `apimatic portal toc new --destination="./src/content/"`, - `apimatic portal toc new --input="./"`, - `apimatic portal toc new --input="./" --destination="./src/content/"` + `${this.cmdTxt} ${format.flag("destination", '"./src/content/"')}`, + `${this.cmdTxt} ${format.flag("input", '"./"')}`, + `${this.cmdTxt} ${format.flag("input", '"./"')} ${format.flag("destination", '"./src/content/"')}` ]; - constructor(argv: string[], config: Config) { - super(argv, config); - } - async run(): Promise { - const { flags } = await this.parse(PortalTocNew); + const { + flags: { + input, + destination, + force, + "expand-endpoints": expandEndpoints, + "expand-models": expandModels + } + } = await this.parse(PortalTocNew); + + const workingDirectory = new DirectoryPath(input ?? DEFAULT_WORKING_DIRECTORY); + const buildDirectory = input ? new DirectoryPath(input, "src") : workingDirectory.join("src"); + const tocDirectory = destination? new DirectoryPath(destination): undefined; + const commandMetadata: CommandMetadata = { commandName: PortalTocNew.id, shell: this.config.shell }; - const telemetryService = new TelemetryService(this.getConfigDir()); - const portalNewTocAction = new PortalNewTocAction(this.getConfigDir(), commandMetadata); - - const workingDirectory = new DirectoryPath(flags.input ?? DEFAULT_WORKING_DIRECTORY); - const buildDirectory = flags.input ? new DirectoryPath(flags.input, "src") : workingDirectory.join("src"); - - let tocDirectory: DirectoryPath | undefined; - - if (flags.destination) { - tocDirectory = new DirectoryPath(flags.destination); - } - - const result = await portalNewTocAction.createToc( + intro("New TOC"); + const action = new PortalNewTocAction(new DirectoryPath(this.config.configDir), commandMetadata); + const result = await action.execute( buildDirectory, tocDirectory, - flags.force, - flags["expand-endpoints"], - flags["expand-models"] + force, + expandEndpoints, + expandModels + ); + outro(result); + + result.mapAll( + () => {}, + async () => { + const telemetryService = new TelemetryService(new DirectoryPath(this.config.configDir)); + await telemetryService.trackEvent( + // TODO: fix Toc error message + new TocCreationFailedEvent('error', PortalTocNew.id, { + input, + destination, + force, + "expand-endpoints": expandEndpoints, + "expand-models": expandModels + }), + commandMetadata.shell + ); + }, + () => {} ); - - //TODO: Add a mapper for automatically mapping events to logger and telemetry service. - if (result.isFailed()) { - await telemetryService.trackEvent( - new TocCreationFailedEvent(result.error!, PortalTocNew.id, flags), - commandMetadata.shell - ); - this.error(result.error!); - } } - private getConfigDir(): DirectoryPath { - return new DirectoryPath(this.config.configDir); - } } diff --git a/src/infrastructure/file-service.ts b/src/infrastructure/file-service.ts index 0bb8f5523..4a40fba4b 100644 --- a/src/infrastructure/file-service.ts +++ b/src/infrastructure/file-service.ts @@ -5,6 +5,8 @@ import { FilePath } from "../types/file/filePath.js"; import { DirectoryPath } from "../types/file/directoryPath.js"; import { pipeline } from "stream"; import { promisify } from "util"; +import { FileName } from "../types/file/fileName.js"; +import { Directory } from "../types/file/directory.js"; export class FileService { public async fileExists(file: FilePath): Promise { @@ -47,6 +49,18 @@ export class FileService { await fsExtra.copy(source.toString(), destination.toString()); } + public async getDirectory(directoryPath: DirectoryPath): Promise { + const entries = await fsExtra.readdir(directoryPath.toString()); + const results = await Promise.all( + entries.map(async (entry) => { + const fullPath = path.join(directoryPath.toString(), entry); + const stat = await fsExtra.stat(fullPath); + return stat.isDirectory() ? await this.getDirectory(new DirectoryPath(fullPath)) : new FileName(entry); + }) + ); + return new Directory(directoryPath, results); + } + public async copyDirectoryContents(source: DirectoryPath, destination: DirectoryPath) { const entries = await fsExtra.readdir(source.toString()); await Promise.all( @@ -85,6 +99,10 @@ export class FileService { await streamPipeline(stream, writeStream); } + public async ensurePathExists(filePath: FilePath) { + await fsExtra.ensureFile(filePath.toString()); + } + public async writeContents(filePath: FilePath, contents: string) { await fsExtra.writeFile(filePath.toString(), contents, "utf-8"); } @@ -92,6 +110,7 @@ export class FileService { public async copy(source: FilePath, destination: FilePath) { await fsExtra.copyFile(source.toString(), destination.toString()); } + } const streamPipeline = promisify(pipeline); diff --git a/src/infrastructure/services/portal-service.ts b/src/infrastructure/services/portal-service.ts index 0d55f26e6..fa4e374ef 100644 --- a/src/infrastructure/services/portal-service.ts +++ b/src/infrastructure/services/portal-service.ts @@ -17,7 +17,6 @@ import { Platforms } from "@apimatic/sdk"; import { AuthInfo, getAuthInfo } from "../../client-utils/auth-manager.js"; -import { Result } from "../../types/common/result.js"; import { getMessageInRedColor, parseStreamBodyToJson } from "../../utils/utils.js"; import { TransformationData } from "../../types/api/transform.js"; import { Sdl } from "../../types/sdl/sdl.js"; @@ -26,8 +25,9 @@ import { DirectoryPath } from "../../types/file/directoryPath.js"; import { FileService } from "../file-service.js"; import { apiClientFactory } from "./api-client-factory.js"; import { CommandMetadata } from "../../types/common/command-metadata.js"; -import { err, ok, Result as ResultEx } from "neverthrow"; +import { err, ok, Result} from "neverthrow"; import { Language } from "../../types/sdk/generate.js"; +import { handleServiceError, ServiceError } from "../api-utils.js"; export class PortalService { private readonly CONTENT_TYPE = ContentType.EnumMultipartformdata; @@ -39,7 +39,7 @@ export class PortalService { configDir: DirectoryPath, eventMetadata: CommandMetadata, authKey: string | null - ): Promise> { + ): Promise> { const buildFileStream = await this.fileService.getStream(buildPath); const file = new FileWrapper(buildFileStream); const authInfo: AuthInfo | null = await getAuthInfo(configDir.toString()); @@ -68,7 +68,7 @@ export class PortalService { configDir: DirectoryPath, commandMetadata: CommandMetadata, authKey: string | null - ): Promise> { + ): Promise> { const specFileStream = await this.fileService.getStream(specPath); const file = new FileWrapper(specFileStream); const authInfo: AuthInfo | null = await getAuthInfo(configDir.toString()); @@ -96,7 +96,7 @@ export class PortalService { specFileStream: ReadStream, configDir: DirectoryPath, commandMetadata: CommandMetadata - ): Promise> { + ): Promise> { const file = new FileWrapper(specFileStream); const authInfo: AuthInfo | null = await getAuthInfo(configDir.toString()); const authorizationHeader = this.createAuthorizationHeader(authInfo, null); @@ -112,25 +112,21 @@ export class PortalService { ); if (!generation.result.success) { - return this.createGenericErrorResult(); + return err(ServiceError.InvalidResponse); } const transformationId = generation.result.id; const { result }: TransformationData = await transformationController.downloadTransformedFile(transformationId); if ((result as NodeJS.ReadableStream).readable) { - return Result.success((await parseStreamBodyToJson(result as NodeJS.ReadableStream)) as Sdl); + return ok((await parseStreamBodyToJson(result as NodeJS.ReadableStream)) as Sdl); } else { - return this.createGenericErrorResult(); + return err(ServiceError.InvalidResponse); } - } catch { - return this.createGenericErrorResult(); + } catch(error) { + return err(handleServiceError(error)); } } - private createGenericErrorResult() { - return Result.failure("An unexpected error occurred"); - } - private createAuthorizationHeader = (authInfo: AuthInfo | null, overrideAuthKey: string | null): string => { const key = overrideAuthKey || authInfo?.authKey; return `X-Auth-Key ${key ?? ""}`; diff --git a/src/prompts/format.ts b/src/prompts/format.ts index 385b38650..7acf09634 100644 --- a/src/prompts/format.ts +++ b/src/prompts/format.ts @@ -8,7 +8,8 @@ export const format = { // Core element types var: (text: string) => pc.magenta(`'${text}'`), path: (text: string) => pc.cyan(`'${text}'`), - cmd: (cmd: string, topic: string, action: string) => `${pc.blue(cmd)} ${pc.dim(topic)} ${pc.dim(action)}`, + cmd: (cmd: string, ...args: string[]) => `${pc.blue(cmd)} ${args.map(arg => pc.dim(arg)).join(" ")}`, + link: (text: string) => pc.underline(pc.blue(text)), flag: (name: string, value: string | undefined = undefined) => { if (value) { return `${pc.green(`--${name}`)}=${pc.dim(value)}`; diff --git a/src/prompts/portal/copilot.ts b/src/prompts/portal/copilot.ts index 9527dcd55..0690b7847 100644 --- a/src/prompts/portal/copilot.ts +++ b/src/prompts/portal/copilot.ts @@ -1,4 +1,4 @@ -import { confirm, isCancel, log, note, outro, select } from "@clack/prompts"; +import { confirm, isCancel, log, note, select } from "@clack/prompts"; import { Result } from "neverthrow"; import { SubscriptionInfo } from "../../types/api/account.js"; import { getErrorMessage, ServiceError } from "../../infrastructure/api-utils.js"; diff --git a/src/prompts/portal/recipe/new-recipe.ts b/src/prompts/portal/recipe/new-recipe.ts index e9bc161dd..7a53c4471 100644 --- a/src/prompts/portal/recipe/new-recipe.ts +++ b/src/prompts/portal/recipe/new-recipe.ts @@ -1,28 +1,41 @@ -import * as fs from "fs"; -import * as path from "path"; import treeify from "treeify"; -import { intro, spinner, select, text, cancel, isCancel, outro, log, autocomplete } from "@clack/prompts"; +import { select, text, isCancel, log, autocomplete, confirm } from "@clack/prompts"; import { getMessageInGreenColor } from "../../../utils/utils.js"; import { SdlEndpoint } from "../../../types/sdl/sdl.js"; +import { DirectoryPath } from "../../../types/file/directoryPath.js"; +import { format as f } from "../../format.js"; +import { StepType } from "../../../types/recipe/recipe.js"; +import { getErrorMessage, ServiceError } from "../../../infrastructure/api-utils.js"; export class PortalRecipePrompts { - private readonly spin = spinner(); - public displayWelcomeMessage(): void { - intro(`Welcome to the API Recipe Generation Wizard. 🪄`); - log.step(`This wizard will guide you through the process of creating an API Recipe.`); - log.message( - `An API Recipe is a collection of steps that allows you to define a single use case for your API Documentation portal.` - ); - log.message( - `ℹ️ Learn more: https://docs.apimatic.io/platform-api/#/http/guides/generating-on-prem-api-portal/api-recipes` - ); - log.message(`Let's get started! 🚀`); - } - - public async recipeNamePrompt(): Promise { + log.step(`Welcome to the API Recipe Generation Wizard.`); + const message = `This wizard will guide you through the process of creating an API Recipe. + +An API Recipe is a collection of steps that allows you to define a single use case for your API Documentation portal. +Learn more: ${f.link("https://docs.apimatic.io/platform-api/#/http/guides/generating-on-prem-api-portal/api-recipes")} +Let's get started!`; + log.message(message); + } + + public recipeNameEmpty() { + const message = "No recipe name provided"; + log.error(message); + } + + public contentFolderNotFound() { + const message = "Content folder not found."; + log.error(message); + } + + public specFileEmptyInvalid() { + const message = "Spec file is empty or invalid."; + log.error(message); + } + + public async recipeNamePrompt(): Promise { const recipeName = await text({ - message: `📘 Enter a name for your API Recipe:`, + message: `Enter a name for your API Recipe:`, placeholder: "This name will be displayed in your API Documentation portal sidebar.", validate: (name) => { if (!name) { @@ -32,55 +45,13 @@ export class PortalRecipePrompts { }); if (isCancel(recipeName)) { - cancel("Operation cancelled."); - return process.exit(0); + return undefined; } return (recipeName as string).trim(); } - //Todo: Find a better place for this function - removeQuotes(str: string): string { - const quotes = ['"', "'"]; - - for (const quote of quotes) { - if (str.startsWith(quote) && str.endsWith(quote) && str.length > 1) { - return this.removeQuotes(str.slice(1, -1)); // Recursive call - } - } - return str; - } - public async buildConfigFilePathPrompt(buildDirectoryPath: string): Promise { - const buildConfigFilePath = await text({ - message: `⚠️ APIMATIC-BUILD.json is required and was not found in "${buildDirectoryPath}".\nPlease enter the path to your build config file (relative to this directory):`, - validate: (filePath) => { - if (!filePath) { - return "Build config file path cannot be empty. Please provide a valid file path."; - } - const cleanedPath = this.removeQuotes(filePath.trim()); - const resolvedPath = path.resolve(buildDirectoryPath, cleanedPath); - - if (!resolvedPath.endsWith(".json")) { - return "The content file must be a JSON (.json) file. Please provide a valid file path."; - } - - - if (fs.existsSync(resolvedPath) && fs.statSync(resolvedPath).isFile()) { - return; - } - - return "The specified path is either not a valid relative file path or it doesn't exist. Please provide a valid relative file path."; - } - }); - - if (isCancel(buildConfigFilePath)) { - cancel("Operation cancelled."); - return process.exit(0); - } - return path.resolve(buildDirectoryPath, this.removeQuotes((buildConfigFilePath as string).trim())); - } - - public async stepNamePrompt(defaultStepName: string): Promise { + public async stepNamePrompt(defaultStepName: string): Promise { const stepName = await text({ message: `Enter a name for the step:`, defaultValue: defaultStepName, @@ -88,46 +59,37 @@ export class PortalRecipePrompts { }); if (isCancel(stepName)) { - cancel("Operation cancelled."); - return process.exit(0); + return undefined; } return (stepName as string).trim(); } public displayStepsInformation(): void { - log.step(`🔧 Add Steps to your API Recipe:`); - log.message(`You can add:`) - log.message( - `1. 📄 Content Step: Display custom content, such as instructions or information related to your API.` - ); - log.message(`2. 🔗 Endpoint Step: Display an API endpoint, its playground and other relevant details.`); - log.message(`💡 Steps appear in the order you add them.`); - log.message(`📝 Let's proceed to adding steps to your API Recipe. `); + log.step(`Add Steps to your API Recipe:`); + const message = `You can add: +1. Content Step: Display custom content, such as instructions or information related to your API. +2. Endpoint Step: Display an API endpoint, its playground and other relevant details. +Steps appear in the order you add them. +Let's proceed to adding steps to your API Recipe.`; + log.message(message); } - public async stepTypeSelectionPrompt(): Promise { + public async stepTypeSelectionPrompt(): Promise { const stepType = await select({ - message: `➕ Select the type of step you want to add:`, + message: `Select the type of step you want to add:`, options: [ { value: "content", label: "Content Step", hint: "For displaying custom content" }, { value: "endpoint", label: "Endpoint Step", hint: "For displaying an API endpoint with its details" } ] }); - if (isCancel(stepType)) { - cancel("Operation cancelled."); - return process.exit(0); + return undefined; } - - return stepType as string; - } - - public displayContentStepInfo(): void { - log.step("📝 Opening markdown editor for you to add content in..."); + return stepType as StepType; } - public async endpointGroupNamePrompt(endpointGroups: Map): Promise { + public async endpointGroupNamePrompt(endpointGroups: Map): Promise { const groupNames = Array.from(endpointGroups.keys()).map((name) => ({ value: name, label: name @@ -139,14 +101,16 @@ export class PortalRecipePrompts { }); if (isCancel(endpointGroupName)) { - cancel("Operation cancelled."); - return process.exit(0); + return undefined; } return (endpointGroupName as string).trim(); } - public async endpointNamePrompt(endpointGroups: Map, endpointGroupName: string): Promise { + public async endpointNamePrompt( + endpointGroups: Map, + endpointGroupName: string + ): Promise { const endpoints = endpointGroups.get(endpointGroupName); const endpointName = await autocomplete({ message: `Select the name of the endpoint:`, @@ -158,15 +122,13 @@ export class PortalRecipePrompts { }); if (isCancel(endpointName)) { - cancel("Operation cancelled."); - return process.exit(0); + return undefined; } return (endpointName as string).trim(); } - public async endpointDescriptionPrompt(endpointGroups: Map, endpointGroupName: string, endpointName: string): Promise { - const defaultDescription = endpointGroups.get(endpointGroupName)!.find((e) => e.Name === endpointName)!.Description; + public async endpointDescriptionPrompt(defaultDescription: string): Promise { const endpointDescription = await text({ message: `Enter a description for the endpoint:`, placeholder: `Optional. Leave this empty to use the endpoint description defined in the API Specification`, @@ -174,67 +136,40 @@ export class PortalRecipePrompts { }); if (isCancel(endpointDescription)) { - cancel("Operation cancelled."); - return process.exit(0); + return undefined; } return (endpointDescription as string).trim(); } public async addAnotherStepSelectionPrompt(): Promise { - const addAnotherStep = await select({ + const addAnotherStep = await confirm({ message: `Do you want to add another step?`, - options: [ - { value: "yes", label: "Yes" }, - { value: "no", label: "No" } - ], - initialValue: "yes" + initialValue: true }); if (isCancel(addAnotherStep)) { - cancel("Operation cancelled."); - return process.exit(0); + return false; } - - return addAnotherStep === "yes"; + return addAnotherStep; } - public async overwriteApiRecipeInTocPrompt(): Promise { - const overwriteApiRecipeInToc = await select({ - message: `⚠️ An API Recipe with this name already exists. Do you want to overwrite it?`, - options: [ - { value: "yes", label: "Yes" }, - { value: "no", label: "No" } - ] + public async overwriteApiRecipeInTocPrompt(name: string): Promise { + const overwrite = await confirm({ + message: `An API Recipe with name ${f.var(name)} already exists. Do you want to overwrite it?`, + initialValue: false }); - if (isCancel(overwriteApiRecipeInToc)) { - cancel("Operation cancelled."); - return process.exit(0); + if (isCancel(overwrite)) { + return false; } - - return overwriteApiRecipeInToc === "yes"; + return overwrite; } public displayStepAddedSuccessfullyMessage() { log.step(`Step has been added successfully.`); } - public displayRecipeGenerationSuccessMessage(buildDirectoryPath: string) { - log.message(`🎉 Your new API Recipe has been added to the source directory at: ${buildDirectoryPath}`); - outro( - `▶ Run the command \`apimatic portal serve\` to preview your documentation portal.` - ); - } - - public startProgressIndicatorWithMessage(message: string): void { - this.spin.start(message); - } - - public stopProgressIndicatorWithMessage(message: string): void { - this.spin.stop(message); - } - public displayBuildDirectoryStructureAsTree(buildDirectoryTreeObject: treeify.TreeObject) { const tree = treeify.asTree(buildDirectoryTreeObject, true, true); @@ -243,10 +178,19 @@ export class PortalRecipePrompts { .map((line: string) => line.replace(/#.*/, (match: string) => getMessageInGreenColor(match))) .join("\n"); - log.step(`🛠️ You can edit the following files to customize your API Recipe :\n\n` + coloredLogString); + log.step(`You can edit the following files to customize your API Recipe:\n\n` + coloredLogString); + } + + public invalidBuildDirectory(directory: DirectoryPath) { + const message = `The ${f.var("src")} directory is either empty or invalid: ${f.path(directory.toString())}`; + log.error(message); + } + + public serviceError(serviceError: ServiceError) { + log.error(getErrorMessage(serviceError)); } - public logError(error: string): void { - outro(error); + public openRecipeMarkdownEditor() { + log.step("Opening markdown editor for you to enter recipe content..."); } } diff --git a/src/prompts/portal/toc/new-toc.ts b/src/prompts/portal/toc/new-toc.ts index 5999c94e7..ffb2a06f6 100644 --- a/src/prompts/portal/toc/new-toc.ts +++ b/src/prompts/portal/toc/new-toc.ts @@ -1,12 +1,35 @@ -import { cancel, outro, confirm, spinner, isCancel, log } from "@clack/prompts"; +import { cancel, confirm, isCancel, log } from "@clack/prompts"; import { FilePath } from "../../../types/file/filePath.js"; +import { Result } from "neverthrow"; +import { SdlTocComponents } from "../../../types/spec-context.js"; +import { format as f, format, withSpinner } from "../../format.js"; +import { DirectoryPath } from "../../../types/file/directoryPath.js"; +import { ServiceError } from "../../../infrastructure/api-utils.js"; +import { Sdl } from "../../../types/sdl/sdl.js"; export class PortalNewTocPrompts { - private readonly spin = spinner(); + public sdlComponentsExtractionFailed() { + log.error( + "Failed to extract endpoints from the API specification. Please validate your spec using APIMatic's interactive VS Code Extension and then try again." + ); + } + + public extractSdlComponents(fn: Promise>) { + return withSpinner( + "Extracting endpoints and/or models from the API specification", + "Extraction successful.", + "Extraction failed.", + fn + ); + } - async overwriteExistingTocPrompt(tocPath: FilePath): Promise { + public generateTOC(fn: Promise>) { + return withSpinner("Generating TOC", "TOC generated successfully.", "TOC generation failed.", fn); + } + + public async overwriteToc(tocPath: FilePath): Promise { const overwrite = await confirm({ - message: `⚠️ The destination file '${tocPath}' already exists, do you want to overwrite it?`, + message: `The destination file '${tocPath}' already exists, do you want to overwrite it?`, initialValue: false }); @@ -15,34 +38,46 @@ export class PortalNewTocPrompts { return false; } - if (!overwrite) { - log.error("Please enter a different destination path or delete the existing toc.yml file and try again."); - } - return overwrite; } - startProgressIndicatorWithMessage(message: string): void { - this.spin.start(message); + public contentGroupsExtractionFailed() { + log.error(`Failed to extract content groups.`); + } + + public fallingBackToDefault() { + log.warn(`Falling back to default TOC structure.`); + } + + public tocFileAlreadyExists() { + const message = `Please enter a different destination path or delete the existing toc.yml file and try again.`; + log.error(message); } - stopProgressIndicatorWithMessage(message: string): void { - this.spin.stop(message); + public logError(message: string) { + log.error(message); } displayOutroMessage(tocPath: FilePath): void { - outro(`toc.yml file successfully created at: ${tocPath}`); + log.info(`${format.var("toc.yml")} file successfully created at: ${format.path(tocPath.toString())}`); } - logError(error: string): void { - outro(error); + public contentDirectoryNotFound(contentFolderPath: DirectoryPath) { + const message = `Content folder not found at: ${contentFolderPath}`; + log.error(message); } - displayWarning(message: string): void { - log.warning(message); + public invalidBuildDirectory(directory: DirectoryPath) { + const message = `The ${f.var("src")} directory is either empty or invalid: ${f.path(directory.toString())}`; + log.error(message); } - displayInfo(message: string): void { - log.step(message); + public extractModels(fn: Promise>) { + return withSpinner( + "Extracting endpoint groups and models", + "Endpoint groups and models extracted.", + "Endpoint groups and models extraction failed.", + fn + ); } } diff --git a/src/types/build-context.ts b/src/types/build-context.ts index 0a79d266f..60453dc59 100644 --- a/src/types/build-context.ts +++ b/src/types/build-context.ts @@ -12,7 +12,7 @@ export class BuildContext { this.buildDirectory = buildDirectory; } - private get BuildFile(): FilePath { + public get BuildFile(): FilePath { // TODO: add checks for build file path return new FilePath(this.buildDirectory, new FileName("APIMATIC-BUILD.json")); } @@ -37,4 +37,3 @@ export class BuildContext { await this.fileService.writeContents(this.BuildFile, JSON.stringify(buildJson, null, 2)); } } - diff --git a/src/types/build/build.ts b/src/types/build/build.ts index 8edad27f8..fb2e44028 100644 --- a/src/types/build/build.ts +++ b/src/types/build/build.ts @@ -1,3 +1,5 @@ +import { DirectoryPath } from "../file/directoryPath.js"; + export interface BuildConfig { generatePortal?: PortalConfig; apiCopilotConfig?: CopilotConfig; @@ -8,6 +10,7 @@ export interface PortalConfig { contentFolder?: string; languageConfig: { [key: string]: object }; [key: string]: unknown; + apiSpecPath?: DirectoryPath; } export interface CopilotConfig { diff --git a/src/types/file/directory.ts b/src/types/file/directory.ts new file mode 100644 index 000000000..fd68e99d5 --- /dev/null +++ b/src/types/file/directory.ts @@ -0,0 +1,52 @@ +import { DirectoryPath } from './directoryPath.js'; +import { FileName } from './fileName.js'; +import { TocCustomPage, TocGroup } from "../toc/toc.js"; +import { FilePath } from "./filePath.js"; + + +export type DirectoryItem = FileName | Directory; + +export class Directory { + public readonly directoryPath: DirectoryPath; + public readonly items: DirectoryItem[]; + + + public constructor(directoryPath: DirectoryPath, filePaths: DirectoryItem[]) { + this.directoryPath = directoryPath; + this.items = filePaths; + } + + public async parseContentFolder(baseContentPath: DirectoryPath): Promise { + const contentItems: (TocGroup | TocCustomPage)[] = []; + + for (const item of this.items) { + if (item instanceof Directory) { + contentItems.push({ + group: item.toString(), + items: await item.parseContentFolder(baseContentPath) + }); + } else { + if (item.isMarkDown()) { + const filePath = new FilePath(this.directoryPath, item); + contentItems.push({ + page: filePath.toString(), + file: new FilePath(baseContentPath, item.normalize()).toString(), + }); + } + } + } + + // Return empty if no mark-down files were found + if (contentItems.length === 0) { + return []; + } + + // Wrap everything under a "Custom Content" group + return [ + { + group: "Custom Content", + items: contentItems + } + ]; + } +} diff --git a/src/types/file/directoryPath.ts b/src/types/file/directoryPath.ts index 09c5e3cf8..a2838dee2 100644 --- a/src/types/file/directoryPath.ts +++ b/src/types/file/directoryPath.ts @@ -12,8 +12,12 @@ export class DirectoryPath { return this.directoryPath; } - public join(subPath: string) { - return new DirectoryPath(path.join(this.directoryPath, subPath)); + public join(...subPath: string[]) { + return new DirectoryPath(path.join(this.directoryPath, ...subPath)); + } + + public joinWith(other: DirectoryPath) { + return new DirectoryPath(path.join(this.directoryPath, other.toString())); } public isEqual(other: DirectoryPath) { diff --git a/src/types/file/file.ts b/src/types/file/file.ts new file mode 100644 index 000000000..7e708f3f0 --- /dev/null +++ b/src/types/file/file.ts @@ -0,0 +1,21 @@ +import { FileName } from './fileName.js'; +import { FilePath } from './filePath.js'; +import * as path from "path"; + +export class File { + public readonly filePath: FilePath; + private readonly fileName: FileName; + + public constructor(filePath: FilePath) { + this.filePath = filePath; + this.fileName = new FileName(path.basename(filePath.toString())); + } + + public getNameWithoutExtension(): string { + return this.fileName.toString().replace(/\.[^/.]+$/, ""); + } + + public getExtension(): string { + return path.extname(this.filePath.toString()); + } +} diff --git a/src/types/file/fileName.ts b/src/types/file/fileName.ts index d16ba71f1..a038aa47a 100644 --- a/src/types/file/fileName.ts +++ b/src/types/file/fileName.ts @@ -11,6 +11,20 @@ export class FileName { return path.extname(this.name).toLowerCase() === ".zip"; } + public isMarkDown() { + return this.name.endsWith(".md"); + } + + public normalize(): FileName { + const nameWithoutExt = this.name.replace(/\.[^/.]+$/, ""); + const normalized = nameWithoutExt + .toLowerCase() + .replace(/[^a-z0-9-]/g, "-") + .replace(/-+/g, "-") + .replace(/(^-|-$)/g, ""); + return new FileName(normalized); + } + public toString(): string { return this.name; } diff --git a/src/types/recipe-context.ts b/src/types/recipe-context.ts new file mode 100644 index 000000000..325f2659e --- /dev/null +++ b/src/types/recipe-context.ts @@ -0,0 +1,30 @@ +import { toPascalCase } from "../utils/utils.js"; +import { FileName } from "./file/fileName.js"; +import { Toc } from "./toc/toc.js"; + +export class RecipeContext { + constructor(private readonly recipeName: string) {} + + getRecipeName(): FileName { + return new FileName(toPascalCase(this.recipeName.trim())); + } + + exists(tocData: Toc, recipeName: string, recipeFileName: FileName): boolean { + let apiRecipesGroup = tocData.toc?.find((item) => "group" in item && item.group === "API Recipes"); + if (!apiRecipesGroup || !("items" in apiRecipesGroup)) { + return false; + } + + // Check if recipe name or file name already exists + const existingRecipe = apiRecipesGroup.items.find( + (item) => + "page" in item && "file" in item && (item.page === recipeName || item.file === `recipes/${recipeFileName}.md`) + ); + + if (existingRecipe) { + return true; + } + + return false; + } +} \ No newline at end of file diff --git a/src/types/recipe/recipe.ts b/src/types/recipe/recipe.ts index 14364e414..69b9b1ea2 100644 --- a/src/types/recipe/recipe.ts +++ b/src/types/recipe/recipe.ts @@ -3,25 +3,11 @@ export enum StepType { Endpoint = "endpoint" } -export interface RecipeContext { - showContent(content: string): Promise; - showEndpoint(config: EndpointConfig): Promise; -} - export interface EndpointConfig { description: string; endpointPermalink: string; } -export interface StepConfig { - name: string; - stepCallback: () => Promise; -} - -export interface RecipeDefinition { - [stepKey: string]: StepConfig; -} - export interface SerializableRecipe { name: string; steps: SerializableStep[]; @@ -30,7 +16,7 @@ export interface SerializableRecipe { export interface SerializableStep { key: string; name: string; - type: "content" | "endpoint"; + type: StepType; config: ContentStepConfig | EndpointStepConfig; } @@ -38,8 +24,9 @@ export interface ContentStepConfig { content: string; } -export interface EndpointStepConfig extends EndpointConfig {} +export type EndpointStepConfig = EndpointConfig + export interface DirectoryNode { [key: string]: DirectoryNode | string | null | undefined; -} \ No newline at end of file +} diff --git a/src/types/sdl/sdl.ts b/src/types/sdl/sdl.ts index 12a951dc7..b1b5c2b2f 100644 --- a/src/types/sdl/sdl.ts +++ b/src/types/sdl/sdl.ts @@ -1,14 +1,56 @@ +import { SdlTocComponents } from "../spec-context.js"; +import { TocEndpoint, TocModel } from "../toc/toc.js"; + export interface Sdl { - readonly Endpoints: SdlEndpoint[], - readonly CustomTypes: SdlModel[] + readonly Endpoints: SdlEndpoint[]; + readonly CustomTypes: SdlModel[]; } export interface SdlEndpoint { - readonly Name: string, - readonly Description: string, - readonly Group: string + readonly Name: string; + readonly Description: string; + readonly Group: string; } export interface SdlModel { - readonly Name: string -} \ No newline at end of file + readonly Name: string; +} + +function extractEndpointGroupsForToc(sdl: Sdl): Map { + const endpointGroups = new Map(); + + const endpoints = sdl.Endpoints.map( + (e: SdlEndpoint): TocEndpoint => ({ + generate: null, + from: "endpoint", + endpointName: e.Name, + endpointGroup: e.Group + }) + ); + + endpoints.forEach((endpoint: TocEndpoint) => { + const group = endpoint.endpointGroup; + if (!endpointGroups.has(group)) { + endpointGroups.set(group, []); + } + endpointGroups.get(group)!.push(endpoint); + }); + + return endpointGroups; +} + +function extractModelsForToc(sdl: Sdl): TocModel[] { + return sdl.CustomTypes.map( + (e: SdlModel): TocModel => ({ + generate: null, + from: "model", + modelName: e.Name + }) + ); +} + +export function getEndpointGroupsAndModels(sdl: Sdl): SdlTocComponents { + const endpointGroups = extractEndpointGroupsForToc(sdl); + const models = extractModelsForToc(sdl); + return { endpointGroups, models }; +} diff --git a/src/types/spec-context.ts b/src/types/spec-context.ts index 4cf704d5a..bdfb7133e 100644 --- a/src/types/spec-context.ts +++ b/src/types/spec-context.ts @@ -1,5 +1,9 @@ import { FileService } from "../infrastructure/file-service.js"; import { DirectoryPath } from "./file/directoryPath.js"; +import { TocEndpoint, TocModel } from "./toc/toc.js"; + +export type EndpointGroup = Map; +export type SdlTocComponents = { endpointGroups: EndpointGroup; models: TocModel[] }; export class SpecContext { private readonly fileService = new FileService(); @@ -10,8 +14,6 @@ export class SpecContext { } public async validate(): Promise { - // TODO: add more checks here - return await this.fileService.directoryExists(this.specDirectory) + return !(await this.fileService.directoryEmpty(this.specDirectory)); } } - diff --git a/src/types/temp-context.ts b/src/types/temp-context.ts index 5d1aa889e..9ff7b04ca 100644 --- a/src/types/temp-context.ts +++ b/src/types/temp-context.ts @@ -3,29 +3,28 @@ import { ZipService } from "../infrastructure/zip-service.js"; import { DirectoryPath } from "./file/directoryPath.js"; import { FilePath } from "./file/filePath.js"; import { FileName } from "./file/fileName.js"; +import { randomUUID } from "crypto"; export class TempContext { private readonly fileService = new FileService(); private readonly zipService = new ZipService(); - constructor(private readonly tempDirectory: DirectoryPath) { - } - - private get buildZipPath(): FilePath { - return new FilePath(this.tempDirectory, new FileName(`build.zip`)); - } + constructor(private readonly tempDirectory: DirectoryPath) {} - private get portalZipPath(): FilePath { - return new FilePath(this.tempDirectory, new FileName(`portal.zip`)); + private get getTempFileName(): FilePath { + const uuid = randomUUID(); + return new FilePath(this.tempDirectory, new FileName(`${uuid}.zip`)); } - async zip(buildDirectory: DirectoryPath) { - await this.zipService.archive(buildDirectory, this.buildZipPath); - return this.buildZipPath; + async zip(buildDirectory: DirectoryPath): Promise { + const tempFile = this.getTempFileName; + await this.zipService.archive(buildDirectory, tempFile); + return tempFile; } - async save(portalStream: NodeJS.ReadableStream) { - await this.fileService.writeFile(this.portalZipPath, portalStream); - return this.portalZipPath; + async save(portalStream: NodeJS.ReadableStream): Promise { + const tempFile = this.getTempFileName; + await this.fileService.writeFile(tempFile, portalStream); + return tempFile; } } diff --git a/src/types/toc-context.ts b/src/types/toc-context.ts new file mode 100644 index 000000000..7ccef50ce --- /dev/null +++ b/src/types/toc-context.ts @@ -0,0 +1,35 @@ +import { FileService } from "../infrastructure/file-service.js"; +import { DirectoryPath } from "./file/directoryPath.js"; +import { FilePath } from "./file/filePath.js"; +import { FileName } from "./file/fileName.js"; +import { Toc } from "./toc/toc.js"; +import { parse } from "yaml"; + +export class TocContext { + private readonly fileService = new FileService(); + private readonly tocFilePath: FilePath; + + constructor(tocDirectory: DirectoryPath) { + this.tocFilePath = new FilePath(tocDirectory, new FileName("toc.yml")); + } + + public get tocPath(): FilePath { + return this.tocFilePath; + } + + public async exists() { + return !(await this.fileService.fileExists(this.tocFilePath)); + } + + public async parseTocData(): Promise { + const tocContent = await this.fileService.getContents(this.tocFilePath); + const parsedToc = parse(tocContent) as Toc; + return parsedToc; + } + + public async save(contents: string) { + await this.fileService.ensurePathExists(this.tocFilePath); + await this.fileService.writeContents(this.tocFilePath, contents); + return this.tocPath; + } +} diff --git a/src/utils/utils.ts b/src/utils/utils.ts index 517f07075..a173cb222 100644 --- a/src/utils/utils.ts +++ b/src/utils/utils.ts @@ -13,6 +13,101 @@ export const createTempDirectory = async () => { return fs.mkdtempSync(path.join(os.tmpdir(), "apimatic-cli-")); }; +export const clearDirectory = async (folderPath: string) => { + if (!fs.existsSync(folderPath)) { + throw new Error(`Directory ${folderPath} does not exist`); + } + + const files = await fsExtra.readdir(folderPath); + + for (const file of files) { + const filePath = path.join(folderPath, file); + await deleteFile(filePath); + } + + await deleteFile(folderPath); +}; + +// TODO: Move to types folder. +interface DirectoryNode { + [key: string]: DirectoryNode | string | null | undefined; +} + +// TODO: Move to portal quickstart command. +const descriptions: { [key: string]: string } = Object.entries({ + "APIMATIC-BUILD.json": "# Defines all configurations for the API portal, including programming languages and themes", + spec: "# Contains all API definition files", + content: "# Includes custom documentation pages in Markdown", + "content/toc.yml": "# Controls the structure of the side navigation bar in the API portal", + static: "# Includes all static files, such as images, GIFs, and PDFs" +}).reduce((acc, [key, value]) => { + acc[path.normalize(key)] = value; + return acc; +}, {} as { [key: string]: string }); + +// TODO: Move to portal quickstart command. +export const directoryToJson = (dirPath: string, parentPath = ""): DirectoryNode => { + const directoryStructure: DirectoryNode = {}; + + const items = fs.readdirSync(dirPath); + items.forEach((item) => { + if (item === ".git") return; // Skip .git directory + + const itemPath = path.join(dirPath, item); + const relativePath = path.join(parentPath, item); + const stats = fs.statSync(itemPath); + + if (stats.isDirectory()) { + const subdirectoryStructure = directoryToJson(itemPath, relativePath); + + const folderName = descriptions[path.normalize(relativePath)] + ? `${item} : ${descriptions[path.normalize(relativePath)]}` + : item; + + directoryStructure[folderName] = subdirectoryStructure; + } else { + directoryStructure[ + descriptions[path.normalize(relativePath)] ? `${item} : ${descriptions[path.normalize(relativePath)]}` : item + ] = null; + } + }); + + return directoryStructure; +}; + +export const toPascalCase = (str: string): string => { + return str + .split(" ") + .map((word) => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase()) + .join(""); + } + +export const isValidUrl = (input: string): boolean => { + if (!input) { + return false; + } + + try { + const url = new URL(input); + + if (!["http:", "https:"].includes(url.protocol)) { + return false; + } + + if (url.protocol === "file:" || fs.existsSync(input)) { + return false; + } + + if (!url.host) { + return false; + } + + return true; + } catch (_) { + return false; + } +}; + export const deleteFile = async (filePath: string) => { return await fsExtra.remove(filePath); }; From 4801fc0557ef40ae0c3331b3a3e7dcc74f766796 Mon Sep 17 00:00:00 2001 From: Shield-Jaguar Date: Wed, 3 Sep 2025 16:41:47 +0500 Subject: [PATCH 57/77] refactor quickstart --- src/actions/portal/quickstart.ts | 276 +++++++++-------------------- src/infrastructure/file-service.ts | 15 ++ src/prompts/api/transform.ts | 2 +- src/prompts/format.ts | 51 +++++- src/prompts/portal/copilot.ts | 19 +- src/prompts/portal/generate.ts | 8 +- src/prompts/portal/quickstart.ts | 90 +++------- src/prompts/sdk/generate.ts | 4 +- src/types/build-context.ts | 14 +- src/types/build/build.ts | 4 +- src/types/resource-context.ts | 21 +++ 11 files changed, 216 insertions(+), 288 deletions(-) diff --git a/src/actions/portal/quickstart.ts b/src/actions/portal/quickstart.ts index aeb8876dd..3965e453e 100644 --- a/src/actions/portal/quickstart.ts +++ b/src/actions/portal/quickstart.ts @@ -4,39 +4,30 @@ import { withDirPath } from "../../infrastructure/tmp-extensions.js"; import { ZipService } from "../../infrastructure/zip-service.js"; import { PortalQuickstartPrompts } from "../../prompts/portal/quickstart.js"; import { DirectoryPath } from "../../types/file/directoryPath.js"; -import { FilePath } from "../../types/file/filePath.js"; import { UrlPath } from "../../types/file/urlPath.js"; import { LoginAction } from "../auth/login.js"; -import { Result } from "../../types/common/result.js"; -import { err, ok, Result as ResultEx } from "neverthrow"; import { ActionResult } from "../action-result.js"; import { PortalServeAction } from "./serve.js"; -import { ResourceContext } from "../../types/resource-context.js"; -import { FileName } from "../../types/file/fileName.js"; import { CommandMetadata } from "../../types/common/command-metadata.js"; -import { createResourceInput } from "../../types/file/resource-input.js"; import { ValidateAction } from "../api/validate.js"; -import { ServiceError } from "../../infrastructure/api-utils.js"; import { BuildContext } from "../../types/build-context.js"; import { TempContext } from "../../types/temp-context.js"; import { FileDownloadService } from "../../infrastructure/services/file-download-service.js"; import { getLanguagesConfig } from "../../types/build/build.js"; -import { FileMetadataService } from "../../infrastructure/services/file-metadata-service.js"; +import { FilePath } from "../../types/file/filePath.js"; + -const defaultSpecUrl: UrlPath = new UrlPath( - "https://raw.githubusercontent.com/apimatic/static-portal-workflow/refs/heads/master/spec/openapi.json" -); const defaultPort: number = 3000 as const; export class PortalQuickstartAction { private readonly prompts: PortalQuickstartPrompts = new PortalQuickstartPrompts(); private readonly zipService: ZipService = new ZipService(); private readonly fileService: FileService = new FileService(); - private readonly fileMetadataService: FileMetadataService = new FileMetadataService(); private readonly configDir: DirectoryPath; private readonly commandMetadata: CommandMetadata; private readonly fileDownloadService = new FileDownloadService(); - private readonly zipUrl = `https://github.com/apimatic/static-portal-workflow/archive/refs/heads/master.zip` as const; + private readonly buildFileUrl = new UrlPath(`https://github.com/apimatic/static-portal-workflow/archive/refs/heads/master.zip`); + private readonly defaultSpecUrl = new UrlPath(`https://github.com/apimatic/static-portal-workflow/archive/refs/heads/master.zip`); private readonly repositoryFolderName = "static-portal-workflow-master" as const; constructor(configDir: DirectoryPath, commandMetadata: CommandMetadata) { @@ -47,101 +38,100 @@ export class PortalQuickstartAction { public readonly execute = async (): Promise => { this.prompts.welcomeMessage(); - const authenticateUserResult = await this.authenticateUser(); - if (authenticateUserResult.isErr()) { - return ActionResult.failed(); + const storedAuth = await getAuthInfo(this.configDir.toString()); + if (!storedAuth?.authKey) { + const loginResult = await new LoginAction(this.configDir, this.commandMetadata).execute(); + if (loginResult.isFailed()) { + return ActionResult.failed(); + } } return await withDirPath(async (tempDirectory: DirectoryPath): Promise => { // Step 1/4 - this.prompts.importSpecStep(); - let inputPath = await this.prompts.specPathPrompt(defaultSpecUrl); - if (!inputPath) { - this.prompts.noSpecSpecified(); - return ActionResult.cancelled(); - } - const resourceContext = new ResourceContext(tempDirectory); - const specPath = await resourceContext.resolveTo(inputPath); - if (specPath.isErr()) { - this.prompts.serviceError(specPath.error); - } - - - while (true) { - - - - if (!UrlPath.create(inputPath)) { - try { - const resourcePath = createResourceInput(inputPath); - if (!(await this.fileService.fileExists(resourcePath as FilePath))) { - this.prompts.specFileDoesNotExist(); - continue; // re-prompt - } - - break; // valid file path. - } catch { - this.prompts.invalidSpecFilePath(); - continue; // re-prompt - } + let specPath: FilePath | undefined; + while (!specPath) { + const inputPath = await this.prompts.specPathPrompt(this.defaultSpecUrl); + if (!inputPath) { + this.prompts.noSpecSpecified(); + return ActionResult.cancelled(); } - try { - const contentType = await this.fileMetadataService.contentType(inputPath); - - if (contentType?.includes("text/html")) { - this.prompts.invalidSpecUrl(); - continue; // re-prompt + if (inputPath instanceof UrlPath) { + const downloadFileResult = await this.prompts.downloadSpecFile( + this.fileDownloadService.downloadFile(inputPath) + ); + if (downloadFileResult.isErr()) { + this.prompts.serviceError(downloadFileResult.error); + } else { + const tempContext = new TempContext(tempDirectory); + specPath = await tempContext.save(downloadFileResult.value); + } + } else { + const fileExists = await this.fileService.fileExists(inputPath); + if (!fileExists) { + this.prompts.specFileDoesNotExist(); + } else { + specPath = inputPath; } - } catch { - this.prompts.unreachableUrl(); - continue; // re-prompt } - - break; // valid URL. - } - - - - const specDirectory = tempDirectory.join("spec"); - await this.fileService.createDirectoryIfNotExists(specDirectory); - await this.fileService.copy(result.value, result.value.replaceDirectory(specDirectory)); - return ok(specDirectory); - - - const specDirectory = await this.importSpec(tempDirectory); - if (specDirectory.isErr()) { - return ActionResult.failed(); } // Step 2/4 - const validatedSpecDirectory = await this.validateSpec(tempDirectory, specDirectory.value); - if (validatedSpecDirectory.isFailed()) { - return ActionResult.failed(); - } - if (validatedSpecDirectory.isCancelled()) { - this.prompts.fixYourSpec(); - return ActionResult.cancelled(); + this.prompts.validateSpecStep(); + const validateAction = new ValidateAction(this.configDir, this.commandMetadata); + const validationResult = await validateAction.execute(specPath, false); + + if (validationResult.isFailed()) { + if (!(await this.prompts.useDefaultSpecPrompt())) { + this.prompts.fixYourSpec(); + return ActionResult.cancelled(); + } + const downloadFileResult = await this.prompts.downloadSpecFile( + this.fileDownloadService.downloadFile(this.defaultSpecUrl) + ); + if (downloadFileResult.isErr()) { + this.prompts.serviceError(downloadFileResult.error); + } else { + const tempContext = new TempContext(tempDirectory); + specPath = await tempContext.save(downloadFileResult.value); + } } // Step 3/4 - const selectedLanguages = await this.selectLanguages(); - if (selectedLanguages.isErr()) { + this.prompts.selectLanguagesStep(); + const languages = await this.prompts.selectLanguagesPrompt(); + if (!languages) { + this.prompts.noLanguagesSelected(); return ActionResult.cancelled(); } // Step 4/4 - const inputDirectory = await this.selectInputDirectory(); - if (inputDirectory.isErr()) { - return ActionResult.cancelled(); + this.prompts.selectInputDirectoryStep(); + let inputDirectory: DirectoryPath | undefined; + while (true) { + inputDirectory = await this.prompts.inputDirectoryPathPrompt(); + if (!inputDirectory) { + this.prompts.noInputDirectoryProvided(); + return ActionResult.cancelled(); + } + + if (!(await this.fileService.directoryExists(inputDirectory))) { + this.prompts.inputDirectoryPathDoesNotExist(inputDirectory); + continue; + } + + if (!(await this.fileService.directoryEmpty(inputDirectory))) { + this.prompts.inputDirectoryNotEmpty(inputDirectory); + continue; + } + break; } const masterBuildFile = await this.prompts.downloadBuildDirectory( - this.fileDownloadService.downloadFile(new UrlPath(this.zipUrl)) + this.fileDownloadService.downloadFile(this.buildFileUrl) ); - if (masterBuildFile.isErr()) { this.prompts.serviceError(masterBuildFile.error); return ActionResult.failed(); @@ -152,128 +142,28 @@ export class PortalQuickstartAction { const extractedFolder = tempDirectory.join(this.repositoryFolderName); const tempBuildContext = new BuildContext(extractedFolder); - await tempBuildContext.replaceDefaultSpec(specDirectory.value); + await tempBuildContext.replaceDefaultSpec(specPath); await tempBuildContext.deleteWorkflowDir(); const buildFile = await tempBuildContext.getBuildFileContents(); - buildFile.generatePortal!.languageConfig = getLanguagesConfig(selectedLanguages); + buildFile.generatePortal!.languageConfig = getLanguagesConfig(languages); await tempBuildContext.updateBuildFileContents(buildFile); - const sourceDirectory = inputDirectory.value.join("src"); + const sourceDirectory = inputDirectory.join("src"); await this.fileService.copyDirectoryContents(extractedFolder, sourceDirectory); const buildDirectoryStructure = await this.fileService.getDirectory(sourceDirectory); this.prompts.printDirectoryStructure(buildDirectoryStructure); - const portalDirectory = inputDirectory.value.join("portal"); - const servePortalResult = await this.servePortal(sourceDirectory, portalDirectory); - if (servePortalResult.isErr()) { + const portalDirectory = inputDirectory.join("portal"); + const portalServeAction = new PortalServeAction(this.configDir, this.commandMetadata, null); + const result = await portalServeAction.execute(sourceDirectory, portalDirectory, defaultPort, true, false, () => { + this.prompts.nextSteps(); + }); + if (result.isFailed()) { return ActionResult.failed(); } return ActionResult.success(); }); }; - - private async authenticateUser(): Promise> { - const storedAuth = await getAuthInfo(this.configDir.toString()); - if (storedAuth?.authKey) { - return ok(); - } - const loginResult = await new LoginAction(this.configDir, this.commandMetadata).execute(); - if (loginResult.isFailed()) { - return err(); - } - return ok(); - } - - private async validateSpec( - tempDirectory: DirectoryPath, - specDirectory: DirectoryPath - ): Promise> { - this.prompts.validateSpecStep(); - const specZipFilePath = new FilePath(tempDirectory, new FileName("spec.zip")); - await this.zipService.archive(specDirectory, specZipFilePath); - - const validateAction = new ValidateAction(this.configDir, this.commandMetadata); - const validationResult = await validateAction.execute(specZipFilePath, false); - - if (validationResult.isFailed()) { - if (!(await this.prompts.useDefaultSpecPrompt())) { - return Result.cancelled(specDirectory); - } - - const resourceContext = new ResourceContext(tempDirectory); - const result = await resourceContext.resolveTo(defaultSpecUrl); - if (result.isErr()) { - this.prompts.serviceError(result.error); - return Result.failure(result.error); - } - await this.fileService.cleanDirectory(specDirectory); - await this.fileService.copy(result.value, result.value.replaceDirectory(specDirectory)); - } - - return Result.success(specDirectory); - } - - private async selectLanguages(): Promise> { - this.prompts.selectLanguagesStep(); - - const languages = await this.prompts.selectLanguagesPrompt(); - if (!languages) { - this.prompts.noLanguagesSelected(); - return err(); - } - - return ok(languages); - } - - private async selectInputDirectory(): Promise> { - this.prompts.selectInputDirectoryStep(); - let inputDirectory: DirectoryPath | undefined; - while (true) { - inputDirectory = await this.prompts.inputDirectoryPathPrompt(); - if (!inputDirectory) { - this.prompts.noInputDirectoryProvided(); - return err(); - } - - if (!(await this.fileService.directoryExists(inputDirectory)) && inputDirectory.toString() !== "./") { - this.prompts.inputDirectoryPathDoesNotExist(); - continue; - } - - const directory = await this.fileService.getDirectory(inputDirectory); - if (directory.items.length > 0) { - this.prompts.inputDirectoryNotEmpty(); - continue; - } - - break; - } - - return ok(inputDirectory); - } - - private async servePortal( - buildDirectory: DirectoryPath, - portalDirectory: DirectoryPath - ): Promise> { - const portalServeAction = new PortalServeAction(this.configDir, this.commandMetadata, null); - const result = await portalServeAction.execute(buildDirectory, portalDirectory, defaultPort, true, false, () => { - this.prompts.nextSteps(); - }); - - // TODO: Figure out a better way for this. - return result.mapAll>( - () => { - return ok("Generated portal and served it successfully."); - }, - () => { - return err(result.getMessage()); - }, - () => { - return err(result.getMessage()); - } - ); - } } diff --git a/src/infrastructure/file-service.ts b/src/infrastructure/file-service.ts index 1d4170ac8..2b8b0c7f6 100644 --- a/src/infrastructure/file-service.ts +++ b/src/infrastructure/file-service.ts @@ -106,6 +106,21 @@ export class FileService { public async copy(source: FilePath, destination: FilePath) { await fsExtra.copyFile(source.toString(), destination.toString()); } + + public async isZipFile(filePath: FilePath): Promise { + try { + const buffer = await fsExtra.readFile(filePath.toString()); + return ( + buffer.length >= 4 && + buffer[0] === 0x50 && // P + buffer[1] === 0x4b && // K + buffer[2] === 0x03 && // \x03 + buffer[3] === 0x04 + ); // \x04 + } catch { + return false; + } + } } const streamPipeline = promisify(pipeline); diff --git a/src/prompts/api/transform.ts b/src/prompts/api/transform.ts index 16473e3e2..e0c1215e1 100644 --- a/src/prompts/api/transform.ts +++ b/src/prompts/api/transform.ts @@ -8,7 +8,7 @@ import { getErrorMessage, ServiceError } from "../../infrastructure/api-utils.js export class ApiTransformPrompts { public async overwriteApi(directory: DirectoryPath): Promise { const overwrite = await confirm({ - message: `A specification file already exists at ${f.path(directory.toString())}. Do you want to overwrite the existing file?`, + message: `A specification file already exists at ${f.path(directory)}. Do you want to overwrite the existing file?`, initialValue: false }); diff --git a/src/prompts/format.ts b/src/prompts/format.ts index 51172fb37..be4d70f0b 100644 --- a/src/prompts/format.ts +++ b/src/prompts/format.ts @@ -1,13 +1,15 @@ import pc from "picocolors"; import { Result } from "neverthrow"; -import { intro as i, outro as o, spinner } from '@clack/prompts'; +import { intro as i, outro as o, spinner } from "@clack/prompts"; import { ActionResult } from "../actions/action-result.js"; - +import { Directory } from "../types/file/directory.js"; +import { DirectoryPath } from "../types/file/directoryPath.js"; +import { FilePath } from "../types/file/filePath.js"; export const format = { // Core element types var: (text: string) => pc.magenta(`'${text}'`), - path: (text: string) => pc.cyan(`'${text}'`), + path: (text: DirectoryPath | FilePath) => pc.cyan(`'${text}'`), link: (text: string) => pc.blue(`${text}`), // TODO: merge with Saeed's implementation description: (text: string) => pc.greenBright(`${text}`), // TODO: merge with Saeed's implementation cmd: (cmd: string, topic: string, action: string) => `${pc.blue(cmd)} ${pc.dim(topic)} ${pc.dim(action)}`, @@ -35,23 +37,22 @@ export function intro(text: string) { } export function outro(result: ActionResult) { - const exitCode = result.getExitCode(); const message = result.getMessage(); const outroMessage = result.mapAll( () => format.outroSuccess(message), () => format.outroFailure(message), - () => format.outroCancelled(message)); + () => format.outroCancelled(message) + ); o(outroMessage); process.exitCode = exitCode; } - export async function withSpinner(intro: string, success: string, failure: string, fn: Promise>) { const s = spinner({ cancelMessage: "cancelled", errorMessage: "failed", - frames: ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'], + frames: ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"] }); s.start(intro); const result = await fn; @@ -61,3 +62,39 @@ export async function withSpinner(intro: string, success: string, failure: ); return result; } + +export function getDirectoryTree(dir: Directory, prefix: string = "", isLast: boolean = true): string { + const folderDescription: Record = { + spec: "# Contains all API definition files", + content: "# Includes custom documentation pages in Markdown", + static: "# Includes all static files, such as images, GIFs, and PDFs" + }; + + const fileDescriptions: Record = { + "toc.yml": "# Controls the structure of the side navigation bar in the API portal", + "APIMATIC-BUILD.json": "# Defines all configurations for the API portal, including programming languages and themes" + }; + + const pointer = isLast ? "└─ " : "├─ "; + const folderName = dir.directoryPath.leafName(); + const description = folderDescription[folderName] ? format.description(folderDescription[folderName]) : ""; + let output = `${prefix}${pointer}${folderName}${description ? " " + description : ""}\n`; + + const items = dir.items; + const newPrefix = prefix + (isLast ? " " : "| "); + + items.forEach((item, index) => { + const last = index === items.length - 1; + + if (item instanceof Directory) { + output += getDirectoryTree(item, newPrefix, last); + } else { + const filePointer = last ? "└─ " : "├─ "; + const fileName = item.toString(); + const fileDescription = fileDescriptions[fileName] ? format.description(fileDescriptions[fileName]) : ""; + output += `${newPrefix}${filePointer}${fileName}${fileDescription ? " " + fileDescription : ""}\n`; + } + }); + + return output; +} diff --git a/src/prompts/portal/copilot.ts b/src/prompts/portal/copilot.ts index 9527dcd55..ca8c3ea83 100644 --- a/src/prompts/portal/copilot.ts +++ b/src/prompts/portal/copilot.ts @@ -1,4 +1,4 @@ -import { confirm, isCancel, log, note, outro, select } from "@clack/prompts"; +import { confirm, isCancel, log, note, select } from "@clack/prompts"; import { Result } from "neverthrow"; import { SubscriptionInfo } from "../../types/api/account.js"; import { getErrorMessage, ServiceError } from "../../infrastructure/api-utils.js"; @@ -40,13 +40,22 @@ export class PortalCopilotPrompts { Copilot ID: ${f.var(copilotId)} Status: ${f.var(status ? "Enabled" : "Disabled")} - Configuration saved to: ${f.path('APIMATIC-BUILD.json')}`); + Configuration saved to: ${f.var("APIMATIC-BUILD.json")}` + ); note( - `API Copilot will index your content the next time you run ${f.cmd('apimatic', 'portal', 'generate')} or ${f.cmd('apimatic', 'portal', 'serve')}. + `API Copilot will index your content the next time you run ${f.cmd("apimatic", "portal", "generate")} or ${f.cmd( + "apimatic", + "portal", + "serve" + )}. This process can take up to 10 minutes, depending on your API’s size. -To see your copilot: If your portal is already running, refresh the page. Otherwise, run ${f.cmd('apimatic', 'portal', 'serve')}, +To see your copilot: If your portal is already running, refresh the page. Otherwise, run ${f.cmd( + "apimatic", + "portal", + "serve" + )}, select any programming language in the Portal and look for the chat icon in the bottom-right corner.`, `Next steps` @@ -91,7 +100,7 @@ select any programming language in the Portal and look for the chat icon in the } public srcDirectoryEmpty(directory: DirectoryPath) { - const message = `The ${f.var("src")} directory is either empty or invalid: ${f.path(directory.toString())}`; + const message = `The ${f.var("src")} directory is either empty or invalid: ${f.path(directory)}`; log.error(message); } diff --git a/src/prompts/portal/generate.ts b/src/prompts/portal/generate.ts index fe2d65c47..c7053d405 100644 --- a/src/prompts/portal/generate.ts +++ b/src/prompts/portal/generate.ts @@ -7,7 +7,7 @@ import { FilePath } from "../../types/file/filePath.js"; export class PortalGeneratePrompts { public async overwritePortal(directory: DirectoryPath): Promise { const overwrite = await confirm({ - message: `The destination ${f.path(directory.toString())} is not empty, do you want to overwrite?`, + message: `The destination ${f.path(directory)} is not empty, do you want to overwrite?`, initialValue: false }); @@ -20,13 +20,13 @@ export class PortalGeneratePrompts { public directoryCannotBeSame(directory: DirectoryPath) { const message = `The ${f.var("src")} and ${f.var("portal")} directories must be different. Current value: ${f.path( - directory.toString() + directory )}`; log.error(message); } public srcDirectoryEmpty(directory: DirectoryPath) { - const message = `The ${f.var("src")} directory is either empty or invalid: ${f.path(directory.toString())}`; + const message = `The ${f.var("src")} directory is either empty or invalid: ${f.path(directory)}`; log.error(message); } @@ -45,7 +45,7 @@ export class PortalGeneratePrompts { portalGenerationErrorWithReport(reportPath: FilePath) { const message = `An error occurred during portal generation due to an issue with the input. -An error report has been written at the destination path: ${f.path(reportPath.toString())}`; +An error report has been written at the destination path: ${f.path(reportPath)}`; log.error(message); } } diff --git a/src/prompts/portal/quickstart.ts b/src/prompts/portal/quickstart.ts index f9e2f46ff..209bc7c58 100644 --- a/src/prompts/portal/quickstart.ts +++ b/src/prompts/portal/quickstart.ts @@ -1,7 +1,7 @@ import { Result } from "neverthrow"; import { isCancel, log, multiselect, note, select, text } from "@clack/prompts"; import { UrlPath } from "../../types/file/urlPath.js"; -import { format as f, withSpinner } from "../format.js"; +import { format as f, withSpinner, getDirectoryTree } from "../format.js"; import { DirectoryPath } from "../../types/file/directoryPath.js"; import { removeQuotes } from "../../utils/string-utils.js"; import { getErrorMessage, ServiceError } from "../../infrastructure/api-utils.js"; @@ -16,12 +16,10 @@ const defaultPortalDirectoryPath = process.cwd(); export class PortalQuickstartPrompts { public welcomeMessage() { log.info(`Hello there.`); - log.message( - `This wizard will help you set up an API Portal via APIMatic's Docs as Code workflow in 4 simple steps.` - ); - log.message(`Let's get started!`); + const message = `This wizard will help you set up an API Portal via APIMatic's Docs as Code workflow in 4 simple steps. +Let's get started!`; + log.message(message); } - public importSpecStep() { const message = `Step 1 of 4: Import your OpenAPI Definition`; log.step(message); @@ -32,17 +30,16 @@ export class PortalQuickstartPrompts { 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 a sample OpenAPI file from APIMatic.", defaultValue: defaultSpecUrl.toString(), - validate: value => { - if (!value || !createResourceInputFromInput(value)) { + + validate: (value) => { + if (value && !createResourceInputFromInput(value)) { return "Please enter a valid file path or URL."; } } }); - if (isCancel(spec)) { return undefined; } - return createResourceInputFromInput(spec); } @@ -50,18 +47,6 @@ export class PortalQuickstartPrompts { log.error("The specified file does not exist or is not a valid file. Please enter a valid file path."); } - public invalidSpecFilePath() { - log.error("Invalid file path provided. Please enter a valid file path."); - } - - public invalidSpecUrl() { - log.error(`Invalid URL. Please check the URL and ensure it points to a valid OpenAPI definition.`); - } - - public unreachableUrl() { - log.error(`Failed to reach the URL. Please check your internet connection or the URL.`); - } - public noSpecSpecified() { log.error("No API definition was provided."); } @@ -77,11 +62,9 @@ export class PortalQuickstartPrompts { { value: "yes", label: `2. Use an example API spec instead (recommended)` } ] }); - if (isCancel(useDefaultSpec)) { return false; } - return useDefaultSpec === "yes"; } @@ -113,6 +96,7 @@ export class PortalQuickstartPrompts { { label: "PHP", value: "php" }, { label: "Go", value: "go" } ], + required: false, initialValues: ["typescript", "ruby", "python", "java", "csharp", "php", "go"] })) as string[]; @@ -146,20 +130,20 @@ export class PortalQuickstartPrompts { const cleanedPath = removeQuotes((inputDirectory as string)?.trim() ?? ""); const directoryPath = new DirectoryPath(cleanedPath); - if ((inputDirectory as string) === "./") { + if (inputDirectory === "./") { return new DirectoryPath(defaultPortalDirectoryPath); } else { return directoryPath; } } - public inputDirectoryPathDoesNotExist() { - log.error("Error: The specified directory path does not exist. Please try again."); + public inputDirectoryPathDoesNotExist(inputDirectory: DirectoryPath) { + log.error(`The specified directory path ${f.path(inputDirectory)} does not exists.`); } - public inputDirectoryNotEmpty() { + public inputDirectoryNotEmpty(inputDirectory: DirectoryPath) { log.error( - "Error: The target directory is not empty. Please provide a path to an empty directory or clear its contents." + `The target directory ${f.path(inputDirectory)} is not empty. Please provide a path to an empty directory or clear its contents.` ); } @@ -170,17 +154,20 @@ export class PortalQuickstartPrompts { public downloadBuildDirectory(fn: Promise>) { return withSpinner( "Downloading build directory", - `Build directory downloaded successfully.`, - "Unable to download build directory.", + `Build directory downloaded successfully`, + "Unable to download build directory", fn ); } + public downloadSpecFile(fn: Promise>) { + return withSpinner("Downloading Spec file", `Spec file downloaded`, "Unable to download spec file", fn); + } + public nextSteps(): void { const message = `Use the API Playground or an SDK to call your API. Customize the Portal theme, add API recipes and enable AI features ${f.link(referenceDocumentationUrl)}`; - note(message, "Next steps"); } @@ -189,44 +176,7 @@ ${f.link(referenceDocumentationUrl)}`; } public printDirectoryStructure(directory: Directory) { - const tree = this.getDirectoryTree(directory); + const tree = getDirectoryTree(directory); log.info(tree); } - - private getDirectoryTree(dir: Directory, prefix: string = "", isLast: boolean = true): string { - const folderDescription: Record = { - spec: "# Contains all API definition files", - content: "# Includes custom documentation pages in Markdown", - static: "# Includes all static files, such as images, GIFs, and PDFs" - }; - - const fileDescriptions: Record = { - "toc.yml": "# Controls the structure of the side navigation bar in the API portal", - "APIMATIC-BUILD.json": - "# Defines all configurations for the API portal, including programming languages and themes" - }; - - const pointer = isLast ? "└─ " : "├─ "; - const folderName = dir.directoryPath.leafName(); - const description = folderDescription[folderName] ? f.description(folderDescription[folderName]) : ""; - let output = `${prefix}${pointer}${folderName}${description ? " " + description : ""}\n`; - - const items = dir.items; - const newPrefix = prefix + (isLast ? " " : "| "); - - items.forEach((item, index) => { - const last = index === items.length - 1; - - if (item instanceof Directory) { - output += this.getDirectoryTree(item, newPrefix, last); - } else { - const filePointer = last ? "└─ " : "├─ "; - const fileName = item.toString(); - const fileDescription = fileDescriptions[fileName] ? f.description(fileDescriptions[fileName]) : ""; - output += `${newPrefix}${filePointer}${fileName}${fileDescription ? " " + fileDescription : ""}\n`; - } - }); - - return output; - } } diff --git a/src/prompts/sdk/generate.ts b/src/prompts/sdk/generate.ts index 5c21d76e1..770ebcf6f 100644 --- a/src/prompts/sdk/generate.ts +++ b/src/prompts/sdk/generate.ts @@ -20,12 +20,12 @@ export class SdkGeneratePrompts { public sameSpecAndSdkDir(directory: DirectoryPath) { const message = `The ${f.var("src")} and ${f.var("portal")} directories must be different. Current value: ${f.path( - directory.toString() + directory )}`; this.logGenerationError(message); } public invalidSpecDirectory(directory: DirectoryPath) { - const message = `The ${f.var("src")} directory is either empty or invalid: ${f.path(directory.toString())}`; + const message = `The ${f.var("src")} directory is either empty or invalid: ${f.path(directory)}`; this.logGenerationError(message); } diff --git a/src/types/build-context.ts b/src/types/build-context.ts index bf09396cb..9ad5aea08 100644 --- a/src/types/build-context.ts +++ b/src/types/build-context.ts @@ -3,10 +3,12 @@ import { DirectoryPath } from "./file/directoryPath.js"; import { FilePath } from "./file/filePath.js"; import { FileName } from "./file/fileName.js"; import { BuildConfig } from "./build/build.js"; +import { ZipService } from "../infrastructure/zip-service.js"; export class BuildContext { private readonly fileService = new FileService(); private readonly buildDirectory: DirectoryPath; + private readonly zipService = new ZipService(); constructor(buildDirectory: DirectoryPath) { this.buildDirectory = buildDirectory; @@ -18,7 +20,7 @@ export class BuildContext { } private get specDirectory(): DirectoryPath { - return this.buildDirectory.join("spec") + return this.buildDirectory.join("spec"); } public async validate(): Promise { @@ -41,9 +43,13 @@ export class BuildContext { await this.fileService.writeContents(this.buildFile, JSON.stringify(buildJson, null, 2)); } - public async replaceDefaultSpec(newSpecDirectory: DirectoryPath) { - await this.fileService.deleteFile(new FilePath(this.specDirectory, new FileName('openapi.json'))); - await this.fileService.copyDirectory(newSpecDirectory, this.specDirectory); + public async replaceDefaultSpec(specPath: FilePath) { + await this.fileService.deleteFile(new FilePath(this.specDirectory, new FileName("openapi.json"))); + if (await this.fileService.isZipFile(specPath)) { + await this.zipService.unArchive(specPath, this.specDirectory); + } else { + await this.fileService.copy(specPath, specPath.replaceDirectory(this.specDirectory)); + } } public async deleteWorkflowDir() { diff --git a/src/types/build/build.ts b/src/types/build/build.ts index 6ada191ef..a8c27b0a6 100644 --- a/src/types/build/build.ts +++ b/src/types/build/build.ts @@ -16,8 +16,8 @@ export interface CopilotConfig { welcomeMessage: string; } -export function getLanguagesConfig(selectedLanguages: { value: string[] }) { - return selectedLanguages.value.reduce((config, lang) => { +export function getLanguagesConfig(selectedLanguages: string[]) { + return selectedLanguages.reduce((config, lang) => { config[lang] = {}; return config; }, {} as { [key: string]: object }); diff --git a/src/types/resource-context.ts b/src/types/resource-context.ts index ef1ef7532..8aa703190 100644 --- a/src/types/resource-context.ts +++ b/src/types/resource-context.ts @@ -32,3 +32,24 @@ export class ResourceContext { return ok(destinationFilePath); } } + +export class ResourceInputContext { + private readonly fileDownloadService = new FileDownloadService(); + private readonly fileService = new FileService(); + + public async validate(inputPath: ResourceInput): Promise { + if (inputPath instanceof UrlPath) { + const downloadFileResult = await this.fileDownloadService.downloadFile(inputPath); + if (downloadFileResult.isErr()) { + return downloadFileResult.error; + } + + } else { + const fileExists = await this.fileService.fileExists(inputPath); + if (!fileExists) { + return undefined; + } + } + return inputPath; + } +} From 2a117f4eed47b902fcd448c555ac8d1fe2075bfc Mon Sep 17 00:00:00 2001 From: Shield-Jaguar Date: Wed, 3 Sep 2025 17:38:28 +0500 Subject: [PATCH 58/77] feat: fix merge conflicts --- src/actions/portal/recipe/new-recipe.ts | 76 ++++++------------- .../portal/recipe/recipe-generator.ts | 26 ++----- src/commands/portal/toc/new.ts | 25 ++---- src/infrastructure/services/portal-service.ts | 69 ++++++++--------- src/prompts/portal/recipe/new-recipe.ts | 15 +--- src/prompts/portal/toc/new-toc.ts | 27 +------ src/types/recipe-context.ts | 11 +-- src/types/recipe/recipe.ts | 5 -- src/types/sdl/sdl.ts | 24 ++++++ src/utils/utils.ts | 12 ++- 10 files changed, 106 insertions(+), 184 deletions(-) diff --git a/src/actions/portal/recipe/new-recipe.ts b/src/actions/portal/recipe/new-recipe.ts index 616ba4b3e..95794a991 100644 --- a/src/actions/portal/recipe/new-recipe.ts +++ b/src/actions/portal/recipe/new-recipe.ts @@ -1,7 +1,7 @@ import { PortalRecipePrompts } from "../../../prompts/portal/recipe/new-recipe.js"; -import { DirectoryNode, StepType } from "../../../types/recipe/recipe.js"; +import { StepType } from "../../../types/recipe/recipe.js"; import { PortalService } from "../../../infrastructure/services/portal-service.js"; -import { Sdl, SdlEndpoint } from "../../../types/sdl/sdl.js"; +import { getEndpointDescription, getEndpointGroupsFromSdl, SdlEndpoint } from "../../../types/sdl/sdl.js"; import { DirectoryPath } from "../../../types/file/directoryPath.js"; import { CommandMetadata } from "../../../types/common/command-metadata.js"; import { ActionResult } from "../../action-result.js"; @@ -9,7 +9,6 @@ import { TocContext } from "../../../types/toc-context.js"; import { FileName } from "../../../types/file/fileName.js"; import { PortalRecipe } from "../../../application/portal/recipe/portal-recipe.js"; import { PortalRecipeGenerator } from "../../../application/portal/recipe/recipe-generator.js"; -import { TreeObject } from "treeify"; import { BuildContext } from "../../../types/build-context.js"; import { ContentContext } from "../toc/new-toc.js"; import { SpecContext } from "../../../types/spec-context.js"; @@ -122,7 +121,7 @@ export class PortalRecipeAction { this.prompts.serviceError(sdlResult.error); return ActionResult.failed(); } - endpointGroups = PortalRecipeAction.getEndpointGroupsFromSdl(sdlResult.value); + endpointGroups = getEndpointGroupsFromSdl(sdlResult.value); } const endpointGroupName = await this.prompts.endpointGroupNamePrompt(endpointGroups); @@ -130,11 +129,7 @@ export class PortalRecipeAction { const endpointName = await this.prompts.endpointNamePrompt(endpointGroups, endpointGroupName); if (!endpointName) return ActionResult.cancelled(); - const defaultDescription = PortalRecipeAction.getEndpointDescription( - endpointGroups, - endpointGroupName, - endpointName - ); + const defaultDescription = getEndpointDescription(endpointGroups, endpointGroupName, endpointName); const description = await this.prompts.endpointDescriptionPrompt(defaultDescription); if (!description) return ActionResult.cancelled(); @@ -146,67 +141,44 @@ export class PortalRecipeAction { } while (await this.prompts.addAnotherStepSelectionPrompt()); const serializableRecipe = recipe.toSerializableRecipe(); - const buildConfig = await buildContext.getBuildFileContents(); // Generate the recipe const recipeGenerator = new PortalRecipeGenerator(); await recipeGenerator.createRecipe( serializableRecipe, - buildConfig, tocData, tocContext.tocPath, recipeName, recipeFileName, - buildContext.BuildFile, + buildContext, contentDirectory ); + // TODO: Sheild fix this toc final // Display build directory structure - const buildDirectoryStructure = this.getBuildDirectoryStructure(recipeFileName.toString()); - this.prompts.displayBuildDirectoryStructureAsTree(buildDirectoryStructure as TreeObject); + // const buildDirectoryStructure = this.getBuildDirectoryStructure(recipeFileName.toString()); + // this.prompts.displayBuildDirectoryStructureAsTree(buildDirectoryStructure as TreeObject); return ActionResult.success(); } // TODO: Refactor this after quick start merge - private getBuildDirectoryStructure(recipeFileName: string): DirectoryNode { - return { - content: { - "toc.yml : # Contains the API Recipes group with a new page for your API recipe": null - }, - static: { - scripts: { - recipes: { - [`${recipeFileName}.js : # Generated recipe script file containing all of the steps`]: null - } - } - } - }; - } - - private static getEndpointGroupsFromSdl(sdl: Sdl): Map { - const endpointGroups = new Map(); - for (const endpoint of sdl.Endpoints) { - if (!endpointGroups.has(endpoint.Group)) { - endpointGroups.set(endpoint.Group, []); - } - - endpointGroups.get(endpoint.Group)!.push({ - Name: endpoint.Name, - Description: endpoint.Description, - Group: endpoint.Group - }); - } - return endpointGroups; - } - - private static getEndpointDescription( - endpointGroups: Map, - endpointGroupName: string, - endpointName: string - ): string { - return endpointGroups.get(endpointGroupName)!.find((e) => e.Name === endpointName)!.Description; - } + // private getBuildDirectoryStructure(recipeFileName: string): Directory { + // + // const content = new Directory(new ) + // return { + // content: { + // "toc.yml : # Contains the API Recipes group with a new page for your API recipe": null + // }, + // static: { + // scripts: { + // recipes: { + // [`${recipeFileName}.js : # Generated recipe script file containing all of the steps`]: null + // } + // } + // } + // }; + // } private async promptForContent(): Promise { return await withDirPath(async (tempDir) => { diff --git a/src/application/portal/recipe/recipe-generator.ts b/src/application/portal/recipe/recipe-generator.ts index 59b73c4d0..aaba95afa 100644 --- a/src/application/portal/recipe/recipe-generator.ts +++ b/src/application/portal/recipe/recipe-generator.ts @@ -3,10 +3,10 @@ import { stringify } from "yaml"; import { SerializableRecipe, ContentStepConfig, EndpointStepConfig } from "../../../types/recipe/recipe.js"; import { DirectoryPath } from "../../../types/file/directoryPath.js"; import { FileService } from "../../../infrastructure/file-service.js"; -import { BuildConfig } from "../../../types/build/build.js"; import { Toc } from "../../../types/toc/toc.js"; import { FilePath } from "../../../types/file/filePath.js"; import { FileName } from "../../../types/file/fileName.js"; +import { BuildContext } from "../../../types/build-context.js"; export class PortalRecipeGenerator { //TODO: Replace tocFileContent any type with concrete type. @@ -14,16 +14,15 @@ export class PortalRecipeGenerator { public async createRecipe( recipe: SerializableRecipe, - buildConfig: BuildConfig, tocFileContent: Toc, tocFilePath: FilePath, recipeName: string, recipeFileName: FileName, - buildConfigFilePath: FilePath, + buildContext: BuildContext, contentFolderPath: DirectoryPath ) { await this.addRecipeToToc(tocFileContent, tocFilePath, recipeName, recipeFileName); - await this.registerRecipeInBuildConfigFile(buildConfig, recipeName, recipeFileName, buildConfigFilePath); + await this.registerRecipeInBuildConfigFile(buildContext, recipeName, recipeFileName); await this.createMarkdownFile(recipeFileName, contentFolderPath); const generatedRecipeScript = await this.createScriptFromRecipe(recipe); @@ -72,11 +71,12 @@ export class PortalRecipeGenerator { } private async registerRecipeInBuildConfigFile( - buildConfig: BuildConfig, + buildContext: BuildContext, recipeName: string, recipeFileName: FileName, - buildConfigFilePath: FilePath ): Promise { + + const buildConfig = await buildContext.getBuildFileContents(); if (!buildConfig.recipes) { buildConfig.recipes = {}; } @@ -104,16 +104,7 @@ export class PortalRecipeGenerator { recipesConfig.workflows.push(newWorkflow); } - await this.writeRecipesConfigToBuildConfigFile(recipesConfig, buildConfigFilePath); - } - - private async writeRecipesConfigToBuildConfigFile(recipesConfig: string, buildConfigFilePath: FilePath): Promise { - const fileData = await this.fileService.getContents(buildConfigFilePath); - const buildConfig = JSON.parse(fileData); - - buildConfig.recipes = recipesConfig; - - await this.fileService.writeContents(buildConfigFilePath, JSON.stringify(buildConfig, null, 2)); + await buildContext.updateBuildFileContents(buildConfig) } private async createMarkdownFile(recipeFileName: FileName, contentFolder: DirectoryPath): Promise { @@ -121,8 +112,7 @@ export class PortalRecipeGenerator { const markdownFileContent = this.getMarkdownFileContent(); await this.fileService.directoryExists(directory); - this.fileService.writeContents(new FilePath(directory, new FileName(`${recipeFileName}.md`)), markdownFileContent); - + await this.fileService.writeContents(new FilePath(directory, new FileName(`${recipeFileName}.md`)), markdownFileContent); } private async createScriptFromRecipe(recipe: SerializableRecipe): Promise { diff --git a/src/commands/portal/toc/new.ts b/src/commands/portal/toc/new.ts index fa10f4eaf..9018620ec 100644 --- a/src/commands/portal/toc/new.ts +++ b/src/commands/portal/toc/new.ts @@ -28,13 +28,13 @@ ${format.link( ...FlagsProvider.force, "expand-endpoints": Flags.boolean({ default: false, - description: `include individual entries for each endpoint in the generated ${format.path( + description: `include individual entries for each endpoint in the generated ${format.var( "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 ${format.path( + description: `include individual entries for each model in the generated ${format.var( "toc.yml" )}. Requires a valid API specification in the working directory.` }) @@ -49,18 +49,12 @@ ${format.link( async run(): Promise { const { - flags: { - input, - destination, - force, - "expand-endpoints": expandEndpoints, - "expand-models": expandModels - } + flags: { input, destination, force, "expand-endpoints": expandEndpoints, "expand-models": expandModels } } = await this.parse(PortalTocNew); const workingDirectory = new DirectoryPath(input ?? DEFAULT_WORKING_DIRECTORY); const buildDirectory = input ? new DirectoryPath(input, "src") : workingDirectory.join("src"); - const tocDirectory = destination? new DirectoryPath(destination): undefined; + const tocDirectory = destination ? new DirectoryPath(destination) : undefined; const commandMetadata: CommandMetadata = { commandName: PortalTocNew.id, @@ -69,13 +63,7 @@ ${format.link( intro("New TOC"); const action = new PortalNewTocAction(new DirectoryPath(this.config.configDir), commandMetadata); - const result = await action.execute( - buildDirectory, - tocDirectory, - force, - expandEndpoints, - expandModels - ); + const result = await action.execute(buildDirectory, tocDirectory, force, expandEndpoints, expandModels); outro(result); result.mapAll( @@ -84,7 +72,7 @@ ${format.link( const telemetryService = new TelemetryService(new DirectoryPath(this.config.configDir)); await telemetryService.trackEvent( // TODO: fix Toc error message - new TocCreationFailedEvent('error', PortalTocNew.id, { + new TocCreationFailedEvent("error", PortalTocNew.id, { input, destination, force, @@ -97,5 +85,4 @@ ${format.link( () => {} ); } - } diff --git a/src/infrastructure/services/portal-service.ts b/src/infrastructure/services/portal-service.ts index fa4e374ef..6b09b846b 100644 --- a/src/infrastructure/services/portal-service.ts +++ b/src/infrastructure/services/portal-service.ts @@ -17,7 +17,7 @@ import { Platforms } from "@apimatic/sdk"; import { AuthInfo, getAuthInfo } from "../../client-utils/auth-manager.js"; -import { getMessageInRedColor, parseStreamBodyToJson } from "../../utils/utils.js"; +import { parseStreamBodyToJson } from "../../utils/utils.js"; import { TransformationData } from "../../types/api/transform.js"; import { Sdl } from "../../types/sdl/sdl.js"; import { FilePath } from "../../types/file/filePath.js"; @@ -25,7 +25,7 @@ import { DirectoryPath } from "../../types/file/directoryPath.js"; import { FileService } from "../file-service.js"; import { apiClientFactory } from "./api-client-factory.js"; import { CommandMetadata } from "../../types/common/command-metadata.js"; -import { err, ok, Result} from "neverthrow"; +import { err, ok, Result } from "neverthrow"; import { Language } from "../../types/sdk/generate.js"; import { handleServiceError, ServiceError } from "../api-utils.js"; @@ -122,7 +122,7 @@ export class PortalService { } else { return err(ServiceError.InvalidResponse); } - } catch(error) { + } catch (error) { return err(handleServiceError(error)); } } @@ -140,25 +140,44 @@ export class PortalService { private static handlePortalGenerationErrors = async (error: unknown): Promise => { if (error instanceof UnauthorizedResponseError) { - //401 + // 401 return error.result?.message ?? "Authorization has been denied for this request."; - } else if (error instanceof ProblemDetailsError) { - //400 & 403 + } + if (error instanceof ProblemDetailsError) { + // 400 & 403 const probDetailsError = error as ProblemDetailsError; const message = Object.values(probDetailsError.result!.errors as Record)[0]?.[0] ?? null; return error.result!.title + "\n- " + message; - } else if (error instanceof ApiError && error.statusCode === 422) { - //422 + } + if (error instanceof ApiError && error.statusCode === 422) { + // 422 return error.body as NodeJS.ReadableStream; - } else if (error instanceof InternalServerErrorResponseError) { - //500 + } + if (error instanceof InternalServerErrorResponseError) { + // 422 const message = error.result?.message; return `${ message ?? "An unknown error occurred." } Please try again later or reach out to our team at support@apimatic.io for help if your problem persists.`; - } else { - return "An unexpected error occurred while generating the portal, please try again later. If the problem persists, please reach out to our team at support@apimatic.io"; } + return "An unexpected error occurred while generating the portal, please try again later. If the problem persists, please reach out to our team at support@apimatic.io"; + }; + + private handleSdkGenerationErrors = async (error: unknown): Promise => { + if (error instanceof BadRequestResponseSdkError) { + //400 + return this.parseBadRequestResponse(error.result?.message); + } + if (error instanceof UnauthorizedResponseError) { + //401 + return error.result?.message ?? "Authorization has been denied for this request."; + } + if (error instanceof ProblemDetailsError) { + // 403 + const message = (error.result!.errors as Record)?.[""]?.[0]; + return error.result!.title + "\n- " + message; + } + return "An unexpected error occurred while generating the portal, please try again later. If the problem persists, please reach out to our team at support@apimatic.io"; }; private parseBadRequestResponse(errorMessage: string | undefined): string { @@ -169,11 +188,11 @@ export class PortalService { // Parse the JSON string const parsedResult = JSON.parse(errorMessage); - // Check if it has the expected structure with Errors array + // Check if it has the expected structure with Errors if (parsedResult.Errors && Array.isArray(parsedResult.Errors) && parsedResult.Errors.length > 0) { // Get the first error and clean it up const firstError = parsedResult.Errors[0]; - // Split on
and take first part, then strip remaining HTML tags + // Split on
and take the first part, then strip remaining HTML tags return firstError.split("
")[0].replace(/<[^<>]*?>/g, ""); } else if (parsedResult.Success === false) { return "API definition file validation failed."; @@ -181,28 +200,6 @@ export class PortalService { return errorMessage; } - private handleSdkGenerationErrors = async (error: unknown): Promise => { - if (error instanceof BadRequestResponseSdkError) { - //400 - const badRequestError = error as BadRequestResponseSdkError; - const errorMessage = this.parseBadRequestResponse(badRequestError.result?.message); - return getMessageInRedColor(errorMessage); - } else if (error instanceof UnauthorizedResponseError) { - //401 - const unAuthError = error as UnauthorizedResponseError; - return getMessageInRedColor(unAuthError.result?.message ?? "Authorization has been denied for this request."); - } else if (error instanceof ProblemDetailsError) { - // 403 - const probDetailsError = error as ProblemDetailsError; - const message = (probDetailsError.result!.errors as Record)?.[""]?.[0]; - return getMessageInRedColor(probDetailsError.result!.title + "\n- " + message); - } else { - return getMessageInRedColor( - "An unexpected error occurred while generating the SDK, please try again later. If the problem persists, please reach out to our team at support@apimatic.io" - ); - } - }; - private readonly languagePlatform: Record = { [Language.CSHARP]: Platforms.CsNetStandardLib, [Language.JAVA]: Platforms.JavaEclipseJreLib, diff --git a/src/prompts/portal/recipe/new-recipe.ts b/src/prompts/portal/recipe/new-recipe.ts index 7a53c4471..d6a227de1 100644 --- a/src/prompts/portal/recipe/new-recipe.ts +++ b/src/prompts/portal/recipe/new-recipe.ts @@ -1,6 +1,4 @@ -import treeify from "treeify"; import { select, text, isCancel, log, autocomplete, confirm } from "@clack/prompts"; -import { getMessageInGreenColor } from "../../../utils/utils.js"; import { SdlEndpoint } from "../../../types/sdl/sdl.js"; import { DirectoryPath } from "../../../types/file/directoryPath.js"; import { format as f } from "../../format.js"; @@ -170,19 +168,8 @@ Let's proceed to adding steps to your API Recipe.`; log.step(`Step has been added successfully.`); } - public displayBuildDirectoryStructureAsTree(buildDirectoryTreeObject: treeify.TreeObject) { - const tree = treeify.asTree(buildDirectoryTreeObject, true, true); - - const coloredLogString = tree - .split("\n") - .map((line: string) => line.replace(/#.*/, (match: string) => getMessageInGreenColor(match))) - .join("\n"); - - log.step(`You can edit the following files to customize your API Recipe:\n\n` + coloredLogString); - } - public invalidBuildDirectory(directory: DirectoryPath) { - const message = `The ${f.var("src")} directory is either empty or invalid: ${f.path(directory.toString())}`; + const message = `The ${f.var("src")} directory is either empty or invalid: ${f.path(directory)}`; log.error(message); } diff --git a/src/prompts/portal/toc/new-toc.ts b/src/prompts/portal/toc/new-toc.ts index ffb2a06f6..51f1c891a 100644 --- a/src/prompts/portal/toc/new-toc.ts +++ b/src/prompts/portal/toc/new-toc.ts @@ -1,27 +1,12 @@ import { cancel, confirm, isCancel, log } from "@clack/prompts"; import { FilePath } from "../../../types/file/filePath.js"; import { Result } from "neverthrow"; -import { SdlTocComponents } from "../../../types/spec-context.js"; -import { format as f, format, withSpinner } from "../../format.js"; +import { format as f, withSpinner } from "../../format.js"; import { DirectoryPath } from "../../../types/file/directoryPath.js"; import { ServiceError } from "../../../infrastructure/api-utils.js"; import { Sdl } from "../../../types/sdl/sdl.js"; export class PortalNewTocPrompts { - public sdlComponentsExtractionFailed() { - log.error( - "Failed to extract endpoints from the API specification. Please validate your spec using APIMatic's interactive VS Code Extension and then try again." - ); - } - - public extractSdlComponents(fn: Promise>) { - return withSpinner( - "Extracting endpoints and/or models from the API specification", - "Extraction successful.", - "Extraction failed.", - fn - ); - } public generateTOC(fn: Promise>) { return withSpinner("Generating TOC", "TOC generated successfully.", "TOC generation failed.", fn); @@ -41,10 +26,6 @@ export class PortalNewTocPrompts { return overwrite; } - public contentGroupsExtractionFailed() { - log.error(`Failed to extract content groups.`); - } - public fallingBackToDefault() { log.warn(`Falling back to default TOC structure.`); } @@ -58,17 +39,13 @@ export class PortalNewTocPrompts { log.error(message); } - displayOutroMessage(tocPath: FilePath): void { - log.info(`${format.var("toc.yml")} file successfully created at: ${format.path(tocPath.toString())}`); - } - public contentDirectoryNotFound(contentFolderPath: DirectoryPath) { const message = `Content folder not found at: ${contentFolderPath}`; log.error(message); } public invalidBuildDirectory(directory: DirectoryPath) { - const message = `The ${f.var("src")} directory is either empty or invalid: ${f.path(directory.toString())}`; + const message = `The ${f.var("src")} directory is either empty or invalid: ${f.path(directory)}`; log.error(message); } diff --git a/src/types/recipe-context.ts b/src/types/recipe-context.ts index 325f2659e..b9cdfce72 100644 --- a/src/types/recipe-context.ts +++ b/src/types/recipe-context.ts @@ -15,16 +15,11 @@ export class RecipeContext { return false; } - // Check if recipe name or file name already exists + // Check if the recipe name or file name already exists const existingRecipe = apiRecipesGroup.items.find( (item) => "page" in item && "file" in item && (item.page === recipeName || item.file === `recipes/${recipeFileName}.md`) ); - - if (existingRecipe) { - return true; - } - - return false; + return !!existingRecipe; } -} \ No newline at end of file +} diff --git a/src/types/recipe/recipe.ts b/src/types/recipe/recipe.ts index 69b9b1ea2..2e294a0f5 100644 --- a/src/types/recipe/recipe.ts +++ b/src/types/recipe/recipe.ts @@ -25,8 +25,3 @@ export interface ContentStepConfig { } export type EndpointStepConfig = EndpointConfig - - -export interface DirectoryNode { - [key: string]: DirectoryNode | string | null | undefined; -} diff --git a/src/types/sdl/sdl.ts b/src/types/sdl/sdl.ts index b1b5c2b2f..85f443611 100644 --- a/src/types/sdl/sdl.ts +++ b/src/types/sdl/sdl.ts @@ -54,3 +54,27 @@ export function getEndpointGroupsAndModels(sdl: Sdl): SdlTocComponents { const models = extractModelsForToc(sdl); return { endpointGroups, models }; } + +export function getEndpointDescription( + endpointGroups: Map, + endpointGroupName: string, + endpointName: string +): string { + return endpointGroups.get(endpointGroupName)!.find((e) => e.Name === endpointName)!.Description; +} + +export function getEndpointGroupsFromSdl(sdl: Sdl): Map { + const endpointGroups = new Map(); + for (const endpoint of sdl.Endpoints) { + if (!endpointGroups.has(endpoint.Group)) { + endpointGroups.set(endpoint.Group, []); + } + + endpointGroups.get(endpoint.Group)!.push({ + Name: endpoint.Name, + Description: endpoint.Description, + Group: endpoint.Group + }); +} +return endpointGroups; +} diff --git a/src/utils/utils.ts b/src/utils/utils.ts index 6df496d4a..43b605f8b 100644 --- a/src/utils/utils.ts +++ b/src/utils/utils.ts @@ -1,7 +1,6 @@ import * as path from "path"; import { Buffer } from "buffer"; import stripTags from "striptags"; -import colors from "picocolors"; export const replaceHTML = (string: string) => { return stripTags(string); @@ -20,10 +19,9 @@ export async function parseStreamBodyToJson(body: NodeJS.ReadableStream): Promis return JSON.parse(text); } -export const getMessageInGreenColor = (message: string) => { - return colors.greenBright(message); -}; - -export const getMessageInRedColor = (message: string) => { - return colors.redBright(message); +export const toPascalCase = (str: string): string => { + return str + .split(" ") + .map((word) => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase()) + .join(""); }; From 46be11fc28c3b097cce7859d7bed4b033798176c Mon Sep 17 00:00:00 2001 From: Shield-Jaguar Date: Wed, 3 Sep 2025 18:03:46 +0500 Subject: [PATCH 59/77] feat: refactor --- eslint.config.js | 1 + src/client-utils/auth-manager.ts | 3 +- src/client-utils/sdk-client.ts | 44 ---------- src/hooks/not-found.ts | 10 +-- src/prompts/format.ts | 2 +- src/types/common/result.ts | 37 -------- src/types/events/domain-event.ts | 2 +- src/types/transform-context.ts | 13 ++- src/validators/common/directoryValidator.ts | 31 ------- src/validators/portal/serve-validator.ts | 19 ---- test/validators/serve-validator.test.ts | 97 --------------------- 11 files changed, 14 insertions(+), 245 deletions(-) delete mode 100644 src/client-utils/sdk-client.ts delete mode 100644 src/types/common/result.ts delete mode 100644 src/validators/common/directoryValidator.ts delete mode 100644 src/validators/portal/serve-validator.ts delete mode 100644 test/validators/serve-validator.test.ts diff --git a/eslint.config.js b/eslint.config.js index 04de4a1bd..0f7501960 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -13,6 +13,7 @@ export default [ globals: { process: "readonly", setTimeout: "readonly", + clearTimeout: "readonly", NodeJS: true, }, }, diff --git a/src/client-utils/auth-manager.ts b/src/client-utils/auth-manager.ts index 13addcb94..e71ee6229 100644 --- a/src/client-utils/auth-manager.ts +++ b/src/client-utils/auth-manager.ts @@ -14,8 +14,7 @@ export type AuthInfo = { */ export async function getAuthInfo(configDir: string): Promise { try { - const data: AuthInfo | null = JSON.parse(await fs.readFile(path.join(configDir, "config.json"), "utf8")); - return data; + return JSON.parse(await fs.readFile(path.join(configDir, "config.json"), "utf8")); } catch { return null; } diff --git a/src/client-utils/sdk-client.ts b/src/client-utils/sdk-client.ts deleted file mode 100644 index ffd50125d..000000000 --- a/src/client-utils/sdk-client.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { Client } from "@apimatic/sdk"; -import { AuthInfo, getAuthInfo, removeAuthInfo } from "./auth-manager.js"; - -/** - * The Singleton class defines the `getInstance` method that lets clients access - * the unique singleton instance. - */ -export class SDKClient { - public static client: Client; - private static instance: SDKClient; - - /** - * The static method that controls the access to the SDKClient instance. - * - * This implementation let you subclass the SDKClient class while keeping - * just one instance of each subclass around. - */ - public static getInstance(): SDKClient { - if (!SDKClient.instance) { - SDKClient.instance = new SDKClient(); - } - - return SDKClient.instance; - } - - public async status(configDir: string): Promise { - try { - let storedAuthInfo: AuthInfo | null = await getAuthInfo(configDir); - - if (!storedAuthInfo) { - // If no config file or no credentials exist in the config file - storedAuthInfo = { email: "", authKey: "" }; - } - - return storedAuthInfo.email === "" && storedAuthInfo.authKey === "" - ? "Not logged in" - : storedAuthInfo.email === "" && storedAuthInfo.authKey !== "" - ? "Logged in with authentication key" - : `Currently logged in as ${storedAuthInfo.email}`; - } catch (error) { - throw error as Error; - } - } -} diff --git a/src/hooks/not-found.ts b/src/hooks/not-found.ts index b7d55d7ec..b2cd7a4ce 100644 --- a/src/hooks/not-found.ts +++ b/src/hooks/not-found.ts @@ -15,12 +15,12 @@ const hook: Hook.CommandNotFound = async function (opts) { let binHelp = `${opts.config.bin} help`; const idSplit = opts.id.split(":"); - if (opts.config.findTopic(idSplit[0])) { + if (opts.config.findTopic(idSplit[0])) { binHelp = `${binHelp} ${idSplit[0]}`; } - + let suggestion: string | null; - if (/:?help:?/.test(opts.id)) { + if (/:?help:?/.test(opts.id)) { suggestion = ["help", ...opts.id.split(":").filter((cmd) => cmd !== "help")].join(":"); } else { suggestion = utils.closest(opts.id, commandIDs); @@ -30,14 +30,14 @@ const hook: Hook.CommandNotFound = async function (opts) { const originalCmd = toConfiguredId(opts.id, this.config); this.warn(`${yellow(originalCmd)} is not a ${opts.config.bin} command.`); - + if (!process.stdin.isTTY || !suggestion) { this.error(`Run ${cyan.bold(binHelp)} for a list of available commands.`, { exit: 127 }); } - let response = false; + let response: boolean; try { response = await utils.getConfirmation(readableSuggestion!); } catch { diff --git a/src/prompts/format.ts b/src/prompts/format.ts index 31023cae8..17d05eed6 100644 --- a/src/prompts/format.ts +++ b/src/prompts/format.ts @@ -12,7 +12,7 @@ export const format = { path: (text: DirectoryPath | FilePath) => pc.cyan(`'${text}'`), cmd: (cmd: string, ...args: string[]) => `${pc.blue(cmd)} ${args.map(arg => pc.dim(arg)).join(" ")}`, link: (text: string) => pc.underline(pc.blue(text)), - description: (text: string) => pc.greenBright(`${text}`), // TODO: merge with Saeed's implementation + description: (text: string) => pc.greenBright(`${text}`), flag: (name: string, value: string | undefined = undefined) => { if (value) { return `${pc.green(`--${name}`)}=${pc.dim(value)}`; diff --git a/src/types/common/result.ts b/src/types/common/result.ts deleted file mode 100644 index f93572f22..000000000 --- a/src/types/common/result.ts +++ /dev/null @@ -1,37 +0,0 @@ -export class Result { - private constructor( - private readonly Status: ResultStatus, - public readonly value?: T, - public readonly error?: TError - ) {} - - public static success(value: T): Result { - return new Result(ResultStatus.Success, value, undefined); - } - - public static failure(error: TError): Result { - return new Result(ResultStatus.Failed, undefined, error); - } - - public static cancelled(value: T): Result { - return new Result(ResultStatus.Cancelled, value, undefined); - } - - public isFailed(): boolean { - return this.Status == ResultStatus.Failed; - } - - public isCancelled(): boolean { - return this.Status == ResultStatus.Cancelled; - } - - public isSuccess(): boolean { - return this.Status == ResultStatus.Success; - } -} - -export enum ResultStatus { - Success, - Failed, - Cancelled -} diff --git a/src/types/events/domain-event.ts b/src/types/events/domain-event.ts index affc0cc7e..e2ab6037c 100644 --- a/src/types/events/domain-event.ts +++ b/src/types/events/domain-event.ts @@ -4,7 +4,7 @@ export abstract class DomainEvent { private readonly commandName: string; private readonly flags: string[]; - constructor(message: string, commandName: string, flags: Record) { + protected constructor(message: string, commandName: string, flags: Record) { this.message = message; this.commandName = commandName; this.flags = this.extractFlagKeys(flags); diff --git a/src/types/transform-context.ts b/src/types/transform-context.ts index 24ac120b7..67457a28e 100644 --- a/src/types/transform-context.ts +++ b/src/types/transform-context.ts @@ -11,27 +11,24 @@ export class TransformContext { private readonly transformedApi: FileName; - constructor(private readonly specFilePath: FilePath, - private readonly format: ExportFormats, + constructor(specFilePath: FilePath, + format: ExportFormats, private readonly destinationDirectory: DirectoryPath ) { this.transformedApi = this.parseFileName(format, specFilePath); } private get specPath(): FilePath { - this.fileService.createDirectoryIfNotExists(this.destinationDirectory); return new FilePath(this.destinationDirectory, this.transformedApi); } public async exists(): Promise { const transformedApiPath = new FilePath(this.destinationDirectory, this.transformedApi); - const fileExists = await this.fileService.fileExists(transformedApiPath); - if (fileExists) { - return true; - } - return false; + return await this.fileService.fileExists(transformedApiPath); + } public async save(stream: NodeJS.ReadableStream): Promise { + await this.fileService.createDirectoryIfNotExists(this.destinationDirectory); await this.fileService.writeFile(this.specPath, stream); } diff --git a/src/validators/common/directoryValidator.ts b/src/validators/common/directoryValidator.ts deleted file mode 100644 index edf974c75..000000000 --- a/src/validators/common/directoryValidator.ts +++ /dev/null @@ -1,31 +0,0 @@ -import fsExtra from "fs-extra"; -import { readdirSync } from "fs"; -import { Result } from "../../types/common/result.js"; - -export class DirectoryValidator { - public validateSourceDirectory(sourceDir: string): Result { - if (!fsExtra.pathExistsSync(sourceDir)) { - return Result.failure( - `The specified source directory does not exist: ${sourceDir}. Please provide a valid source directory to continue.` - ); - } - - const sourceDirItems = this.getDirectoryItems(sourceDir); - if (sourceDirItems.length == 0) { - return Result.failure( - "The source directory is empty. Please ensure that the directory contains a valid build input." - ); - } - if (!sourceDirItems.some((item) => item.endsWith("APIMATIC-BUILD.json"))) { - return Result.failure( - "The source directory is missing the 'APIMATIC-BUILD.json' file. Please add a valid build config file to continue." - ); - } - - return Result.success(`Source directory ${sourceDir} is valid.`); - } - - private getDirectoryItems(directoryPath: string): string[] { - return readdirSync(directoryPath).filter((item) => !item.startsWith(".")); - } -} diff --git a/src/validators/portal/serve-validator.ts b/src/validators/portal/serve-validator.ts deleted file mode 100644 index 771c352c5..000000000 --- a/src/validators/portal/serve-validator.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { Result } from "../../types/common/result.js"; -import { DirectoryValidator } from "../common/directoryValidator.js"; - -export class PortalServeValidator { - private readonly directoryValidator: DirectoryValidator; - - constructor() { - this.directoryValidator = new DirectoryValidator(); - } - - public async validateSourceDirectory(sourceDirectoryPath: string) : Promise> { - const sourceDirectoryValidationResult = this.directoryValidator.validateSourceDirectory(sourceDirectoryPath); - if (sourceDirectoryValidationResult.isFailed()) { - return Result.failure(sourceDirectoryValidationResult.error!); - } - - return Result.success("Serve flags validated successfully."); - } -} diff --git a/test/validators/serve-validator.test.ts b/test/validators/serve-validator.test.ts deleted file mode 100644 index 382ea04a9..000000000 --- a/test/validators/serve-validator.test.ts +++ /dev/null @@ -1,97 +0,0 @@ -import * as path from "path"; -import fsExtra from "fs-extra"; -import { expect } from "chai"; -import { dir as tmpDir, DirectoryResult } from "tmp-promise"; -import { PortalServeValidator } from "../../src/validators/portal/serve-validator.js"; -import { ServeFlags, ServePaths } from "../../src/types/portal/serve.js"; - -describe("PortalServeValidator", () => { - let TEST_WORKING_DIR: string; - let TEST_DEST_DIR: string; - let validator: PortalServeValidator; - let tmpDirResult: DirectoryResult; - - function getServeFlags(overrides: Partial = {}): ServeFlags { - return { - port: 3000, - folder: TEST_WORKING_DIR, - destination: TEST_DEST_DIR, - ignore: "", - open: false, - "auth-key": "", - "no-reload": false, - ...overrides - }; - } - - function getServePaths(flags: ServeFlags): ServePaths { - return { - sourceDirectoryPath: flags.folder, - destinationDirectoryPath: flags.destination, - generatedPortalArtifactsDirectoryPath: path.join(flags.destination, "generated_portal"), - generatedPortalArtifactsZipFilePath: path.join(flags.destination, ".generated_portal.zip") - }; - } - - beforeEach(async () => { - tmpDirResult = await tmpDir({ unsafeCleanup: true }); - TEST_WORKING_DIR = tmpDirResult.path; - TEST_DEST_DIR = path.join(TEST_WORKING_DIR, "dest"); - await fsExtra.ensureDir(TEST_WORKING_DIR); - await fsExtra.ensureDir(TEST_DEST_DIR); - await fsExtra.ensureDir(path.join(TEST_WORKING_DIR, "spec")) - await fsExtra.writeFile(path.join(TEST_WORKING_DIR, "APIMATIC-BUILD.json"), "{}", "utf8"); - await fsExtra.writeFile(path.join(TEST_WORKING_DIR, "spec", "spec.json"), "{}", "utf8"); - validator = new PortalServeValidator(); - }); - - afterEach(async () => { - await tmpDirResult.cleanup(); - }); - - it("should validate existing source directory", async () => { - const flags = getServeFlags(); - const paths = getServePaths(flags); - const result = await validator.validateSourceDirectory(paths.sourceDirectoryPath); - expect(result.isSuccess()).to.be.true; - }); - - it("should validate non-existent source directory", async () => { - const nonExistentFolder = path.join(TEST_WORKING_DIR, "does-not-exist"); - const flags = getServeFlags({ folder: nonExistentFolder }); - const paths = getServePaths(flags); - const result = await validator.validateSourceDirectory(paths.sourceDirectoryPath); - expect(result.isFailed()).to.be.true; - expect(result.error).to.include("The specified source directory does not exist"); - }); - - it("should fail if source directory is empty", async () => { - const src = path.join(TEST_WORKING_DIR, "no-buildfile-src"); - const dest = path.join(TEST_WORKING_DIR, "no-buildfile-dest"); - await fsExtra.ensureDir(src); - await fsExtra.ensureDir(dest); - const flags = getServeFlags({ folder: src, destination: dest }); - const paths = getServePaths(flags); - const result = await validator.validateSourceDirectory(paths.sourceDirectoryPath); - expect(result.isFailed()).to.be.true; - expect(result.error).to.include("The source directory is empty."); - await fsExtra.remove(src); - await fsExtra.remove(dest); - }); - - it("should fail if APIMATIC-BUILD.json is missing", async () => { - const src = path.join(TEST_WORKING_DIR, "no-buildfile-src"); - const dest = path.join(TEST_WORKING_DIR, "no-buildfile-dest"); - await fsExtra.ensureDir(src); - await fsExtra.ensureDir(dest); - await fsExtra.ensureDir(path.join(src, "spec")); - await fsExtra.writeFile(path.join(src, "spec", "spec.json"), "{}", "utf8"); - const flags = getServeFlags({ folder: src, destination: dest }); - const paths = getServePaths(flags); - const result = await validator.validateSourceDirectory(paths.sourceDirectoryPath); - expect(result.isFailed()).to.be.true; - expect(result.error).to.include("The source directory is missing the 'APIMATIC-BUILD.json' file."); - await fsExtra.remove(src); - await fsExtra.remove(dest); - }); -}); \ No newline at end of file From 09015406ec0974695623050ae9d4c3c47f2e71d2 Mon Sep 17 00:00:00 2001 From: Shield-Jaguar Date: Thu, 4 Sep 2025 10:09:36 +0500 Subject: [PATCH 60/77] feat: refactor to final version --- eslint.config.js | 1 + src/actions/portal/quickstart.ts | 13 ++- src/actions/portal/recipe/new-recipe.ts | 69 ++++++----- .../portal/recipe/recipe-generator.ts | 2 +- src/infrastructure/file-service.ts | 4 - .../services/file-download-service.ts | 108 +++++++++++++++++- src/prompts/api/validate.ts | 9 +- src/prompts/format.ts | 43 +++++++ src/prompts/portal/quickstart.ts | 15 ++- src/prompts/portal/recipe/new-recipe.ts | 7 +- src/types/api/transform.ts | 13 --- src/types/api/validate.ts | 13 --- src/types/file/directory.ts | 37 +++++- src/types/file/file.ts | 21 ---- src/types/portal/generate.ts | 14 --- src/types/resource-context.ts | 23 +--- src/types/sdl/sdl.ts | 4 +- src/types/spec-context.ts | 11 +- src/types/temp-context.ts | 2 +- src/types/toc-context.ts | 3 +- 20 files changed, 263 insertions(+), 149 deletions(-) delete mode 100644 src/types/api/validate.ts delete mode 100644 src/types/file/file.ts delete mode 100644 src/types/portal/generate.ts diff --git a/eslint.config.js b/eslint.config.js index 0f7501960..b3323789a 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -14,6 +14,7 @@ export default [ process: "readonly", setTimeout: "readonly", clearTimeout: "readonly", + URL: "readonly", NodeJS: true, }, }, diff --git a/src/actions/portal/quickstart.ts b/src/actions/portal/quickstart.ts index 3965e453e..b0042c095 100644 --- a/src/actions/portal/quickstart.ts +++ b/src/actions/portal/quickstart.ts @@ -15,6 +15,7 @@ import { TempContext } from "../../types/temp-context.js"; import { FileDownloadService } from "../../infrastructure/services/file-download-service.js"; import { getLanguagesConfig } from "../../types/build/build.js"; import { FilePath } from "../../types/file/filePath.js"; +import { SpecContext } from "../../types/spec-context.js"; const defaultPort: number = 3000 as const; @@ -27,7 +28,7 @@ export class PortalQuickstartAction { private readonly commandMetadata: CommandMetadata; private readonly fileDownloadService = new FileDownloadService(); private readonly buildFileUrl = new UrlPath(`https://github.com/apimatic/static-portal-workflow/archive/refs/heads/master.zip`); - private readonly defaultSpecUrl = new UrlPath(`https://github.com/apimatic/static-portal-workflow/archive/refs/heads/master.zip`); + private readonly defaultSpecUrl = new UrlPath(`https://raw.githubusercontent.com/apimatic/static-portal-workflow/refs/heads/master/spec/openapi.json`); private readonly repositoryFolderName = "static-portal-workflow-master" as const; constructor(configDir: DirectoryPath, commandMetadata: CommandMetadata) { @@ -65,8 +66,8 @@ export class PortalQuickstartAction { if (downloadFileResult.isErr()) { this.prompts.serviceError(downloadFileResult.error); } else { - const tempContext = new TempContext(tempDirectory); - specPath = await tempContext.save(downloadFileResult.value); + const specContext = new SpecContext(tempDirectory) + specPath = await specContext.save(downloadFileResult.value.stream, downloadFileResult.value.filename); } } else { const fileExists = await this.fileService.fileExists(inputPath); @@ -94,8 +95,8 @@ export class PortalQuickstartAction { if (downloadFileResult.isErr()) { this.prompts.serviceError(downloadFileResult.error); } else { - const tempContext = new TempContext(tempDirectory); - specPath = await tempContext.save(downloadFileResult.value); + const specContext = new SpecContext(tempDirectory) + specPath = await specContext.save(downloadFileResult.value.stream, downloadFileResult.value.filename); } } @@ -137,7 +138,7 @@ export class PortalQuickstartAction { return ActionResult.failed(); } const tempContext = new TempContext(tempDirectory); - const masterBuildFilePath = await tempContext.save(masterBuildFile.value); + const masterBuildFilePath = await tempContext.save(masterBuildFile.value.stream); await this.zipService.unArchive(masterBuildFilePath, tempDirectory); const extractedFolder = tempDirectory.join(this.repositoryFolderName); diff --git a/src/actions/portal/recipe/new-recipe.ts b/src/actions/portal/recipe/new-recipe.ts index 95794a991..a12afce87 100644 --- a/src/actions/portal/recipe/new-recipe.ts +++ b/src/actions/portal/recipe/new-recipe.ts @@ -18,6 +18,7 @@ import { FilePath } from "../../../types/file/filePath.js"; import { withDirPath } from "../../../infrastructure/tmp-extensions.js"; import { TempContext } from "../../../types/temp-context.js"; import { RecipeContext } from "../../../types/recipe-context.js"; +import { TreeNode } from "../../../prompts/format.js"; export class PortalRecipeAction { private readonly prompts: PortalRecipePrompts = new PortalRecipePrompts(); @@ -105,13 +106,7 @@ export class PortalRecipeAction { const specFileStream = await this.fileService.getStream(specZipPath); try { - const sdlResult = await this.portalService.generateSdl( - specFileStream, - this.configDirectory, - this.commandMetadata - ); - - return sdlResult; + return await this.portalService.generateSdl(specFileStream, this.configDirectory, this.commandMetadata); } finally { specFileStream.close(); } @@ -154,32 +149,12 @@ export class PortalRecipeAction { contentDirectory ); - // TODO: Sheild fix this toc final - // Display build directory structure - // const buildDirectoryStructure = this.getBuildDirectoryStructure(recipeFileName.toString()); - // this.prompts.displayBuildDirectoryStructureAsTree(buildDirectoryStructure as TreeObject); + const tocStructure = this.getTocStructure(recipeFileName); + this.prompts.displayTocStructure(tocStructure); return ActionResult.success(); } - // TODO: Refactor this after quick start merge - // private getBuildDirectoryStructure(recipeFileName: string): Directory { - // - // const content = new Directory(new ) - // return { - // content: { - // "toc.yml : # Contains the API Recipes group with a new page for your API recipe": null - // }, - // static: { - // scripts: { - // recipes: { - // [`${recipeFileName}.js : # Generated recipe script file containing all of the steps`]: null - // } - // } - // } - // }; - // } - private async promptForContent(): Promise { return await withDirPath(async (tempDir) => { const tempFile = new FilePath(tempDir, new FileName(`recipe-markdown-content.md`)); @@ -195,4 +170,40 @@ export class PortalRecipeAction { return fileContent.replace(/\r\n|\r/g, "\n"); }); } + + private getTocStructure(recipeFileName: FileName): TreeNode { + return { + name: "src", + items: [ + { + name: "content", + items: [ + { + name: "toc.yml", + description: "# Contains the API Recipes group with a new page for your API recipe" + } + ] + }, + { + name: "static", + items: [ + { + name: "scripts", + items: [ + { + name: "recipes", + items: [ + { + name: `${recipeFileName}`, + description: "# Generated recipe script file containing all of the steps" + } + ] + } + ] + } + ] + } + ] + }; + } } diff --git a/src/application/portal/recipe/recipe-generator.ts b/src/application/portal/recipe/recipe-generator.ts index aaba95afa..1173d41fc 100644 --- a/src/application/portal/recipe/recipe-generator.ts +++ b/src/application/portal/recipe/recipe-generator.ts @@ -154,7 +154,7 @@ export class PortalRecipeGenerator { generatedRecipeScript = await this.formatScript(generatedRecipeScript); } - this.fileService.createDirectoryIfNotExists(generatedRecipeScriptsDirectoryPath); + await this.fileService.createDirectoryIfNotExists(generatedRecipeScriptsDirectoryPath); await this.fileService.writeContents(new FilePath(generatedRecipeScriptsDirectoryPath, new FileName(`${recipeFileName}.js`)), generatedRecipeScript); } diff --git a/src/infrastructure/file-service.ts b/src/infrastructure/file-service.ts index 3a2981201..7d97af3f6 100644 --- a/src/infrastructure/file-service.ts +++ b/src/infrastructure/file-service.ts @@ -45,10 +45,6 @@ export class FileService { await fsExtra.ensureDir(dir.toString()); } - public async copyDirectory(source: DirectoryPath, destination: DirectoryPath) { - await fsExtra.copy(source.toString(), destination.toString()); - } - public async getDirectory(directoryPath: DirectoryPath): Promise { const entries = await fsExtra.readdir(directoryPath.toString()); const results = await Promise.all( diff --git a/src/infrastructure/services/file-download-service.ts b/src/infrastructure/services/file-download-service.ts index f7d7c814e..a8cdc871a 100644 --- a/src/infrastructure/services/file-download-service.ts +++ b/src/infrastructure/services/file-download-service.ts @@ -2,14 +2,116 @@ import axios from "axios"; import { UrlPath } from "../../types/file/urlPath.js"; import { err, ok, Result } from "neverthrow"; import { handleServiceError, ServiceError } from "../api-utils.js"; +import { FileName } from "../../types/file/fileName.js"; + +export type FileDownloadResponse = { + stream: NodeJS.ReadableStream; + filename: FileName; +}; export class FileDownloadService { - public async downloadFile(url: UrlPath): Promise> { + public async downloadFile(url: UrlPath): Promise> { try { - const response = await axios.get(url.toString(), { responseType: "stream" }); - return ok(response.data as NodeJS.ReadableStream); + const response = await axios.get(url.toString(), { + responseType: "stream" + }); + + const contentDisposition = response.headers["content-disposition"]; + let filename: FileName | undefined; + + // Try to parse filename from Content-Disposition (supports filename* as per RFC 5987 and plain filename) + if (contentDisposition) { + const parsed = this.parseFilenameFromContentDisposition(contentDisposition); + if (parsed) { + filename = new FileName(parsed); + } + } + + // ... existing code ... + // If no filename derived from headers, fallback to URL + if (!filename) { + const fromUrl = this.getFilenameFromUrl(url.toString()); + if (fromUrl) { + filename = new FileName(fromUrl); + } else { + filename = new FileName("file"); + } + } + + // Basic guard — responseType: "stream" should always yield a stream + const data = response.data as unknown; + const isReadableStream = data && typeof (data as NodeJS.ReadableStream).pipe === "function"; + + if (!isReadableStream) { + return err(ServiceError.InvalidResponse); + } + + return ok({ + stream: response.data as NodeJS.ReadableStream, + filename + }); } catch (error: unknown) { return err(handleServiceError(error)); } } + + private parseFilenameFromContentDisposition(headerValue: string): string | null { + // Try RFC 5987: filename*=UTF-8''encoded%20name.ext + const filenameStarMatch = headerValue.match(/filename\*\s*=\s*([^']*)''([^;]+)/i); + if (filenameStarMatch) { + const encoded = filenameStarMatch[2].trim(); + try { + const decoded = decodeURIComponent(encoded); + return this.sanitizeFilename(decoded); + } catch { + // fall through to other strategies + } + } + + // Try plain: filename="name.ext" or filename=name.ext + const filenameMatch = headerValue.match(/filename\s*=\s*"?([^";]+)"?/i); + if (filenameMatch) { + return this.sanitizeFilename(filenameMatch[1]); + } + + return null; + } + + private getFilenameFromUrl(rawUrl: string): string | null { + try { + const u = new URL(rawUrl); + const last = u.pathname.split("/").filter(Boolean).pop(); + if (!last) return null; + + // Remove any spurious trailing spaces and decode + const decoded = this.safeDecodeURIComponent(last.trim()); + return this.sanitizeFilename(decoded); + } catch { + // Fallback for non-URL-safe strings + const parts = rawUrl.split("/").filter(Boolean); + const last = parts.pop(); + if (!last) return null; + return this.sanitizeFilename(this.safeDecodeURIComponent(last)); + } + } + + private safeDecodeURIComponent(value: string): string { + try { + return decodeURIComponent(value); + } catch { + return value; + } + } + + + private sanitizeFilename(name: string): string { + // Replace characters not allowed in common filesystems and trim dots/spaces + const sanitized = name + .replace('/[<>:"/\\|?*\x00-\x1F]/g', "_") + .replace(/\s+/g, " ") + .trim(); + // Avoid names that are empty or only dots/spaces + const safe = sanitized.replace(/^[. ]+|[. ]+$/g, ""); + return safe.length > 0 ? safe : "file"; + } } diff --git a/src/prompts/api/validate.ts b/src/prompts/api/validate.ts index 773d65d16..057aab347 100644 --- a/src/prompts/api/validate.ts +++ b/src/prompts/api/validate.ts @@ -1,4 +1,4 @@ -import { spinner, log } from "@clack/prompts"; +import { log } from "@clack/prompts"; import { replaceHTML } from "../../utils/utils.js"; import { ValidationMessages } from "../../types/utils.js"; import { Result } from "neverthrow"; @@ -7,17 +7,10 @@ import { ApiValidationSummary } from "@apimatic/sdk"; import { getErrorMessage, ServiceError } from "../../infrastructure/api-utils.js"; export class ApiValidatePrompts { - private readonly spin = spinner(); - public async validateApi(fn: Promise>) { return withSpinner("Validating API", "API validated successfully.", "API validation failed.", fn); } - public async invalidFilePathProvided() { - const message = `Invalid file path or URL provided.`; - log.error(message); - } - displayValidationMessages({ warnings, errors, messages }: ValidationMessages): void { if (messages.length > 0) { log.info("Messages"); diff --git a/src/prompts/format.ts b/src/prompts/format.ts index 17d05eed6..c0b9a3dde 100644 --- a/src/prompts/format.ts +++ b/src/prompts/format.ts @@ -98,3 +98,46 @@ export function getDirectoryTree(dir: Directory, prefix: string = "", isLast: bo return output; } + + +export interface LeafNode { + name: string; + description?: string; +} + +export interface TreeNode extends LeafNode { + items: Array; +} + + +export function getTree( + dir: TreeNode, + prefix: string = "", + isLast: boolean = true +): string { + const pointer = isLast ? "└─ " : "├─ "; + const folderName = dir.name; + const description = dir.description ? format.description(dir.description) : ""; + + let output = `${prefix}${pointer}${folderName}${description ? " " + description : ""}\n`; + + const items = dir.items; + const newPrefix = prefix + (isLast ? " " : "| "); + + items.forEach((item, index) => { + const last = index === items.length - 1; + + if ('items' in item) { + output += getTree(item as TreeNode, newPrefix, last); + } else { + const filePointer = last ? "└─ " : "├─ "; + const fileName = item.name; + const fileDescription = item.description ? format.description(item.description) : ""; + output += `${newPrefix}${filePointer}${fileName}${fileDescription ? " " + fileDescription : ""}\n`; + } + }); + + return output; +} + + diff --git a/src/prompts/portal/quickstart.ts b/src/prompts/portal/quickstart.ts index 209bc7c58..a46a07d99 100644 --- a/src/prompts/portal/quickstart.ts +++ b/src/prompts/portal/quickstart.ts @@ -1,12 +1,13 @@ import { Result } from "neverthrow"; import { isCancel, log, multiselect, note, select, text } from "@clack/prompts"; import { UrlPath } from "../../types/file/urlPath.js"; -import { format as f, withSpinner, getDirectoryTree } from "../format.js"; +import { format as f, getTree, withSpinner } from "../format.js"; import { DirectoryPath } from "../../types/file/directoryPath.js"; import { removeQuotes } from "../../utils/string-utils.js"; import { getErrorMessage, ServiceError } from "../../infrastructure/api-utils.js"; import { Directory } from "../../types/file/directory.js"; import { createResourceInputFromInput, ResourceInput } from "../../types/file/resource-input.js"; +import { FileDownloadResponse } from "../../infrastructure/services/file-download-service.js"; const vscodeExtensionUrl = "https://marketplace.visualstudio.com/items?itemName=apimatic-developers.apimatic-for-vscode"; @@ -143,7 +144,9 @@ Let's get started!`; public inputDirectoryNotEmpty(inputDirectory: DirectoryPath) { log.error( - `The target directory ${f.path(inputDirectory)} is not empty. Please provide a path to an empty directory or clear its contents.` + `The target directory ${f.path( + inputDirectory + )} is not empty. Please provide a path to an empty directory or clear its contents.` ); } @@ -151,7 +154,7 @@ Let's get started!`; log.error("No build directory was provided."); } - public downloadBuildDirectory(fn: Promise>) { + public downloadBuildDirectory(fn: Promise>) { return withSpinner( "Downloading build directory", `Build directory downloaded successfully`, @@ -160,7 +163,7 @@ Let's get started!`; ); } - public downloadSpecFile(fn: Promise>) { + public downloadSpecFile(fn: Promise>) { return withSpinner("Downloading Spec file", `Spec file downloaded`, "Unable to download spec file", fn); } @@ -176,7 +179,7 @@ ${f.link(referenceDocumentationUrl)}`; } public printDirectoryStructure(directory: Directory) { - const tree = getDirectoryTree(directory); - log.info(tree); + const message = getTree(directory.toTreeNode()); + log.info(message); } } diff --git a/src/prompts/portal/recipe/new-recipe.ts b/src/prompts/portal/recipe/new-recipe.ts index d6a227de1..0140cd113 100644 --- a/src/prompts/portal/recipe/new-recipe.ts +++ b/src/prompts/portal/recipe/new-recipe.ts @@ -1,7 +1,7 @@ import { select, text, isCancel, log, autocomplete, confirm } from "@clack/prompts"; import { SdlEndpoint } from "../../../types/sdl/sdl.js"; import { DirectoryPath } from "../../../types/file/directoryPath.js"; -import { format as f } from "../../format.js"; +import { format as f, getTree, TreeNode } from "../../format.js"; import { StepType } from "../../../types/recipe/recipe.js"; import { getErrorMessage, ServiceError } from "../../../infrastructure/api-utils.js"; @@ -180,4 +180,9 @@ Let's proceed to adding steps to your API Recipe.`; public openRecipeMarkdownEditor() { log.step("Opening markdown editor for you to enter recipe content..."); } + + displayTocStructure(tocStructure: TreeNode) { + const message = getTree(tocStructure); + log.info(message) + } } diff --git a/src/types/api/transform.ts b/src/types/api/transform.ts index 4cb0b40fa..a6a91dd1e 100644 --- a/src/types/api/transform.ts +++ b/src/types/api/transform.ts @@ -1,16 +1,3 @@ -import { TransformationController } from "@apimatic/sdk"; - -export type TransformationIdParams = { - file: string; - url: string; - format: string; -}; - -export type DownloadTransformationParams = { - id: string; - destinationFilePath: string; - transformationController: TransformationController; -}; export type TransformationData = { result: NodeJS.ReadableStream | Blob; diff --git a/src/types/api/validate.ts b/src/types/api/validate.ts deleted file mode 100644 index 3309d44e8..000000000 --- a/src/types/api/validate.ts +++ /dev/null @@ -1,13 +0,0 @@ -export type GetValidationParams = { - file: string; - url: string; -}; - -export type APIValidateError = { - modelState: { - "exception Error": string[]; - }; -}; -export type AuthorizationError = { - body: string; -}; diff --git a/src/types/file/directory.ts b/src/types/file/directory.ts index 49a399c7e..002106a9d 100644 --- a/src/types/file/directory.ts +++ b/src/types/file/directory.ts @@ -2,7 +2,7 @@ import { DirectoryPath } from "./directoryPath.js"; import { FileName } from "./fileName.js"; import { TocCustomPage, TocGroup } from "../toc/toc.js"; import { FilePath } from "./filePath.js"; - +import { TreeNode } from "../../prompts/format.js"; export type DirectoryItem = FileName | Directory; @@ -15,6 +15,41 @@ export class Directory { this.items = filePaths; } + private static readonly folderDescriptions: Record = { + spec: "# Contains all API definition files", + content: "# Includes custom documentation pages in Markdown", + static: "# Includes all static files, such as images, GIFs, and PDFs" + }; + + private static readonly fileDescriptions: Record = { + "toc.yml": "# Controls the structure of the side navigation bar in the API portal", + "APIMATIC-BUILD.json": "# Defines all configurations for the API portal, including programming languages and themes" + }; + + + public toTreeNode(): TreeNode { + const folderName = this.directoryPath.leafName(); + const folderDescription = Directory.folderDescriptions[folderName]; + + return { + name: folderName, + description: folderDescription, + items: this.items.map((item) => { + if (item instanceof Directory) { + return item.toTreeNode(); + } + + // file case + const fileName = item.toString(); + const fileDescription = Directory.fileDescriptions[fileName]; + return { + name: fileName, + description: fileDescription + }; + }) + }; + } + public async parseContentFolder(baseContentPath: DirectoryPath): Promise { const contentItems: (TocGroup | TocCustomPage)[] = []; diff --git a/src/types/file/file.ts b/src/types/file/file.ts deleted file mode 100644 index 7e708f3f0..000000000 --- a/src/types/file/file.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { FileName } from './fileName.js'; -import { FilePath } from './filePath.js'; -import * as path from "path"; - -export class File { - public readonly filePath: FilePath; - private readonly fileName: FileName; - - public constructor(filePath: FilePath) { - this.filePath = filePath; - this.fileName = new FileName(path.basename(filePath.toString())); - } - - public getNameWithoutExtension(): string { - return this.fileName.toString().replace(/\.[^/.]+$/, ""); - } - - public getExtension(): string { - return path.extname(this.filePath.toString()); - } -} diff --git a/src/types/portal/generate.ts b/src/types/portal/generate.ts deleted file mode 100644 index 737f68cc8..000000000 --- a/src/types/portal/generate.ts +++ /dev/null @@ -1,14 +0,0 @@ -export interface GeneratePortalParams { - readonly sourceBuildInputZipFilePath: string; - readonly generatedPortalArtifactsFolderPath: string; - readonly generatedPortalArtifactsZipFilePath: string; - readonly overrideAuthKey: string | null; - readonly generateZipFile: boolean; -}; - -export interface ErrorResponse { - readonly title: string; - readonly detail?: string; - readonly errors: Record; - readonly message?: string; -} diff --git a/src/types/resource-context.ts b/src/types/resource-context.ts index 8aa703190..16369b149 100644 --- a/src/types/resource-context.ts +++ b/src/types/resource-context.ts @@ -24,7 +24,7 @@ export class ResourceContext { if (downloadFileResult.isErr()) { return err(downloadFileResult.error); } - await this.fileService.writeFile(destinationFilePath, downloadFileResult.value); + await this.fileService.writeFile(destinationFilePath, downloadFileResult.value.stream); } if (resourcePath instanceof FilePath) { await this.fileService.copy(resourcePath, destinationFilePath); @@ -32,24 +32,3 @@ export class ResourceContext { return ok(destinationFilePath); } } - -export class ResourceInputContext { - private readonly fileDownloadService = new FileDownloadService(); - private readonly fileService = new FileService(); - - public async validate(inputPath: ResourceInput): Promise { - if (inputPath instanceof UrlPath) { - const downloadFileResult = await this.fileDownloadService.downloadFile(inputPath); - if (downloadFileResult.isErr()) { - return downloadFileResult.error; - } - - } else { - const fileExists = await this.fileService.fileExists(inputPath); - if (!fileExists) { - return undefined; - } - } - return inputPath; - } -} diff --git a/src/types/sdl/sdl.ts b/src/types/sdl/sdl.ts index 85f443611..3ee71e95f 100644 --- a/src/types/sdl/sdl.ts +++ b/src/types/sdl/sdl.ts @@ -1,5 +1,7 @@ -import { SdlTocComponents } from "../spec-context.js"; import { TocEndpoint, TocModel } from "../toc/toc.js"; +export type EndpointGroup = Map; +export type SdlTocComponents = { endpointGroups: EndpointGroup; models: TocModel[] }; + export interface Sdl { readonly Endpoints: SdlEndpoint[]; diff --git a/src/types/spec-context.ts b/src/types/spec-context.ts index bdfb7133e..ac8127b88 100644 --- a/src/types/spec-context.ts +++ b/src/types/spec-context.ts @@ -1,9 +1,8 @@ import { FileService } from "../infrastructure/file-service.js"; import { DirectoryPath } from "./file/directoryPath.js"; -import { TocEndpoint, TocModel } from "./toc/toc.js"; +import { FilePath } from "./file/filePath.js"; +import { FileName } from "./file/fileName.js"; -export type EndpointGroup = Map; -export type SdlTocComponents = { endpointGroups: EndpointGroup; models: TocModel[] }; export class SpecContext { private readonly fileService = new FileService(); @@ -16,4 +15,10 @@ export class SpecContext { public async validate(): Promise { return !(await this.fileService.directoryEmpty(this.specDirectory)); } + + async save(stream: NodeJS.ReadableStream, fileName: FileName): Promise { + const filePath = new FilePath(this.specDirectory, fileName); + await this.fileService.writeFile(filePath, stream); + return filePath; + } } diff --git a/src/types/temp-context.ts b/src/types/temp-context.ts index 9ff7b04ca..6ee3d21d0 100644 --- a/src/types/temp-context.ts +++ b/src/types/temp-context.ts @@ -13,7 +13,7 @@ export class TempContext { private get getTempFileName(): FilePath { const uuid = randomUUID(); - return new FilePath(this.tempDirectory, new FileName(`${uuid}.zip`)); + return new FilePath(this.tempDirectory, new FileName(`${uuid}`)); } async zip(buildDirectory: DirectoryPath): Promise { diff --git a/src/types/toc-context.ts b/src/types/toc-context.ts index 7ccef50ce..cb2423241 100644 --- a/src/types/toc-context.ts +++ b/src/types/toc-context.ts @@ -23,8 +23,7 @@ export class TocContext { public async parseTocData(): Promise { const tocContent = await this.fileService.getContents(this.tocFilePath); - const parsedToc = parse(tocContent) as Toc; - return parsedToc; + return parse(tocContent) as Toc; } public async save(contents: string) { From 25657c007b007e66d7f554a832042c0ae80b9cbd Mon Sep 17 00:00:00 2001 From: Shield-Jaguar Date: Thu, 4 Sep 2025 10:27:30 +0500 Subject: [PATCH 61/77] feat: fix typo and unused --- package-lock.json | 216 +++++++++--------- src/actions/sdk/generate.ts | 1 + .../portal/recipe/recipe-generator.ts | 2 +- src/infrastructure/api-utils.ts | 5 +- .../services/api-client-factory.ts | 2 - .../services/file-metadata-service.ts | 8 - src/prompts/api/transform.ts | 5 - src/prompts/format.ts | 3 +- src/prompts/portal/copilot.ts | 6 +- src/prompts/portal/quickstart.ts | 2 +- src/prompts/portal/toc/new-toc.ts | 1 - src/prompts/sdk/generate.ts | 5 - src/types/events/recipe-creation-failed.ts | 2 +- src/types/events/toc-creation-failed.ts | 2 +- src/types/file/fileName.ts | 6 +- 15 files changed, 123 insertions(+), 143 deletions(-) delete mode 100644 src/infrastructure/services/file-metadata-service.ts diff --git a/package-lock.json b/package-lock.json index 94b079761..baed2ecc2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2314,9 +2314,9 @@ } }, "node_modules/@eslint/config-array": { - "version": "0.20.1", - "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.20.1.tgz", - "integrity": "sha512-OL0RJzC/CBzli0DrrR31qzj6d6i6Mm3HByuhflhl4LOBiWxN+3i6/t/ZQQNii4tjksXi8r2CRW1wMpWA2ULUEw==", + "version": "0.21.0", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.0.tgz", + "integrity": "sha512-ENIdc4iLu0d93HeYirvKmrzshzofPw6VkZRKQGe9Nv46ZnWUzcF1xV01dcvEg/1wXUR61OmmlSfyeyO7EvjLxQ==", "dev": true, "license": "Apache-2.0", "dependencies": { @@ -2329,7 +2329,9 @@ } }, "node_modules/@eslint/config-array/node_modules/brace-expansion": { - "version": "1.1.11", + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", "dev": true, "license": "MIT", "dependencies": { @@ -2339,6 +2341,8 @@ }, "node_modules/@eslint/config-array/node_modules/minimatch": { "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", "dev": true, "license": "ISC", "dependencies": { @@ -2349,9 +2353,9 @@ } }, "node_modules/@eslint/config-helpers": { - "version": "0.2.3", - "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.2.3.tgz", - "integrity": "sha512-u180qk2Um1le4yf0ruXH3PYFeEZeYC3p/4wCTKrr2U1CmGdzGi3KtY0nuPDH48UJxlKCC5RDzbcbh4X0XlqgHg==", + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.3.1.tgz", + "integrity": "sha512-xR93k9WhrDYpXHORXpxVL5oHj3Era7wo6k/Wd8/IsQNnZUTzkGS29lyn3nAT05v6ltUuTFVCCYDEGfy2Or/sPA==", "dev": true, "license": "Apache-2.0", "engines": { @@ -2359,9 +2363,9 @@ } }, "node_modules/@eslint/core": { - "version": "0.14.0", - "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.14.0.tgz", - "integrity": "sha512-qIbV0/JZr7iSDjqAc60IqbLdsj9GDt16xQtWD+B78d/HAlvysGdZZ6rpJHGAc2T0FQx1X6thsSPdnoiGKdNtdg==", + "version": "0.15.2", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.15.2.tgz", + "integrity": "sha512-78Md3/Rrxh83gCxoUc0EiciuOHsIITzLy53m3d9UyiW8y9Dj2D29FeETqyKA+BRK76tnTp6RXWb3pCay8Oyomg==", "dev": true, "license": "Apache-2.0", "dependencies": { @@ -2396,7 +2400,9 @@ } }, "node_modules/@eslint/eslintrc/node_modules/brace-expansion": { - "version": "1.1.11", + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", "dev": true, "license": "MIT", "dependencies": { @@ -2430,6 +2436,8 @@ }, "node_modules/@eslint/object-schema": { "version": "2.1.6", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.6.tgz", + "integrity": "sha512-RBMg5FRL0I0gs51M/guSAj5/e14VQ4tpZnQNWwuDT66P14I43ItmPfIZRhO9fUVIPOAQXU47atlywZ/czoqFPA==", "dev": true, "license": "Apache-2.0", "engines": { @@ -2437,32 +2445,19 @@ } }, "node_modules/@eslint/plugin-kit": { - "version": "0.3.2", - "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.3.2.tgz", - "integrity": "sha512-4SaFZCNfJqvk/kenHpI8xvN42DMaoycy4PzKc5otHxRswww1kAt82OlBuwRVLofCACCTZEcla2Ydxv8scMXaTg==", + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.3.5.tgz", + "integrity": "sha512-Z5kJ+wU3oA7MMIqVR9tyZRtjYPr4OC004Q4Rw7pgOKUOKkJfZ3O24nz3WYfGRpMDNmcOi3TwQOmgm7B7Tpii0w==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@eslint/core": "^0.15.0", + "@eslint/core": "^0.15.2", "levn": "^0.4.1" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, - "node_modules/@eslint/plugin-kit/node_modules/@eslint/core": { - "version": "0.15.0", - "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.15.0.tgz", - "integrity": "sha512-b7ePw78tEWWkpgZCDYkbqDOP8dmM6qe+AOC6iuJqlq1R/0ahMAeH3qynpnqKFGkMltrp44ohV4ubGyvLX28tzw==", - "dev": true, - "license": "Apache-2.0", - "dependencies": { - "@types/json-schema": "^7.0.15" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - } - }, "node_modules/@humanfs/core": { "version": "0.19.1", "dev": true, @@ -2840,15 +2835,15 @@ } }, "node_modules/@inquirer/editor": { - "version": "4.2.13", - "resolved": "https://registry.npmjs.org/@inquirer/editor/-/editor-4.2.13.tgz", - "integrity": "sha512-WbicD9SUQt/K8O5Vyk9iC2ojq5RHoCLK6itpp2fHsWe44VxxcA9z3GTWlvjSTGmMQpZr+lbVmrxdHcumJoLbMA==", + "version": "4.2.18", + "resolved": "https://registry.npmjs.org/@inquirer/editor/-/editor-4.2.18.tgz", + "integrity": "sha512-yeQN3AXjCm7+Hmq5L6Dm2wEDeBRdAZuyZ4I7tWSSanbxDzqM0KqzoDbKM7p4ebllAYdoQuPJS6N71/3L281i6w==", "dev": true, "license": "MIT", "dependencies": { - "@inquirer/core": "^10.1.13", - "@inquirer/type": "^3.0.7", - "external-editor": "^3.1.0" + "@inquirer/core": "^10.2.0", + "@inquirer/external-editor": "^1.0.1", + "@inquirer/type": "^3.0.8" }, "engines": { "node": ">=18" @@ -2863,14 +2858,14 @@ } }, "node_modules/@inquirer/editor/node_modules/@inquirer/core": { - "version": "10.1.13", - "resolved": "https://registry.npmjs.org/@inquirer/core/-/core-10.1.13.tgz", - "integrity": "sha512-1viSxebkYN2nJULlzCxES6G9/stgHSepZ9LqqfdIGPHj5OHhiBUXVS0a6R0bEC2A+VL4D9w6QB66ebCr6HGllA==", + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/@inquirer/core/-/core-10.2.0.tgz", + "integrity": "sha512-NyDSjPqhSvpZEMZrLCYUquWNl+XC/moEcVFqS55IEYIYsY0a1cUCevSqk7ctOlnm/RaSBU5psFryNlxcmGrjaA==", "dev": true, "license": "MIT", "dependencies": { - "@inquirer/figures": "^1.0.12", - "@inquirer/type": "^3.0.7", + "@inquirer/figures": "^1.0.13", + "@inquirer/type": "^3.0.8", "ansi-escapes": "^4.3.2", "cli-width": "^4.1.0", "mute-stream": "^2.0.0", @@ -2891,9 +2886,9 @@ } }, "node_modules/@inquirer/editor/node_modules/@inquirer/type": { - "version": "3.0.7", - "resolved": "https://registry.npmjs.org/@inquirer/type/-/type-3.0.7.tgz", - "integrity": "sha512-PfunHQcjwnju84L+ycmcMKB/pTPIngjUJvfnRhKY6FKPuYXlM4aQCb/nIdTFR6BEhMjFvngzvng/vBAJMZpLSA==", + "version": "3.0.8", + "resolved": "https://registry.npmjs.org/@inquirer/type/-/type-3.0.8.tgz", + "integrity": "sha512-lg9Whz8onIHRthWaN1Q9EGLa/0LFJjyM8mEUbL1eTi6yMGvBf8gvyDLtxSXztQsxMvhxxNpJYrwa1YHdq+w4Jw==", "dev": true, "license": "MIT", "engines": { @@ -3079,10 +3074,45 @@ "node": ">=8" } }, + "node_modules/@inquirer/external-editor": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@inquirer/external-editor/-/external-editor-1.0.1.tgz", + "integrity": "sha512-Oau4yL24d2B5IL4ma4UpbQigkVhzPDXLoqy1ggK4gnHg/stmkffJE4oOXHXF3uz0UEpywG68KcyXsyYpA1Re/Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "chardet": "^2.1.0", + "iconv-lite": "^0.6.3" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/external-editor/node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/@inquirer/figures": { - "version": "1.0.12", - "resolved": "https://registry.npmjs.org/@inquirer/figures/-/figures-1.0.12.tgz", - "integrity": "sha512-MJttijd8rMFcKJC8NYmprWr6hD3r9Gd9qUC0XwPNwoEPWSMVJwA2MlXxF+nhZZNMY+HXsWa+o7KY2emWYIn0jQ==", + "version": "1.0.13", + "resolved": "https://registry.npmjs.org/@inquirer/figures/-/figures-1.0.13.tgz", + "integrity": "sha512-lGPVU3yO9ZNqA7vTYz26jny41lE7yoQansmqdMLBEfqaGsmdg7V3W9mK9Pvb5IL4EVZ9GnSDGMO/cJXud5dMaw==", "dev": true, "license": "MIT", "engines": { @@ -5420,6 +5450,8 @@ }, "node_modules/@types/json-schema": { "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", "dev": true, "license": "MIT" }, @@ -6649,7 +6681,9 @@ "license": "MIT" }, "node_modules/brace-expansion": { - "version": "2.0.1", + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", "license": "MIT", "dependencies": { "balanced-match": "^1.0.0" @@ -6958,7 +6992,9 @@ } }, "node_modules/chardet": { - "version": "0.7.0", + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/chardet/-/chardet-2.1.0.tgz", + "integrity": "sha512-bNFETTG/pM5ryzQ9Ad0lJOTa6HWD/YsScAR3EnCPZRPlQh77JocYktSHOUHelyhm8IARL+o4c4F1bP5KVOjiRA==", "dev": true, "license": "MIT" }, @@ -7944,18 +7980,20 @@ } }, "node_modules/eslint": { - "version": "9.21.0", + "version": "9.34.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.34.0.tgz", + "integrity": "sha512-RNCHRX5EwdrESy3Jc9o8ie8Bog+PeYvvSR8sDGoZxNFTvZ4dlxUB3WzQ3bQMztFrSRODGrLLj8g6OFuGY/aiQg==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.12.1", - "@eslint/config-array": "^0.20.1", - "@eslint/config-helpers": "^0.2.1", - "@eslint/core": "^0.14.0", + "@eslint/config-array": "^0.21.0", + "@eslint/config-helpers": "^0.3.1", + "@eslint/core": "^0.15.2", "@eslint/eslintrc": "^3.3.1", - "@eslint/js": "9.29.0", - "@eslint/plugin-kit": "^0.3.1", + "@eslint/js": "9.34.0", + "@eslint/plugin-kit": "^0.3.5", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", @@ -8088,7 +8126,9 @@ } }, "node_modules/eslint-plugin-import/node_modules/brace-expansion": { - "version": "1.1.11", + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", "dev": true, "license": "MIT", "dependencies": { @@ -8151,21 +8191,10 @@ "url": "https://opencollective.com/eslint" } }, - "node_modules/eslint/node_modules/@eslint/js": { - "version": "9.29.0", - "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.29.0.tgz", - "integrity": "sha512-3PIF4cBw/y+1u2EazflInpV+lYsSG0aByVIQzAgb1m1MhHFSbqTyNqtBKHgWf/9Ykud+DhILS9EGkmekVhbKoQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "url": "https://eslint.org/donate" - } - }, "node_modules/eslint/node_modules/brace-expansion": { - "version": "1.1.11", + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", "dev": true, "license": "MIT", "dependencies": { @@ -8443,19 +8472,6 @@ "version": "2.0.0", "license": "MIT" }, - "node_modules/external-editor": { - "version": "3.1.0", - "dev": true, - "license": "MIT", - "dependencies": { - "chardet": "^0.7.0", - "iconv-lite": "^0.4.24", - "tmp": "^0.0.33" - }, - "engines": { - "node": ">=4" - } - }, "node_modules/extract-zip": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/extract-zip/-/extract-zip-2.0.1.tgz", @@ -8789,7 +8805,9 @@ } }, "node_modules/form-data": { - "version": "4.0.2", + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.4.tgz", + "integrity": "sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==", "license": "MIT", "dependencies": { "asynckit": "^0.4.0", @@ -10196,7 +10214,9 @@ } }, "node_modules/jake/node_modules/brace-expansion": { - "version": "1.1.11", + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", "license": "MIT", "dependencies": { "balanced-match": "^1.0.0", @@ -11598,14 +11618,6 @@ "version": "2.0.2", "license": "BSD-2-Clause" }, - "node_modules/os-tmpdir": { - "version": "1.0.2", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/outvariant": { "version": "1.4.3", "resolved": "https://registry.npmjs.org/outvariant/-/outvariant-1.4.3.tgz", @@ -13359,7 +13371,9 @@ } }, "node_modules/test-exclude/node_modules/brace-expansion": { - "version": "1.1.11", + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", "dev": true, "license": "MIT", "dependencies": { @@ -13473,14 +13487,12 @@ } }, "node_modules/tmp": { - "version": "0.0.33", - "dev": true, + "version": "0.2.5", + "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.5.tgz", + "integrity": "sha512-voyz6MApa1rQGUxT3E+BK7/ROe8itEx7vD8/HEvt4xwXucvQ5G5oeEiHkmHZJuBO21RpOf+YYm9MOivj709jow==", "license": "MIT", - "dependencies": { - "os-tmpdir": "~1.0.2" - }, "engines": { - "node": ">=0.6.0" + "node": ">=14.14" } }, "node_modules/tmp-promise": { @@ -13492,14 +13504,6 @@ "tmp": "^0.2.0" } }, - "node_modules/tmp-promise/node_modules/tmp": { - "version": "0.2.3", - "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.3.tgz", - "integrity": "sha512-nZD7m9iCPC5g0pYmcaxogYKggSfLsdxl8of3Q/oIbqCqLLIO9IAF0GWjX1z9NZRHPiXv8Wex4yDCaZsgEw0Y8w==", - "engines": { - "node": ">=14.14" - } - }, "node_modules/to-regex-range": { "version": "5.0.1", "license": "MIT", diff --git a/src/actions/sdk/generate.ts b/src/actions/sdk/generate.ts index 677c93e60..fab9d4860 100644 --- a/src/actions/sdk/generate.ts +++ b/src/actions/sdk/generate.ts @@ -54,6 +54,7 @@ export class GenerateAction { this.portalService.generateSdk(specZipPath, language, this.configDir, this.commandMetadata, this.authKey) ); + // TODO: this should be service error if (response.isErr()) { this.prompts.logGenerationError(response.error); return ActionResult.failed(); diff --git a/src/application/portal/recipe/recipe-generator.ts b/src/application/portal/recipe/recipe-generator.ts index 1173d41fc..14b98ea1d 100644 --- a/src/application/portal/recipe/recipe-generator.ts +++ b/src/application/portal/recipe/recipe-generator.ts @@ -49,7 +49,7 @@ export class PortalRecipeGenerator { group: "API Recipes", items: [] }; - // Insert after the last group section, before any generate sections + // Insert after the last group section, before generate sections let lastGroupIdx = -1; for (let i = 0; i < toc.length; i++) { if (toc[i].group) lastGroupIdx = i; diff --git a/src/infrastructure/api-utils.ts b/src/infrastructure/api-utils.ts index 0b3310a69..342b66808 100644 --- a/src/infrastructure/api-utils.ts +++ b/src/infrastructure/api-utils.ts @@ -1,4 +1,5 @@ import axios from "axios"; +import { format as f } from "../prompts/format.js"; export const enum ServiceError { NotFound = "NOT_FOUND", @@ -18,7 +19,7 @@ export function getErrorMessage(error: ServiceError): string { case ServiceError.ServerError: case ServiceError.InvalidResponse: default: - return "An unexpected error occurred, please try again later. If the problem persists, please reach out to our team at support@apimatic.io"; + return `An unexpected error occurred, please try again later. If the problem persists, please reach out to our team at ${f.var('support@apimatic.io')}`; } } @@ -38,4 +39,4 @@ export function handleServiceError(error: unknown): ServiceError { } } return ServiceError.ServerError; -} \ No newline at end of file +} diff --git a/src/infrastructure/services/api-client-factory.ts b/src/infrastructure/services/api-client-factory.ts index 57f69eaea..15f160bb6 100644 --- a/src/infrastructure/services/api-client-factory.ts +++ b/src/infrastructure/services/api-client-factory.ts @@ -23,5 +23,3 @@ export class ApiClientFactory { } export const apiClientFactory = new ApiClientFactory(); - -export default ApiClientFactory; diff --git a/src/infrastructure/services/file-metadata-service.ts b/src/infrastructure/services/file-metadata-service.ts deleted file mode 100644 index 5b6f715c9..000000000 --- a/src/infrastructure/services/file-metadata-service.ts +++ /dev/null @@ -1,8 +0,0 @@ -import axios from "axios"; - -export class FileMetadataService { - public async contentType(path: string): Promise { - const response = await axios.head(path); - return response.headers["content-type"]; - } -} diff --git a/src/prompts/api/transform.ts b/src/prompts/api/transform.ts index e0c1215e1..2472bf475 100644 --- a/src/prompts/api/transform.ts +++ b/src/prompts/api/transform.ts @@ -24,11 +24,6 @@ export class ApiTransformPrompts { log.error(message); } - public async InvalidFilePathProvided(){ - const message = `Invalid file path or URL provided.`; - log.error(message); - } - public async transformApi(fn: Promise>) { return withSpinner("Transforming API", "API transformed successfully.", "API transformation failed.", fn); } diff --git a/src/prompts/format.ts b/src/prompts/format.ts index c0b9a3dde..9ef0eb642 100644 --- a/src/prompts/format.ts +++ b/src/prompts/format.ts @@ -22,14 +22,13 @@ export const format = { // Common message styles success: (text: string) => pc.green(text), - warning: (text: string) => pc.yellow(text), error: (text: string) => pc.red(text), info: (text: string) => pc.cyan(text), intro: (text: string) => pc.bgCyan(text), outroSuccess: (text: string) => pc.bgGreen(text), outroFailure: (text: string) => pc.bgRed(text), - outroCancelled: (text: string) => pc.bgWhite(text) + outroCancelled: (text: string) => pc.bgWhite(text), }; export function intro(text: string) { diff --git a/src/prompts/portal/copilot.ts b/src/prompts/portal/copilot.ts index ca8c3ea83..c328f23b2 100644 --- a/src/prompts/portal/copilot.ts +++ b/src/prompts/portal/copilot.ts @@ -3,7 +3,7 @@ import { Result } from "neverthrow"; import { SubscriptionInfo } from "../../types/api/account.js"; import { getErrorMessage, ServiceError } from "../../infrastructure/api-utils.js"; import { DirectoryPath } from "../../types/file/directoryPath.js"; -import { format, format as f, withSpinner } from "../format.js"; +import { format as f, withSpinner } from "../format.js"; export class PortalCopilotPrompts { public async displayApiCopilotKeyUsageWarning() { @@ -114,13 +114,13 @@ select any programming language in the Portal and look for the chat icon in the public noCopilotKeyFound() { log.error( - `No copilot key found for the current subscription. Please contact support at ${format.var( + `No copilot key found for the current subscription. Please contact support at ${f.var( "support@apimatic.io" )}.` ); } public noCopilotKeySelected() { - log.error("Operation cancelled. No API Copilot key was selected."); + log.error("No API Copilot key was selected."); } } diff --git a/src/prompts/portal/quickstart.ts b/src/prompts/portal/quickstart.ts index a46a07d99..2e9276b5a 100644 --- a/src/prompts/portal/quickstart.ts +++ b/src/prompts/portal/quickstart.ts @@ -87,7 +87,7 @@ Let's get started!`; public async selectLanguagesPrompt(): Promise { const languages = (await multiselect({ message: - "Your API Portal will contain SDKs and SDK Documentation in the following Languages. Press enter to continue with all languages, or use the arrow keys and spacebar to customize your selection:", + "Your API Portal will contain SDKs and SDK Documentation in the following Languages. Press enter to continue with all languages, or use the arrow keys and space to customize your selection:", options: [ { label: "Typescript", value: "typescript" }, { label: "Ruby", value: "ruby" }, diff --git a/src/prompts/portal/toc/new-toc.ts b/src/prompts/portal/toc/new-toc.ts index 51f1c891a..e6b569aa3 100644 --- a/src/prompts/portal/toc/new-toc.ts +++ b/src/prompts/portal/toc/new-toc.ts @@ -19,7 +19,6 @@ export class PortalNewTocPrompts { }); if (isCancel(overwrite)) { - cancel("Operation cancelled."); return false; } diff --git a/src/prompts/sdk/generate.ts b/src/prompts/sdk/generate.ts index 770ebcf6f..d32e6758f 100644 --- a/src/prompts/sdk/generate.ts +++ b/src/prompts/sdk/generate.ts @@ -38,11 +38,6 @@ export class SdkGeneratePrompts { return withSpinner("Generating SDK", "SDK generated successfully.", "SDK Generation failed.", fn); } - public networkError(serviceError: ServiceError): void { - const message = getErrorMessage(serviceError); - this.logGenerationError(message); - } - public logGenerationError(error: string): void { log.error(error); } diff --git a/src/types/events/recipe-creation-failed.ts b/src/types/events/recipe-creation-failed.ts index 5e1b5d7c7..39c29dbd2 100644 --- a/src/types/events/recipe-creation-failed.ts +++ b/src/types/events/recipe-creation-failed.ts @@ -1,4 +1,4 @@ -import { DomainEvent } from "../../types/events/domain-event.js"; +import { DomainEvent } from "./domain-event.js"; export class RecipeCreationFailedEvent extends DomainEvent { protected readonly eventName = RecipeCreationFailedEvent.name; diff --git a/src/types/events/toc-creation-failed.ts b/src/types/events/toc-creation-failed.ts index 450867825..10b9664ef 100644 --- a/src/types/events/toc-creation-failed.ts +++ b/src/types/events/toc-creation-failed.ts @@ -1,4 +1,4 @@ -import { DomainEvent } from "../../types/events/domain-event.js"; +import { DomainEvent } from "./domain-event.js"; export class TocCreationFailedEvent extends DomainEvent { protected readonly eventName = TocCreationFailedEvent.name; diff --git a/src/types/file/fileName.ts b/src/types/file/fileName.ts index a038aa47a..609fb48b6 100644 --- a/src/types/file/fileName.ts +++ b/src/types/file/fileName.ts @@ -7,10 +7,6 @@ export class FileName { this.name = name; } - public isZipFile() { - return path.extname(this.name).toLowerCase() === ".zip"; - } - public isMarkDown() { return this.name.endsWith(".md"); } @@ -28,4 +24,4 @@ export class FileName { public toString(): string { return this.name; } -} \ No newline at end of file +} From 45d01e9e2eafee5b9bfa6215cb2d7d79abacfd5f Mon Sep 17 00:00:00 2001 From: Sohail Date: Thu, 4 Sep 2025 12:19:49 +0500 Subject: [PATCH 62/77] resolved bug where recipe.js was not being saved correctly --- src/actions/portal/recipe/new-recipe.ts | 2 +- src/application/portal/recipe/recipe-generator.ts | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/actions/portal/recipe/new-recipe.ts b/src/actions/portal/recipe/new-recipe.ts index a12afce87..63fb16135 100644 --- a/src/actions/portal/recipe/new-recipe.ts +++ b/src/actions/portal/recipe/new-recipe.ts @@ -146,7 +146,7 @@ export class PortalRecipeAction { recipeName, recipeFileName, buildContext, - contentDirectory + buildDirectory ); const tocStructure = this.getTocStructure(recipeFileName); diff --git a/src/application/portal/recipe/recipe-generator.ts b/src/application/portal/recipe/recipe-generator.ts index 14b98ea1d..15a9c7fcc 100644 --- a/src/application/portal/recipe/recipe-generator.ts +++ b/src/application/portal/recipe/recipe-generator.ts @@ -19,14 +19,14 @@ export class PortalRecipeGenerator { recipeName: string, recipeFileName: FileName, buildContext: BuildContext, - contentFolderPath: DirectoryPath + buildDirectory: DirectoryPath ) { await this.addRecipeToToc(tocFileContent, tocFilePath, recipeName, recipeFileName); await this.registerRecipeInBuildConfigFile(buildContext, recipeName, recipeFileName); - await this.createMarkdownFile(recipeFileName, contentFolderPath); + await this.createMarkdownFile(recipeFileName, buildDirectory.join("content")); const generatedRecipeScript = await this.createScriptFromRecipe(recipe); - const generatedRecipeScriptsDirectoryPath = contentFolderPath.join("static", "scripts", "recipes"); + const generatedRecipeScriptsDirectoryPath = buildDirectory.join("static", "scripts", "recipes"); await this.saveGeneratedRecipeScriptToBuildDirectory( generatedRecipeScript, generatedRecipeScriptsDirectoryPath, @@ -111,7 +111,7 @@ export class PortalRecipeGenerator { const directory = contentFolder.join("recipes"); const markdownFileContent = this.getMarkdownFileContent(); - await this.fileService.directoryExists(directory); + await this.fileService.createDirectoryIfNotExists(directory); await this.fileService.writeContents(new FilePath(directory, new FileName(`${recipeFileName}.md`)), markdownFileContent); } From 8e0a7f01ebf9f8ee40e98533d0db3eff56cb7810 Mon Sep 17 00:00:00 2001 From: Shield-Jaguar Date: Thu, 4 Sep 2025 13:43:36 +0500 Subject: [PATCH 63/77] session 1 --- src/actions/portal/generate.ts | 3 +++ src/actions/portal/quickstart.ts | 2 ++ src/actions/portal/recipe/new-recipe.ts | 6 +++--- src/actions/portal/serve.ts | 4 +++- src/actions/sdk/generate.ts | 3 +++ src/commands/portal/copilot.ts | 4 ++-- src/commands/portal/serve.ts | 12 ++++++------ src/commands/portal/toc/new.ts | 6 +++--- src/prompts/api/validate.ts | 2 +- src/prompts/format.ts | 7 ++++--- src/prompts/portal/copilot.ts | 23 ++++++++--------------- src/prompts/portal/generate.ts | 4 ++++ src/prompts/portal/quickstart.ts | 9 ++++++--- src/prompts/portal/recipe/new-recipe.ts | 14 +++++++++++--- src/prompts/portal/toc/new-toc.ts | 2 +- src/prompts/sdk/generate.ts | 6 ++++-- 16 files changed, 64 insertions(+), 43 deletions(-) diff --git a/src/actions/portal/generate.ts b/src/actions/portal/generate.ts index c7f308971..7fea19497 100644 --- a/src/actions/portal/generate.ts +++ b/src/actions/portal/generate.ts @@ -74,6 +74,9 @@ export class GenerateAction { const tempPortalZipPath = await tempContext.save(response.value); await portalContext.save(tempPortalZipPath, zipPortal); + + this.prompts.portalGenerated(portalDirectory); + return ActionResult.success(); }); }; diff --git a/src/actions/portal/quickstart.ts b/src/actions/portal/quickstart.ts index b0042c095..6932e4d16 100644 --- a/src/actions/portal/quickstart.ts +++ b/src/actions/portal/quickstart.ts @@ -85,6 +85,7 @@ export class PortalQuickstartAction { const validationResult = await validateAction.execute(specPath, false); if (validationResult.isFailed()) { + this.prompts.specValidationFailed(); if (!(await this.prompts.useDefaultSpecPrompt())) { this.prompts.fixYourSpec(); return ActionResult.cancelled(); @@ -120,6 +121,7 @@ export class PortalQuickstartAction { if (!(await this.fileService.directoryExists(inputDirectory))) { this.prompts.inputDirectoryPathDoesNotExist(inputDirectory); + // TODO: Prompt user if he wants to create the directory continue; } diff --git a/src/actions/portal/recipe/new-recipe.ts b/src/actions/portal/recipe/new-recipe.ts index 63fb16135..d46738d2c 100644 --- a/src/actions/portal/recipe/new-recipe.ts +++ b/src/actions/portal/recipe/new-recipe.ts @@ -150,8 +150,8 @@ export class PortalRecipeAction { ); const tocStructure = this.getTocStructure(recipeFileName); - this.prompts.displayTocStructure(tocStructure); - + this.prompts.displayRecipeStructure(tocStructure); + this.prompts.nextSteps(buildDirectory); return ActionResult.success(); } @@ -164,8 +164,8 @@ export class PortalRecipeAction { "Feel free to edit this. Save your changes and then close the file once you're done."; await this.fileService.writeContents(tempFile, defaultContent); - await this.launcherService.openInEditor(tempFile); this.prompts.openRecipeMarkdownEditor(); + await this.launcherService.openInEditor(tempFile); const fileContent = await this.fileService.getContents(tempFile); return fileContent.replace(/\r\n|\r/g, "\n"); }); diff --git a/src/actions/portal/serve.ts b/src/actions/portal/serve.ts index 2de361323..11740f1a6 100644 --- a/src/actions/portal/serve.ts +++ b/src/actions/portal/serve.ts @@ -124,9 +124,11 @@ export class PortalServeAction { if (openInBrowser) { await this.launcherService.openUrlInBrowser(portalUrl); } + + this.prompts.promptForExit(); + this.isPortalServed = true; } - this.prompts.promptForExit(); liveReloadServer.refresh(portalDirectory.toString()); this.clearStandardInput(); diff --git a/src/actions/sdk/generate.ts b/src/actions/sdk/generate.ts index fab9d4860..fcddfb553 100644 --- a/src/actions/sdk/generate.ts +++ b/src/actions/sdk/generate.ts @@ -62,6 +62,9 @@ export class GenerateAction { const tempSdkFilePath = await tempContext.save(response.value); await sdkContext.save(tempSdkFilePath, zipSdk); + + this.prompts.sdkGenerated(sdkContext.sdkLanguageDirectory); + return ActionResult.success(); }); }; diff --git a/src/commands/portal/copilot.ts b/src/commands/portal/copilot.ts index 6425d7658..10c293065 100644 --- a/src/commands/portal/copilot.ts +++ b/src/commands/portal/copilot.ts @@ -24,8 +24,8 @@ export default class PortalCopilot extends Command { static cmdTxt = format.cmd("apimatic", "portal", "copilot"); static examples = [ - `${this.cmdTxt} ${format.flag("input", '"./"')}`, - `${this.cmdTxt} ${format.flag("input", '"./"')} ${format.flag("disable")}` + `${this.cmdTxt} ${format.flag("input", './')}`, + `${this.cmdTxt} ${format.flag("input", './')} ${format.flag("disable")}` ]; async run(): Promise { diff --git a/src/commands/portal/serve.ts b/src/commands/portal/serve.ts index e1b97961e..848b97e24 100644 --- a/src/commands/portal/serve.ts +++ b/src/commands/portal/serve.ts @@ -16,7 +16,7 @@ export default class PortalServe extends Command { static flags = { port: Flags.integer({ char: "p", - description: "Port to serve the portal.", + description: "port to serve the portal.", default: 3000, helpValue: "3000" }), @@ -24,11 +24,11 @@ export default class PortalServe extends Command { ...FlagsProvider.destination("portal", "portal"), open: Flags.boolean({ char: "o", - description: "Open the portal in the default browser.", + description: "open the portal in the default browser.", default: false }), "no-reload": Flags.boolean({ - description: "Disable hot reload.", + description: "disable hot reload.", default: false }), ...FlagsProvider.authKey @@ -37,9 +37,9 @@ export default class PortalServe extends Command { static cmdTxt = format.cmd("apimatic", "portal", "serve"); static examples = [ this.cmdTxt, - `${this.cmdTxt}` + - `${format.flag("input", '"./"')} ` + - `${format.flag("destination", '"./portal"')} ` + + `${this.cmdTxt} ` + + `${format.flag("input", './')} ` + + `${format.flag("destination", './portal')} ` + `${format.flag("port", "3000")} ` + `${format.flag("open")} ` + `${format.flag("no-reload")}` diff --git a/src/commands/portal/toc/new.ts b/src/commands/portal/toc/new.ts index 9018620ec..b58a7bed8 100644 --- a/src/commands/portal/toc/new.ts +++ b/src/commands/portal/toc/new.ts @@ -42,9 +42,9 @@ ${format.link( static cmdTxt = format.cmd("apimatic", "portal", "toc", "new"); static examples = [ - `${this.cmdTxt} ${format.flag("destination", '"./src/content/"')}`, - `${this.cmdTxt} ${format.flag("input", '"./"')}`, - `${this.cmdTxt} ${format.flag("input", '"./"')} ${format.flag("destination", '"./src/content/"')}` + `${this.cmdTxt} ${format.flag("destination", './src/content/')}`, + `${this.cmdTxt} ${format.flag("input", './')}`, + `${this.cmdTxt} ${format.flag("input", './')} ${format.flag("destination", './src/content/')}` ]; async run(): Promise { diff --git a/src/prompts/api/validate.ts b/src/prompts/api/validate.ts index 057aab347..346fbc78f 100644 --- a/src/prompts/api/validate.ts +++ b/src/prompts/api/validate.ts @@ -8,7 +8,7 @@ import { getErrorMessage, ServiceError } from "../../infrastructure/api-utils.js export class ApiValidatePrompts { public async validateApi(fn: Promise>) { - return withSpinner("Validating API", "API validated successfully.", "API validation failed.", fn); + return withSpinner("Validating API", "API validation completed", "API validation failed", fn); } displayValidationMessages({ warnings, errors, messages }: ValidationMessages): void { diff --git a/src/prompts/format.ts b/src/prompts/format.ts index 9ef0eb642..ede60b370 100644 --- a/src/prompts/format.ts +++ b/src/prompts/format.ts @@ -10,8 +10,9 @@ export const format = { // Core element types var: (text: string) => pc.magenta(`'${text}'`), path: (text: DirectoryPath | FilePath) => pc.cyan(`'${text}'`), - cmd: (cmd: string, ...args: string[]) => `${pc.blue(cmd)} ${args.map(arg => pc.dim(arg)).join(" ")}`, - link: (text: string) => pc.underline(pc.blue(text)), + cmd: (cmd: string, ...args: string[]) => `${pc.blueBright(cmd)} ${args.map(arg => pc.dim(arg)).join(" ")}`, + cmdAlt: (cmd: string, ...args: string[]) => `${pc.blueBright(cmd)} ${args.map(arg => pc.blueBright(arg)).join(" ")}`, + link: (text: string) => pc.underline(pc.blueBright(text)), description: (text: string) => pc.greenBright(`${text}`), flag: (name: string, value: string | undefined = undefined) => { if (value) { @@ -28,7 +29,7 @@ export const format = { intro: (text: string) => pc.bgCyan(text), outroSuccess: (text: string) => pc.bgGreen(text), outroFailure: (text: string) => pc.bgRed(text), - outroCancelled: (text: string) => pc.bgWhite(text), + outroCancelled: (text: string) => pc.bgWhite(pc.blackBright(text)), }; export function intro(text: string) { diff --git a/src/prompts/portal/copilot.ts b/src/prompts/portal/copilot.ts index c328f23b2..78af8e569 100644 --- a/src/prompts/portal/copilot.ts +++ b/src/prompts/portal/copilot.ts @@ -44,21 +44,16 @@ export class PortalCopilotPrompts { ); note( - `API Copilot will index your content the next time you run ${f.cmd("apimatic", "portal", "generate")} or ${f.cmd( - "apimatic", - "portal", - "serve" - )}. + `API Copilot will index your content the next time you run +'${f.cmdAlt("apimatic", "portal", "generate")}' or '${f.cmdAlt("apimatic", "portal", "serve")}'. This process can take up to 10 minutes, depending on your API’s size. -To see your copilot: If your portal is already running, refresh the page. Otherwise, run ${f.cmd( - "apimatic", - "portal", - "serve" - )}, -select any programming language in the Portal and look for the chat icon in the bottom-right corner.`, +To see your copilot: If your portal is already running, refresh the page. +Otherwise, run '${f.cmdAlt("apimatic", "portal", "serve")}', +select any programming language in the Portal and +look for the chat icon in the bottom-right corner.`, - `Next steps` + `Next Steps` ); } @@ -114,9 +109,7 @@ select any programming language in the Portal and look for the chat icon in the public noCopilotKeyFound() { log.error( - `No copilot key found for the current subscription. Please contact support at ${f.var( - "support@apimatic.io" - )}.` + `No copilot key found for the current subscription. Please contact support at ${f.var("support@apimatic.io")}.` ); } diff --git a/src/prompts/portal/generate.ts b/src/prompts/portal/generate.ts index c7053d405..0f6eaa944 100644 --- a/src/prompts/portal/generate.ts +++ b/src/prompts/portal/generate.ts @@ -48,4 +48,8 @@ export class PortalGeneratePrompts { An error report has been written at the destination path: ${f.path(reportPath)}`; log.error(message); } + + public portalGenerated(portal: DirectoryPath) { + log.info(`Generated portal at ${f.path(portal)}.`); + } } diff --git a/src/prompts/portal/quickstart.ts b/src/prompts/portal/quickstart.ts index 2e9276b5a..0147f97d5 100644 --- a/src/prompts/portal/quickstart.ts +++ b/src/prompts/portal/quickstart.ts @@ -71,7 +71,7 @@ Let's get started!`; public fixYourSpec() { const message = `Good luck fixing your API definition! Feel free to run this command again once you're done.`; - log.step(message); + log.info(message); } public validateSpecStep() { @@ -97,7 +97,6 @@ Let's get started!`; { label: "PHP", value: "php" }, { label: "Go", value: "go" } ], - required: false, initialValues: ["typescript", "ruby", "python", "java", "csharp", "php", "go"] })) as string[]; @@ -139,7 +138,7 @@ Let's get started!`; } public inputDirectoryPathDoesNotExist(inputDirectory: DirectoryPath) { - log.error(`The specified directory path ${f.path(inputDirectory)} does not exists.`); + log.error(`The specified directory path ${f.path(inputDirectory)} does not exist.`); } public inputDirectoryNotEmpty(inputDirectory: DirectoryPath) { @@ -182,4 +181,8 @@ ${f.link(referenceDocumentationUrl)}`; const message = getTree(directory.toTreeNode()); log.info(message); } + + public specValidationFailed() { + log.error(`Oops, it looks like there are some errors in your API Definition`); + } } diff --git a/src/prompts/portal/recipe/new-recipe.ts b/src/prompts/portal/recipe/new-recipe.ts index 0140cd113..b582df945 100644 --- a/src/prompts/portal/recipe/new-recipe.ts +++ b/src/prompts/portal/recipe/new-recipe.ts @@ -1,9 +1,10 @@ -import { select, text, isCancel, log, autocomplete, confirm } from "@clack/prompts"; +import { select, text, isCancel, log, autocomplete, confirm, note } from "@clack/prompts"; import { SdlEndpoint } from "../../../types/sdl/sdl.js"; import { DirectoryPath } from "../../../types/file/directoryPath.js"; import { format as f, getTree, TreeNode } from "../../format.js"; import { StepType } from "../../../types/recipe/recipe.js"; import { getErrorMessage, ServiceError } from "../../../infrastructure/api-utils.js"; +import { Dir } from "node:fs"; export class PortalRecipePrompts { public displayWelcomeMessage(): void { @@ -181,8 +182,15 @@ Let's proceed to adding steps to your API Recipe.`; log.step("Opening markdown editor for you to enter recipe content..."); } - displayTocStructure(tocStructure: TreeNode) { + public displayRecipeStructure(tocStructure: TreeNode) { + const heading = `You can edit the following files to customize your API Recipe:\n`; const message = getTree(tocStructure); - log.info(message) + log.info(heading + message); + } + + public nextSteps(buildDirectory: DirectoryPath) { + const message = `Your new API Recipe has been added to the source directory at: ${f.path(buildDirectory)} +Run the command '${f.cmdAlt(`apimatic`, 'portal', 'serve')}' to preview your documentation portal`; + note(message, "Next Steps"); } } diff --git a/src/prompts/portal/toc/new-toc.ts b/src/prompts/portal/toc/new-toc.ts index e6b569aa3..00701c829 100644 --- a/src/prompts/portal/toc/new-toc.ts +++ b/src/prompts/portal/toc/new-toc.ts @@ -1,4 +1,4 @@ -import { cancel, confirm, isCancel, log } from "@clack/prompts"; +import { confirm, isCancel, log } from "@clack/prompts"; import { FilePath } from "../../../types/file/filePath.js"; import { Result } from "neverthrow"; import { format as f, withSpinner } from "../../format.js"; diff --git a/src/prompts/sdk/generate.ts b/src/prompts/sdk/generate.ts index d32e6758f..9125f5451 100644 --- a/src/prompts/sdk/generate.ts +++ b/src/prompts/sdk/generate.ts @@ -2,7 +2,6 @@ import { isCancel, confirm, log } from "@clack/prompts"; import { DirectoryPath } from "../../types/file/directoryPath.js"; import { format as f, withSpinner } from "../format.js"; import { Result } from "neverthrow"; -import { getErrorMessage, ServiceError } from "../../infrastructure/api-utils.js"; export class SdkGeneratePrompts { public async overwriteSdk(directory: DirectoryPath): Promise { @@ -34,7 +33,7 @@ export class SdkGeneratePrompts { this.logGenerationError(message); } - public async generateSDK(fn: Promise>) { + public generateSDK(fn: Promise>) { return withSpinner("Generating SDK", "SDK generated successfully.", "SDK Generation failed.", fn); } @@ -42,4 +41,7 @@ export class SdkGeneratePrompts { log.error(error); } + public sdkGenerated(sdk: DirectoryPath) { + log.info(`Generated SDK at ${f.path(sdk)}.`); + } } From 088a73aacef290a0e1df2449b7f29eb568192a21 Mon Sep 17 00:00:00 2001 From: Sohail Date: Thu, 4 Sep 2025 16:21:19 +0500 Subject: [PATCH 64/77] toc bug fixes --- src/actions/portal/toc/new-toc.ts | 5 ++- src/infrastructure/file-service.ts | 13 +++++++ src/prompts/portal/toc/new-toc.ts | 4 +- src/types/file/directory.ts | 61 ++++++++++++++++++++---------- src/types/file/fileName.ts | 2 - src/types/toc-context.ts | 2 +- 6 files changed, 61 insertions(+), 26 deletions(-) diff --git a/src/actions/portal/toc/new-toc.ts b/src/actions/portal/toc/new-toc.ts index 50f8c92ea..d2a65933e 100644 --- a/src/actions/portal/toc/new-toc.ts +++ b/src/actions/portal/toc/new-toc.ts @@ -78,11 +78,12 @@ export class PortalNewTocAction { const specZipPath = await tempContext.zip(specDirectory); const specFileStream = await this.fileService.getStream(specZipPath); try { - const result = await this.prompts.extractModels( + const result = await this.prompts.extractEndpointGroupsAndModels( this.portalService.generateSdl(specFileStream, this.configDirectory, this.commandMetadata) ); if (result.isErr()) { - return err(result.error); + this.prompts.fallingBackToDefault(); + return ok({ endpointGroups: new Map(), models: [] } as SdlComponents); } return ok(getEndpointGroupsAndModels(result.value)); } finally { diff --git a/src/infrastructure/file-service.ts b/src/infrastructure/file-service.ts index 7d97af3f6..cd11b73ba 100644 --- a/src/infrastructure/file-service.ts +++ b/src/infrastructure/file-service.ts @@ -82,6 +82,19 @@ export class FileService { } } + public getRelativePath(filePath: FilePath, basePath: DirectoryPath): string { + const filePathStr = filePath.toString(); + const basePathStr = basePath.toString(); + + if (filePathStr.startsWith(basePathStr)) { + const relativePath = filePathStr.substring(basePathStr.length).replace(/^[/\\]/, ""); + return relativePath.replace(/\\/g, "/"); + } + + // Normalize the full path if it doesn't start with basePath + return filePathStr.replace(/\\/g, "/"); + } + public async getStream(filePath: FilePath) { return fs.createReadStream(filePath.toString()); } diff --git a/src/prompts/portal/toc/new-toc.ts b/src/prompts/portal/toc/new-toc.ts index e6b569aa3..9eb2c509c 100644 --- a/src/prompts/portal/toc/new-toc.ts +++ b/src/prompts/portal/toc/new-toc.ts @@ -1,4 +1,4 @@ -import { cancel, confirm, isCancel, log } from "@clack/prompts"; +import { confirm, isCancel, log } from "@clack/prompts"; import { FilePath } from "../../../types/file/filePath.js"; import { Result } from "neverthrow"; import { format as f, withSpinner } from "../../format.js"; @@ -48,7 +48,7 @@ export class PortalNewTocPrompts { log.error(message); } - public extractModels(fn: Promise>) { + public extractEndpointGroupsAndModels(fn: Promise>) { return withSpinner( "Extracting endpoint groups and models", "Endpoint groups and models extracted.", diff --git a/src/types/file/directory.ts b/src/types/file/directory.ts index 002106a9d..ef1c43e08 100644 --- a/src/types/file/directory.ts +++ b/src/types/file/directory.ts @@ -3,12 +3,14 @@ import { FileName } from "./fileName.js"; import { TocCustomPage, TocGroup } from "../toc/toc.js"; import { FilePath } from "./filePath.js"; import { TreeNode } from "../../prompts/format.js"; +import { FileService } from "../../infrastructure/file-service.js"; export type DirectoryItem = FileName | Directory; export class Directory { public readonly directoryPath: DirectoryPath; public readonly items: DirectoryItem[]; + private readonly fileService = new FileService(); public constructor(directoryPath: DirectoryPath, filePaths: DirectoryItem[]) { this.directoryPath = directoryPath; @@ -26,7 +28,6 @@ export class Directory { "APIMATIC-BUILD.json": "# Defines all configurations for the API portal, including programming languages and themes" }; - public toTreeNode(): TreeNode { const folderName = this.directoryPath.leafName(); const folderDescription = Directory.folderDescriptions[folderName]; @@ -51,36 +52,58 @@ export class Directory { } public async parseContentFolder(baseContentPath: DirectoryPath): Promise { - const contentItems: (TocGroup | TocCustomPage)[] = []; + const groups: TocGroup[] = []; + const pages: TocCustomPage[] = []; for (const item of this.items) { if (item instanceof Directory) { - contentItems.push({ - group: item.toString(), - items: await item.parseContentFolder(baseContentPath) - }); + const subGroups = await item.parseContentFolder(baseContentPath); + + if (subGroups.length > 0) { + const directoryName = item.directoryPath.leafName(); + groups.push({ + group: directoryName, + items: subGroups + }); + } } else { if (item.isMarkDown()) { - const filePath = new FilePath(this.directoryPath, item); - contentItems.push({ - page: filePath.toString(), - file: new FilePath(baseContentPath, item.normalize()).toString(), + const currentFilePath = new FilePath(this.directoryPath, item); + const relativeFilePath = this.fileService.getRelativePath(currentFilePath, baseContentPath); + + pages.push({ + page: this.getPageName(item), + file: relativeFilePath }); } } } - // Return empty if no mark-down files were found - if (contentItems.length === 0) { + const allItems: (TocGroup | TocCustomPage)[] = [...pages, ...groups]; + + if (allItems.length === 0) { return []; } - // Wrap everything under a "Custom Content" group - return [ - { - group: "Custom Content", - items: contentItems - } - ]; + if (this.isRootContentDirectory(baseContentPath)) { + return [ + { + group: "Custom Content", + items: allItems + } + ]; + } + + // For subdirectories, return the items directly + return allItems as TocGroup[]; + } + + private getPageName(fileName: FileName): string { + const fileNameStr = fileName.toString(); + return fileNameStr.replace(/\.md$/, ""); + } + + private isRootContentDirectory(baseContentPath: DirectoryPath): boolean { + return this.directoryPath.toString() === baseContentPath.toString(); } } diff --git a/src/types/file/fileName.ts b/src/types/file/fileName.ts index 609fb48b6..ee204cd52 100644 --- a/src/types/file/fileName.ts +++ b/src/types/file/fileName.ts @@ -1,5 +1,3 @@ -import path from "path"; - export class FileName { private readonly name: string; diff --git a/src/types/toc-context.ts b/src/types/toc-context.ts index cb2423241..8ff519996 100644 --- a/src/types/toc-context.ts +++ b/src/types/toc-context.ts @@ -18,7 +18,7 @@ export class TocContext { } public async exists() { - return !(await this.fileService.fileExists(this.tocFilePath)); + return await this.fileService.fileExists(this.tocFilePath); } public async parseTocData(): Promise { From 659db585b7936fa8eb4fce06d0bf87e6801a657e Mon Sep 17 00:00:00 2001 From: Ali Asghar <75574550+aliasghar98@users.noreply.github.com> Date: Thu, 4 Sep 2025 16:25:45 +0500 Subject: [PATCH 65/77] fix: resolved portal generation problems while serving --- src/actions/portal/serve.ts | 40 ++++++++++++++----------------------- 1 file changed, 15 insertions(+), 25 deletions(-) diff --git a/src/actions/portal/serve.ts b/src/actions/portal/serve.ts index 11740f1a6..522a300da 100644 --- a/src/actions/portal/serve.ts +++ b/src/actions/portal/serve.ts @@ -39,6 +39,10 @@ export class PortalServeAction { onAfterServe?: () => void ): Promise { const generatePortalAction = new GenerateAction(this.configDir, this.commandMetadata, this.authKey); + const result = await generatePortalAction.execute(buildDirectory, portalDirectory, true, false); + if (result.isFailed()) { + return ActionResult.failed(); + } const servePort = await this.networkService.getServerPort([port, 3000, 3001, 3002]); if (servePort != port) { @@ -51,21 +55,21 @@ export class PortalServeAction { .use(express.static(portalDirectory.toString(), { extensions: ["html"] })) .listen(servePort); - if (!hotReload) { - await generatePortalAction.execute(buildDirectory, portalDirectory, true, false); - const portalUrl = new UrlPath(`http://localhost:${servePort}`); - this.prompts.portalServed(portalUrl); - if (openInBrowser) { - await this.launcherService.openUrlInBrowser(portalUrl); - } - this.prompts.promptForExit(); + const portalUrl = new UrlPath(`http://localhost:${servePort}`); + this.prompts.portalServed(portalUrl); + if (openInBrowser) { + await this.launcherService.openUrlInBrowser(portalUrl); + } + this.prompts.promptForExit(); + if (!hotReload) { if (onAfterServe) { onAfterServe(); } this.clearStandardInput(); await this.prompts.blockExecution(); + liveReloadServer.close(); server.close(); return ActionResult.success(); @@ -73,7 +77,7 @@ export class PortalServeAction { const watcher = chokidar.watch(buildDirectory.toString(), { ignored: [/(^|[/\\])\..+/], - ignoreInitial: false, // TODO: check this flag with Saeed + ignoreInitial: true, persistent: true, awaitWriteFinish: true, atomic: true @@ -106,9 +110,7 @@ export class PortalServeAction { }); await debounceService.batchSingleRequest(async () => { - if (this.isPortalServed) { - this.prompts.changesDetected(); - } + this.prompts.changesDetected(); // TODO: Verify if this is needed. if (!eventQueue.has(eventId)) { @@ -117,19 +119,6 @@ export class PortalServeAction { await generatePortalAction.execute(buildDirectory, portalDirectory, true, false); - const portalUrl = new UrlPath(`http://localhost:${servePort}`); - if (!this.isPortalServed) { - this.prompts.portalServed(portalUrl); - - if (openInBrowser) { - await this.launcherService.openUrlInBrowser(portalUrl); - } - - this.prompts.promptForExit(); - - this.isPortalServed = true; - } - liveReloadServer.refresh(portalDirectory.toString()); this.clearStandardInput(); }); @@ -139,6 +128,7 @@ export class PortalServeAction { }); // Wait for SIGINT or SIGTERM + this.clearStandardInput(); await this.prompts.blockExecution(); await watcher.close(); From 9f6445c2cc63abdcd9658c36e35a4e93f60aed64 Mon Sep 17 00:00:00 2001 From: Sohail Date: Thu, 4 Sep 2025 16:44:17 +0500 Subject: [PATCH 66/77] added extension to recipe file name --- src/types/recipe-context.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/types/recipe-context.ts b/src/types/recipe-context.ts index b9cdfce72..3b78a67ae 100644 --- a/src/types/recipe-context.ts +++ b/src/types/recipe-context.ts @@ -6,7 +6,7 @@ export class RecipeContext { constructor(private readonly recipeName: string) {} getRecipeName(): FileName { - return new FileName(toPascalCase(this.recipeName.trim())); + return new FileName(toPascalCase(this.recipeName.trim()) + `.js`); } exists(tocData: Toc, recipeName: string, recipeFileName: FileName): boolean { From 62989526692a2eb9cff9c30648670c30a68c353a Mon Sep 17 00:00:00 2001 From: Shield-Jaguar Date: Thu, 4 Sep 2025 18:41:10 +0500 Subject: [PATCH 67/77] feat: updated messages --- src/actions/action-result.ts | 4 ++ src/actions/portal/quickstart.ts | 2 +- src/actions/portal/serve.ts | 5 ++- src/actions/portal/toc/new-toc.ts | 52 +++++++------------------ src/prompts/portal/copilot.ts | 7 ++-- src/prompts/portal/generate.ts | 2 +- src/prompts/portal/quickstart.ts | 19 ++++----- src/prompts/portal/recipe/new-recipe.ts | 3 +- src/prompts/portal/serve.ts | 14 +++++-- src/prompts/portal/toc/new-toc.ts | 11 +++--- 10 files changed, 50 insertions(+), 69 deletions(-) diff --git a/src/actions/action-result.ts b/src/actions/action-result.ts index 41d8ba8b6..49752bca6 100644 --- a/src/actions/action-result.ts +++ b/src/actions/action-result.ts @@ -25,6 +25,10 @@ export class ActionResult { return new ActionResult(ResultType.Cancel, " Cancelled "); } + static stopped() { + return new ActionResult(ResultType.Cancel, " Stopped "); + } + public getMessage() { return this.message; } diff --git a/src/actions/portal/quickstart.ts b/src/actions/portal/quickstart.ts index 6932e4d16..ebd7b4117 100644 --- a/src/actions/portal/quickstart.ts +++ b/src/actions/portal/quickstart.ts @@ -156,7 +156,7 @@ export class PortalQuickstartAction { await this.fileService.copyDirectoryContents(extractedFolder, sourceDirectory); const buildDirectoryStructure = await this.fileService.getDirectory(sourceDirectory); - this.prompts.printDirectoryStructure(buildDirectoryStructure); + this.prompts.printDirectoryStructure(inputDirectory, buildDirectoryStructure); const portalDirectory = inputDirectory.join("portal"); const portalServeAction = new PortalServeAction(this.configDir, this.commandMetadata, null); diff --git a/src/actions/portal/serve.ts b/src/actions/portal/serve.ts index 522a300da..c394102cb 100644 --- a/src/actions/portal/serve.ts +++ b/src/actions/portal/serve.ts @@ -49,7 +49,8 @@ export class PortalServeAction { this.prompts.usingFallbackPort(port, servePort); } - const liveReloadServer = createLiveReloadServer(); + const liveReloadPort = await this.networkService.getServerPort([35729, 35730, 35731, 35732]); + const liveReloadServer = createLiveReloadServer({ port: liveReloadPort}); const server = this.application .use(connectLiveReload()) .use(express.static(portalDirectory.toString(), { extensions: ["html"] })) @@ -75,6 +76,8 @@ export class PortalServeAction { return ActionResult.success(); } + this.prompts.hotReloadEnabled(buildDirectory); + const watcher = chokidar.watch(buildDirectory.toString(), { ignored: [/(^|[/\\])\..+/], ignoreInitial: true, diff --git a/src/actions/portal/toc/new-toc.ts b/src/actions/portal/toc/new-toc.ts index d2a65933e..42e879bd8 100644 --- a/src/actions/portal/toc/new-toc.ts +++ b/src/actions/portal/toc/new-toc.ts @@ -109,47 +109,21 @@ export class PortalNewTocAction { contentGroups = await contentContext.extractContentGroups(); } - const tocResult = await this.prompts.generateTOC( - this.generateToc( - tocContext, - sdlComponents.endpointGroups, - sdlComponents.models, - expandEndpoints, - expandModels, - contentGroups - ) + + const toc = this.tocGenerator.createTocStructure( + sdlComponents.endpointGroups, + sdlComponents.models, + expandEndpoints, + expandModels, + contentGroups ); - if (tocResult.isErr()) { - this.prompts.logError(tocResult.error); - return ActionResult.failed(); - } - return ActionResult.success(); - } + const yamlString = this.tocGenerator.transformToYaml(toc); + const tocFilePath = await tocContext.save(yamlString); - private async generateToc( - tocContext: TocContext, - endpointGroups: Map, - models: TocModel[], - expandEndpoints: boolean, - expandModels: boolean, - contentGroups: TocGroup[] - ): Promise> { - try { - const toc = this.tocGenerator.createTocStructure( - endpointGroups, - models, - expandEndpoints, - expandModels, - contentGroups - ); - - const yamlString = this.tocGenerator.transformToYaml(toc); - const tocFilePath = await tocContext.save(yamlString); - - return ok(tocFilePath); - } catch (error) { - return err(`Failed to generate TOC: ${(error as Error).message}`); - } + + this.prompts.tocCreated(tocFilePath) + + return ActionResult.success(); } } diff --git a/src/prompts/portal/copilot.ts b/src/prompts/portal/copilot.ts index 78af8e569..cd25b94a1 100644 --- a/src/prompts/portal/copilot.ts +++ b/src/prompts/portal/copilot.ts @@ -52,7 +52,6 @@ To see your copilot: If your portal is already running, refresh the page. Otherwise, run '${f.cmdAlt("apimatic", "portal", "serve")}', select any programming language in the Portal and look for the chat icon in the bottom-right corner.`, - `Next Steps` ); } @@ -73,8 +72,8 @@ look for the chat icon in the bottom-right corner.`, public async spinnerAccountInfo(fn: Promise>) { return withSpinner( "Retrieving your subscription info", - "Retrieved subscription info", - "Error retrieving subscription info", + "Subscription info retrieved", + "Subscription info retrieval failed", fn ); } @@ -83,7 +82,7 @@ look for the chat icon in the bottom-right corner.`, const confirmKeyUsage = await confirm({ message: "API Copilot can only be active on one Portal at a time. Configuring it on this Portal will disable it on any previously configured Portal.\n" + - `Do you want to use this key: '${f.var(apiCopilotKey)}'?`, + `Do you want to use this key: ${f.var(apiCopilotKey)}?`, initialValue: true }); diff --git a/src/prompts/portal/generate.ts b/src/prompts/portal/generate.ts index 0f6eaa944..23817e5d7 100644 --- a/src/prompts/portal/generate.ts +++ b/src/prompts/portal/generate.ts @@ -50,6 +50,6 @@ An error report has been written at the destination path: ${f.path(reportPath)}` } public portalGenerated(portal: DirectoryPath) { - log.info(`Generated portal at ${f.path(portal)}.`); + log.info(`Portal artifacts can be found at ${f.path(portal)}.`); } } diff --git a/src/prompts/portal/quickstart.ts b/src/prompts/portal/quickstart.ts index 0147f97d5..4905ae648 100644 --- a/src/prompts/portal/quickstart.ts +++ b/src/prompts/portal/quickstart.ts @@ -16,14 +16,13 @@ const defaultPortalDirectoryPath = process.cwd(); export class PortalQuickstartPrompts { public welcomeMessage() { - log.info(`Hello there.`); + log.info(`Welcome to the Portal Quickstart Wizard.`); const message = `This wizard will help you set up an API Portal via APIMatic's Docs as Code workflow in 4 simple steps. Let's get started!`; log.message(message); } public importSpecStep() { - const message = `Step 1 of 4: Import your OpenAPI Definition`; - log.step(message); + log.info(`Step 1 of 4: Import your OpenAPI Definition`); } public async specPathPrompt(defaultSpecUrl: UrlPath): Promise { @@ -75,13 +74,11 @@ Let's get started!`; } public validateSpecStep() { - const message = `Step 2 of 4: Validate and Lint your OpenAPI file`; - log.step(message); + log.info(`Step 2 of 4: Validate and Lint your OpenAPI file`); } public selectLanguagesStep() { - const message = `Step 3 of 4: Select programming languages`; - log.step(message); + log.info(`Step 3 of 4: Select programming languages`); } public async selectLanguagesPrompt(): Promise { @@ -112,8 +109,7 @@ Let's get started!`; } public selectInputDirectoryStep() { - const message = `Step 4 of 4: Generate source files for Docs as Code`; - log.step(message); + log.info(`Step 4 of 4: Generate source files for Docs as Code`); } public async inputDirectoryPathPrompt(): Promise { @@ -177,9 +173,10 @@ ${f.link(referenceDocumentationUrl)}`; log.error(getErrorMessage(error)); } - public printDirectoryStructure(directory: Directory) { + public printDirectoryStructure(inputDirectory: DirectoryPath, directory: Directory) { + const heading = `${f.var('src')} directory containing source files created at ${f.path(inputDirectory)}\n`; const message = getTree(directory.toTreeNode()); - log.info(message); + log.info(heading + message); } public specValidationFailed() { diff --git a/src/prompts/portal/recipe/new-recipe.ts b/src/prompts/portal/recipe/new-recipe.ts index b582df945..012f60796 100644 --- a/src/prompts/portal/recipe/new-recipe.ts +++ b/src/prompts/portal/recipe/new-recipe.ts @@ -4,7 +4,6 @@ import { DirectoryPath } from "../../../types/file/directoryPath.js"; import { format as f, getTree, TreeNode } from "../../format.js"; import { StepType } from "../../../types/recipe/recipe.js"; import { getErrorMessage, ServiceError } from "../../../infrastructure/api-utils.js"; -import { Dir } from "node:fs"; export class PortalRecipePrompts { public displayWelcomeMessage(): void { @@ -14,7 +13,7 @@ export class PortalRecipePrompts { An API Recipe is a collection of steps that allows you to define a single use case for your API Documentation portal. Learn more: ${f.link("https://docs.apimatic.io/platform-api/#/http/guides/generating-on-prem-api-portal/api-recipes")} Let's get started!`; - log.message(message); + log.info(message); } public recipeNameEmpty() { diff --git a/src/prompts/portal/serve.ts b/src/prompts/portal/serve.ts index e7aad9f2e..67dc90120 100644 --- a/src/prompts/portal/serve.ts +++ b/src/prompts/portal/serve.ts @@ -1,7 +1,8 @@ -import { log } from "@clack/prompts"; +import { log, note } from "@clack/prompts"; import { format as f } from "../format.js"; import { UrlPath } from "../../types/file/urlPath.js"; import { once } from "events"; +import { DirectoryPath } from "../../types/file/directoryPath.js"; export class PortalServePrompts { public usingFallbackPort(currentPort: number, availablePort: number) { @@ -12,12 +13,12 @@ export class PortalServePrompts { } public portalServed(urlPath: UrlPath) { - const message = `Portal hosted at ${f.link(urlPath.toString())}`; + const message = `The portal is running at ${f.link(urlPath.toString())}`; log.message(message); } public promptForExit() { - const message = "Press CTRL+C to exit."; + const message = "Press CTRL+C to stop the server."; log.message(message); } @@ -28,11 +29,16 @@ export class PortalServePrompts { public watcherError() { const message = - "An unexpected error occurred while watching your build folder for changes. Please try again later. If the issue persists, contact our team at support@apimatic.io"; + `An unexpected error occurred while watching your build folder for changes. Please try again later. If the issue persists, contact our team at ${f.var('support@apimatic.io')}`; log.error(message); } public async blockExecution() { await Promise.race([once(process, "SIGINT"), once(process, "SIGTERM")]); } + + public hotReloadEnabled(srcDirectory: DirectoryPath) { + note(`Hot reload is enabled. +Watching the directory ${f.path(srcDirectory)} for any changes`, `Note`); + } } diff --git a/src/prompts/portal/toc/new-toc.ts b/src/prompts/portal/toc/new-toc.ts index 9eb2c509c..d7205c552 100644 --- a/src/prompts/portal/toc/new-toc.ts +++ b/src/prompts/portal/toc/new-toc.ts @@ -7,14 +7,9 @@ import { ServiceError } from "../../../infrastructure/api-utils.js"; import { Sdl } from "../../../types/sdl/sdl.js"; export class PortalNewTocPrompts { - - public generateTOC(fn: Promise>) { - return withSpinner("Generating TOC", "TOC generated successfully.", "TOC generation failed.", fn); - } - public async overwriteToc(tocPath: FilePath): Promise { const overwrite = await confirm({ - message: `The destination file '${tocPath}' already exists, do you want to overwrite it?`, + message: `The destination file ${f.path(tocPath)} already exists, do you want to overwrite it?`, initialValue: false }); @@ -56,4 +51,8 @@ export class PortalNewTocPrompts { fn ); } + + public tocCreated(tocPath: FilePath) { + log.info(`The TOC file successfully created at: ${f.path(tocPath)}`) + } } From 241f1bb8581f4b9c872fe7a99ee36eda51829a4f Mon Sep 17 00:00:00 2001 From: Shield-Jaguar Date: Thu, 4 Sep 2025 19:13:09 +0500 Subject: [PATCH 68/77] refactor --- src/actions/portal/recipe/new-recipe.ts | 5 +++-- src/prompts/portal/generate.ts | 4 ++-- src/prompts/portal/recipe/new-recipe.ts | 23 ++++++++++++++++++----- src/prompts/portal/toc/new-toc.ts | 14 ++++++-------- 4 files changed, 29 insertions(+), 17 deletions(-) diff --git a/src/actions/portal/recipe/new-recipe.ts b/src/actions/portal/recipe/new-recipe.ts index d46738d2c..ff8d58d0e 100644 --- a/src/actions/portal/recipe/new-recipe.ts +++ b/src/actions/portal/recipe/new-recipe.ts @@ -106,7 +106,7 @@ export class PortalRecipeAction { const specFileStream = await this.fileService.getStream(specZipPath); try { - return await this.portalService.generateSdl(specFileStream, this.configDirectory, this.commandMetadata); + return await this.prompts.generateSdl(this.portalService.generateSdl(specFileStream, this.configDirectory, this.commandMetadata)) } finally { specFileStream.close(); } @@ -150,8 +150,9 @@ export class PortalRecipeAction { ); const tocStructure = this.getTocStructure(recipeFileName); + this.prompts.recipeCreated(); this.prompts.displayRecipeStructure(tocStructure); - this.prompts.nextSteps(buildDirectory); + this.prompts.nextSteps(); return ActionResult.success(); } diff --git a/src/prompts/portal/generate.ts b/src/prompts/portal/generate.ts index 23817e5d7..190a9a853 100644 --- a/src/prompts/portal/generate.ts +++ b/src/prompts/portal/generate.ts @@ -44,8 +44,8 @@ export class PortalGeneratePrompts { } portalGenerationErrorWithReport(reportPath: FilePath) { - const message = `An error occurred during portal generation due to an issue with the input. -An error report has been written at the destination path: ${f.path(reportPath)}`; + const message = `An error occurred during portal generation. +A report has been written at the destination path ${f.path(reportPath)}`; log.error(message); } diff --git a/src/prompts/portal/recipe/new-recipe.ts b/src/prompts/portal/recipe/new-recipe.ts index 012f60796..31bfd62b9 100644 --- a/src/prompts/portal/recipe/new-recipe.ts +++ b/src/prompts/portal/recipe/new-recipe.ts @@ -1,9 +1,10 @@ import { select, text, isCancel, log, autocomplete, confirm, note } from "@clack/prompts"; -import { SdlEndpoint } from "../../../types/sdl/sdl.js"; +import { Sdl, SdlEndpoint } from "../../../types/sdl/sdl.js"; import { DirectoryPath } from "../../../types/file/directoryPath.js"; -import { format as f, getTree, TreeNode } from "../../format.js"; +import { format as f, getTree, TreeNode, withSpinner } from "../../format.js"; import { StepType } from "../../../types/recipe/recipe.js"; import { getErrorMessage, ServiceError } from "../../../infrastructure/api-utils.js"; +import { Result } from "neverthrow"; export class PortalRecipePrompts { public displayWelcomeMessage(): void { @@ -187,9 +188,21 @@ Let's proceed to adding steps to your API Recipe.`; log.info(heading + message); } - public nextSteps(buildDirectory: DirectoryPath) { - const message = `Your new API Recipe has been added to the source directory at: ${f.path(buildDirectory)} -Run the command '${f.cmdAlt(`apimatic`, 'portal', 'serve')}' to preview your documentation portal`; + public nextSteps() { + const message = `Run the command '${f.cmdAlt(`apimatic`, 'portal', 'serve')}' to preview your documentation portal`; note(message, "Next Steps"); } + + public generateSdl(fn: Promise>) { + return withSpinner( + "Extracting endpoints", + "Endpoints extracted", + "Endpoints extraction failed", + fn + ); + } + + public recipeCreated() { + log.info(`A new API Recipe has been created successfully.`); + } } diff --git a/src/prompts/portal/toc/new-toc.ts b/src/prompts/portal/toc/new-toc.ts index d7205c552..3441cc175 100644 --- a/src/prompts/portal/toc/new-toc.ts +++ b/src/prompts/portal/toc/new-toc.ts @@ -21,12 +21,11 @@ export class PortalNewTocPrompts { } public fallingBackToDefault() { - log.warn(`Falling back to default TOC structure.`); + log.warn(`Falling back to the default TOC structure.`); } public tocFileAlreadyExists() { - const message = `Please enter a different destination path or delete the existing toc.yml file and try again.`; - log.error(message); + log.error(`Please enter a different destination path or delete the existing toc.yml file and try again.`); } public logError(message: string) { @@ -34,8 +33,7 @@ export class PortalNewTocPrompts { } public contentDirectoryNotFound(contentFolderPath: DirectoryPath) { - const message = `Content folder not found at: ${contentFolderPath}`; - log.error(message); + log.error(`Content folder not found at: ${contentFolderPath}`); } public invalidBuildDirectory(directory: DirectoryPath) { @@ -46,13 +44,13 @@ export class PortalNewTocPrompts { public extractEndpointGroupsAndModels(fn: Promise>) { return withSpinner( "Extracting endpoint groups and models", - "Endpoint groups and models extracted.", - "Endpoint groups and models extraction failed.", + "Endpoint groups and models extracted", + "Endpoint groups and models extraction failed", fn ); } public tocCreated(tocPath: FilePath) { - log.info(`The TOC file successfully created at: ${f.path(tocPath)}`) + log.info(`The TOC file successfully created at: ${f.path(tocPath)}`); } } From dfdba2cbc9cae4cffd5c09fe6947c3656a9dec93 Mon Sep 17 00:00:00 2001 From: Ali Asghar <75574550+aliasghar98@users.noreply.github.com> Date: Fri, 5 Sep 2025 11:08:04 +0500 Subject: [PATCH 69/77] fix: minor issues found during testing --- src/actions/api/transform.ts | 8 +++++--- src/actions/auth/logout.ts | 3 +-- src/actions/portal/generate.ts | 14 ++++++-------- src/actions/portal/serve.ts | 2 +- src/infrastructure/services/portal-service.ts | 4 ++-- src/prompts/api/validate.ts | 6 ++++++ src/prompts/auth/logout.ts | 2 +- src/prompts/sdk/generate.ts | 2 +- src/types/transform-context.ts | 3 ++- 9 files changed, 25 insertions(+), 19 deletions(-) diff --git a/src/actions/api/transform.ts b/src/actions/api/transform.ts index 82e68dcab..4efbeb9b8 100644 --- a/src/actions/api/transform.ts +++ b/src/actions/api/transform.ts @@ -10,7 +10,6 @@ import { TransformContext } from "../../types/transform-context.js"; import { ResourceInput } from "../../types/file/resource-input.js"; import { ResourceContext } from "../../types/resource-context.js"; - export class TransformAction { private readonly prompts: ApiTransformPrompts = new ApiTransformPrompts(); private readonly validatePrompts: ApiValidatePrompts = new ApiValidatePrompts(); @@ -34,7 +33,7 @@ export class TransformAction { return await withDirPath(async (tempDirectory) => { const resourceContext = new ResourceContext(tempDirectory); const specFileDirResult = await resourceContext.resolveTo(resourcePath); - if (specFileDirResult.isErr()){ + if (specFileDirResult.isErr()) { this.prompts.networkError(specFileDirResult.error); return ActionResult.failed(); } @@ -59,8 +58,11 @@ export class TransformAction { return ActionResult.failed(); } - await transformContext.save(result.value.stream); + const transformedFilePath = await transformContext.save(result.value.stream); this.validatePrompts.displayValidationMessages(result.value.apiValidationSummary); + + this.validatePrompts.transformedApiSaved(transformedFilePath); + return ActionResult.success(); }); }; diff --git a/src/actions/auth/logout.ts b/src/actions/auth/logout.ts index 44e6c9e19..f03a10d71 100644 --- a/src/actions/auth/logout.ts +++ b/src/actions/auth/logout.ts @@ -4,14 +4,13 @@ import { removeAuthInfo } from "../../client-utils/auth-manager.js"; import { LogoutPrompts } from "../../prompts/auth/logout.js"; export class LogoutAction { - private readonly prompts = new LogoutPrompts(); constructor(private readonly configDir: DirectoryPath) {} public async execute(): Promise { + await removeAuthInfo(this.configDir); this.prompts.removeAuthInfo(); - await removeAuthInfo(this.configDir) return ActionResult.success(); } } diff --git a/src/actions/portal/generate.ts b/src/actions/portal/generate.ts index 7fea19497..c23e8d6c3 100644 --- a/src/actions/portal/generate.ts +++ b/src/actions/portal/generate.ts @@ -27,7 +27,8 @@ export class GenerateAction { buildDirectory: DirectoryPath, portalDirectory: DirectoryPath, force: boolean, - zipPortal: boolean + zipPortal: boolean, + displayMessages: boolean = true ): Promise => { if (buildDirectory.isEqual(portalDirectory)) { this.prompts.directoryCannotBeSame(portalDirectory); @@ -51,12 +52,7 @@ export class GenerateAction { const buildZipPath = await tempContext.zip(buildDirectory); const response = await this.prompts.generatePortal( - this.portalService.generatePortal( - buildZipPath, - this.configDir, - this.commandMetadata, - this.authKey - ) + this.portalService.generatePortal(buildZipPath, this.configDir, this.commandMetadata, this.authKey) ); if (response.isErr()) { @@ -75,7 +71,9 @@ export class GenerateAction { const tempPortalZipPath = await tempContext.save(response.value); await portalContext.save(tempPortalZipPath, zipPortal); - this.prompts.portalGenerated(portalDirectory); + if (displayMessages) { + this.prompts.portalGenerated(portalDirectory); + } return ActionResult.success(); }); diff --git a/src/actions/portal/serve.ts b/src/actions/portal/serve.ts index c394102cb..2ef950620 100644 --- a/src/actions/portal/serve.ts +++ b/src/actions/portal/serve.ts @@ -120,7 +120,7 @@ export class PortalServeAction { return; } - await generatePortalAction.execute(buildDirectory, portalDirectory, true, false); + await generatePortalAction.execute(buildDirectory, portalDirectory, true, false, false); liveReloadServer.refresh(portalDirectory.toString()); this.clearStandardInput(); diff --git a/src/infrastructure/services/portal-service.ts b/src/infrastructure/services/portal-service.ts index 6b09b846b..b88eb1a70 100644 --- a/src/infrastructure/services/portal-service.ts +++ b/src/infrastructure/services/portal-service.ts @@ -191,9 +191,9 @@ export class PortalService { // Check if it has the expected structure with Errors if (parsedResult.Errors && Array.isArray(parsedResult.Errors) && parsedResult.Errors.length > 0) { // Get the first error and clean it up - const firstError = parsedResult.Errors[0]; + return parsedResult.Errors[0].split(".")[0] + "."; // Split on
and take the first part, then strip remaining HTML tags - return firstError.split("
")[0].replace(/<[^<>]*?>/g, ""); + // return firstError.split("
")[0].replace(/<[^<>]*?>/g, ""); } else if (parsedResult.Success === false) { return "API definition file validation failed."; } diff --git a/src/prompts/api/validate.ts b/src/prompts/api/validate.ts index 346fbc78f..2eeaf62b3 100644 --- a/src/prompts/api/validate.ts +++ b/src/prompts/api/validate.ts @@ -5,6 +5,8 @@ import { Result } from "neverthrow"; import { withSpinner } from "../format.js"; import { ApiValidationSummary } from "@apimatic/sdk"; import { getErrorMessage, ServiceError } from "../../infrastructure/api-utils.js"; +import { FilePath } from "../../types/file/filePath.js"; +import { format as f } from "../format.js"; export class ApiValidatePrompts { public async validateApi(fn: Promise>) { @@ -40,4 +42,8 @@ export class ApiValidatePrompts { const message = getErrorMessage(serviceError); log.error(message); } + + public transformedApiSaved(filePath: FilePath): void { + log.info(`Transformed API has been saved to ${f.path(filePath)}.`); + } } diff --git a/src/prompts/auth/logout.ts b/src/prompts/auth/logout.ts index 023d4e7e2..8646f428f 100644 --- a/src/prompts/auth/logout.ts +++ b/src/prompts/auth/logout.ts @@ -4,6 +4,6 @@ import { log } from "@clack/prompts"; export class LogoutPrompts { public removeAuthInfo() { - log.info("Removing auth info"); + log.info("Logged out successfully."); } } diff --git a/src/prompts/sdk/generate.ts b/src/prompts/sdk/generate.ts index 9125f5451..99f009149 100644 --- a/src/prompts/sdk/generate.ts +++ b/src/prompts/sdk/generate.ts @@ -42,6 +42,6 @@ export class SdkGeneratePrompts { } public sdkGenerated(sdk: DirectoryPath) { - log.info(`Generated SDK at ${f.path(sdk)}.`); + log.info(`Generated SDK can be found at ${f.path(sdk)}.`); } } diff --git a/src/types/transform-context.ts b/src/types/transform-context.ts index 67457a28e..4485350b8 100644 --- a/src/types/transform-context.ts +++ b/src/types/transform-context.ts @@ -27,9 +27,10 @@ export class TransformContext { } - public async save(stream: NodeJS.ReadableStream): Promise { + public async save(stream: NodeJS.ReadableStream): Promise { await this.fileService.createDirectoryIfNotExists(this.destinationDirectory); await this.fileService.writeFile(this.specPath, stream); + return this.specPath; } private parseFileName(format: string, file: FilePath): FileName { From 98f3395766e79a2297eccfdd25e50c48a460dc01 Mon Sep 17 00:00:00 2001 From: Ali Asghar <75574550+aliasghar98@users.noreply.github.com> Date: Fri, 5 Sep 2025 11:21:06 +0500 Subject: [PATCH 70/77] fix: resolved security review warnings --- .../services/file-download-service.ts | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/src/infrastructure/services/file-download-service.ts b/src/infrastructure/services/file-download-service.ts index a8cdc871a..de9def462 100644 --- a/src/infrastructure/services/file-download-service.ts +++ b/src/infrastructure/services/file-download-service.ts @@ -57,7 +57,7 @@ export class FileDownloadService { private parseFilenameFromContentDisposition(headerValue: string): string | null { // Try RFC 5987: filename*=UTF-8''encoded%20name.ext - const filenameStarMatch = headerValue.match(/filename\*\s*=\s*([^']*)''([^;]+)/i); + const filenameStarMatch = headerValue.match(/filename\*\s*=\s*([^']*?)''([^;]+)/i); if (filenameStarMatch) { const encoded = filenameStarMatch[2].trim(); try { @@ -103,15 +103,14 @@ export class FileDownloadService { } } - private sanitizeFilename(name: string): string { // Replace characters not allowed in common filesystems and trim dots/spaces - const sanitized = name - .replace('/[<>:"/\\|?*\x00-\x1F]/g', "_") - .replace(/\s+/g, " ") - .trim(); + const sanitized = name.replace('/[<>:"/\\|?*\x00-\x1F]/g', "_").replace(/\s+/g, " ").trim(); // Avoid names that are empty or only dots/spaces - const safe = sanitized.replace(/^[. ]+|[. ]+$/g, ""); + const safe = sanitized + .replace(/^\.+/, "") // remove all leading dots + .replace(/\.+$/, "") // remove all trailing dots (not just one) + .trim(); // remove spaces on both sides return safe.length > 0 ? safe : "file"; } } From 55d71cd51514eb7b835653dc36b770d79ba11a45 Mon Sep 17 00:00:00 2001 From: Ali Asghar <75574550+aliasghar98@users.noreply.github.com> Date: Fri, 5 Sep 2025 11:32:56 +0500 Subject: [PATCH 71/77] revert: regex changes --- src/infrastructure/services/file-download-service.ts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/infrastructure/services/file-download-service.ts b/src/infrastructure/services/file-download-service.ts index de9def462..ba8c5f94c 100644 --- a/src/infrastructure/services/file-download-service.ts +++ b/src/infrastructure/services/file-download-service.ts @@ -57,7 +57,7 @@ export class FileDownloadService { private parseFilenameFromContentDisposition(headerValue: string): string | null { // Try RFC 5987: filename*=UTF-8''encoded%20name.ext - const filenameStarMatch = headerValue.match(/filename\*\s*=\s*([^']*?)''([^;]+)/i); + const filenameStarMatch = headerValue.match(/filename\*\s*=\s*([^']*)''([^;]+)/i); if (filenameStarMatch) { const encoded = filenameStarMatch[2].trim(); try { @@ -105,12 +105,12 @@ export class FileDownloadService { private sanitizeFilename(name: string): string { // Replace characters not allowed in common filesystems and trim dots/spaces - const sanitized = name.replace('/[<>:"/\\|?*\x00-\x1F]/g', "_").replace(/\s+/g, " ").trim(); + const sanitized = name + .replace('/[<>:"/\\|?*\x00-\x1F]/g', "_") + .replace(/\s+/g, " ") + .trim(); // Avoid names that are empty or only dots/spaces - const safe = sanitized - .replace(/^\.+/, "") // remove all leading dots - .replace(/\.+$/, "") // remove all trailing dots (not just one) - .trim(); // remove spaces on both sides + const safe = sanitized.replace(/^[. ]+|[. ]+$/g, ""); return safe.length > 0 ? safe : "file"; } } From 68e07667ad75fe6b11362fe2cdca4d1ea1e4060c Mon Sep 17 00:00:00 2001 From: Ali Asghar <75574550+aliasghar98@users.noreply.github.com> Date: Fri, 5 Sep 2025 12:03:49 +0500 Subject: [PATCH 72/77] fix: removed fallback port message from quickstart --- src/actions/portal/serve.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/actions/portal/serve.ts b/src/actions/portal/serve.ts index 2ef950620..e58e9b012 100644 --- a/src/actions/portal/serve.ts +++ b/src/actions/portal/serve.ts @@ -45,7 +45,7 @@ export class PortalServeAction { } const servePort = await this.networkService.getServerPort([port, 3000, 3001, 3002]); - if (servePort != port) { + if (servePort != port && !onAfterServe) { this.prompts.usingFallbackPort(port, servePort); } From cbf365c2bfe22daa1ffeb77443f4e9a2bdd9679a Mon Sep 17 00:00:00 2001 From: Ali Asghar <75574550+aliasghar98@users.noreply.github.com> Date: Fri, 5 Sep 2025 12:04:08 +0500 Subject: [PATCH 73/77] chore: updated cli version to 1.1.0-alpha.22 --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index baed2ecc2..c5aa171e0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@apimatic/cli", - "version": "1.1.0-alpha.21", + "version": "1.1.0-alpha.22", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@apimatic/cli", - "version": "1.1.0-alpha.21", + "version": "1.1.0-alpha.22", "license": "MIT", "dependencies": { "@apimatic/sdk": "^0.2.0-alpha.4", diff --git a/package.json b/package.json index 1aeff2bfa..b9305fc60 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "@apimatic/cli", "description": "The official CLI for APIMatic.", - "version": "1.1.0-alpha.21", + "version": "1.1.0-alpha.22", "author": "APIMatic", "bin": { "apimatic": "./bin/run.js" From cd2e709c401ef8fd3dd929ed8d8f5b83b2217236 Mon Sep 17 00:00:00 2001 From: saeedjamshaid Date: Fri, 5 Sep 2025 15:18:32 +0500 Subject: [PATCH 74/77] minor fixes --- src/actions/sdk/generate.ts | 4 ++-- src/prompts/sdk/generate.ts | 2 +- src/types/api/transform.ts | 4 ++-- src/types/sdk-context.ts | 14 ++++++++------ 4 files changed, 13 insertions(+), 11 deletions(-) diff --git a/src/actions/sdk/generate.ts b/src/actions/sdk/generate.ts index fcddfb553..72d3f027e 100644 --- a/src/actions/sdk/generate.ts +++ b/src/actions/sdk/generate.ts @@ -61,9 +61,9 @@ export class GenerateAction { } const tempSdkFilePath = await tempContext.save(response.value); - await sdkContext.save(tempSdkFilePath, zipSdk); + const sdkLanguageDirectory = await sdkContext.save(tempSdkFilePath, zipSdk); - this.prompts.sdkGenerated(sdkContext.sdkLanguageDirectory); + this.prompts.sdkGenerated(sdkLanguageDirectory); return ActionResult.success(); }); diff --git a/src/prompts/sdk/generate.ts b/src/prompts/sdk/generate.ts index 99f009149..b00ddfdb9 100644 --- a/src/prompts/sdk/generate.ts +++ b/src/prompts/sdk/generate.ts @@ -6,7 +6,7 @@ import { Result } from "neverthrow"; export class SdkGeneratePrompts { public async overwriteSdk(directory: DirectoryPath): Promise { const overwrite = await confirm({ - message: `The destination '${directory}' is not empty, do you want to overwrite?`, + message: `The destination ${f.path(directory)} is not empty, do you want to overwrite?`, initialValue: false }); diff --git a/src/types/api/transform.ts b/src/types/api/transform.ts index a6a91dd1e..6220648c8 100644 --- a/src/types/api/transform.ts +++ b/src/types/api/transform.ts @@ -12,8 +12,8 @@ export const DestinationFormats = { Swagger10: "json", Swagger20: "json", SwaggerYaml: "yaml", - RAML: "yaml", - RAML10: "yaml", + RAML: "raml", + RAML10: "raml", Postman10: "json", Postman20: "json", GraphQlSchema: "json" diff --git a/src/types/sdk-context.ts b/src/types/sdk-context.ts index 35af3a69f..8d6131025 100644 --- a/src/types/sdk-context.ts +++ b/src/types/sdk-context.ts @@ -9,26 +9,28 @@ export class SdkContext { private readonly fileService = new FileService(); private readonly zipService = new ZipService(); - constructor(private readonly sdkDirectory: DirectoryPath, private readonly language: Language) {} + constructor(private readonly sdkDirectory: DirectoryPath, private readonly language: Language) { + } - private get ZipPath(): FilePath { + private get zipPath(): FilePath { return new FilePath(this.sdkLanguageDirectory, new FileName(`${this.language}.zip`)); } - - public get sdkLanguageDirectory(): DirectoryPath{ + + public get sdkLanguageDirectory(): DirectoryPath { return this.sdkDirectory.join(this.language); } public async exists() { - return !(await this.fileService.directoryEmpty(this.sdkDirectory)); + return !(await this.fileService.directoryEmpty(this.sdkLanguageDirectory)); } public async save(tempPortalFilePath: FilePath, zipPortal: boolean) { await this.fileService.cleanDirectory(this.sdkLanguageDirectory); if (zipPortal) { - await this.fileService.copy(tempPortalFilePath, this.ZipPath); + await this.fileService.copy(tempPortalFilePath, this.zipPath); } else { await this.zipService.unArchive(tempPortalFilePath, this.sdkLanguageDirectory); } + return this.sdkLanguageDirectory; } } From 17478ae4061ab80e7075fbe41a053d235ded5af5 Mon Sep 17 00:00:00 2001 From: saeedjamshaid Date: Fri, 5 Sep 2025 15:38:51 +0500 Subject: [PATCH 75/77] make the format flag in api transform consistent --- src/commands/api/transform.ts | 39 +++++++++++------------------------ src/types/api/transform.ts | 28 ++++++++++++------------- 2 files changed, 26 insertions(+), 41 deletions(-) diff --git a/src/commands/api/transform.ts b/src/commands/api/transform.ts index c6d769d1e..6c54dd542 100644 --- a/src/commands/api/transform.ts +++ b/src/commands/api/transform.ts @@ -7,7 +7,6 @@ import { format, intro, outro } from "../../prompts/format.js"; import { createResourceInput } from "../../types/file/resource-input.js"; import { TransformationFormats } from "../../types/api/transform.js"; import { ExportFormats } from "@apimatic/sdk"; -import { err, ok, Result } from "neverthrow"; const DEFAULT_WORKING_DIRECTORY = "./"; @@ -31,10 +30,11 @@ Supports multiple formats including OpenAPI/Swagger, RAML, WSDL, and Postman Col ]; static flags = { - format: Flags.string({ - required: true, - description: "Specification format to transform API specification into" - }), + format: Flags.string({ + required: true, + description: "Specification format to transform API specification into", + options: Object.keys(TransformationFormats) + }), file: Flags.string({ description: "Path to the API specification file to transform" }), @@ -55,15 +55,13 @@ Supports multiple formats including OpenAPI/Swagger, RAML, WSDL, and Postman Col flags: { format, file, url, destination, force, "auth-key": authKey } } = await this.parse(Transform); - const workingDirectory = new DirectoryPath(destination ?? DEFAULT_WORKING_DIRECTORY); - const transformedApiDirectory = workingDirectory.join("transformations"); - const specFile = createResourceInput(file, url); - const parsedFormatResult = this.getValidFormat(format); - - if (parsedFormatResult.isErr()) { - this.error(parsedFormatResult.error); - } - const parsedFormat = parsedFormatResult.value; + const workingDirectory = new DirectoryPath(destination ?? DEFAULT_WORKING_DIRECTORY); + const transformedApiDirectory = workingDirectory.join("transformations"); + const specFile = createResourceInput(file, url); + // Directly map the format flag to ExportFormats using TransformationFormats + const key = format as keyof typeof TransformationFormats; + const transformationFormat = TransformationFormats[key] as keyof typeof ExportFormats; + const parsedFormat = ExportFormats[transformationFormat]; const commandMetadata: CommandMetadata = { commandName: Transform.id, @@ -79,17 +77,4 @@ Supports multiple formats including OpenAPI/Swagger, RAML, WSDL, and Postman Col private readonly getConfigDir = () => { return new DirectoryPath(this.config.configDir); }; - - private readonly getValidFormat = (format: string): Result => { - const key = Object.keys(TransformationFormats).find((value) => value === format) as - | keyof typeof TransformationFormats - | undefined; - if (key) { - const transformationFormat = TransformationFormats[key] as keyof typeof ExportFormats; - return ok(ExportFormats[transformationFormat]); - } else { - const formats = Object.keys(TransformationFormats).join("|"); - return err(`Please provide a valid platform, e.g. ${formats}`); - } - }; } diff --git a/src/types/api/transform.ts b/src/types/api/transform.ts index 6220648c8..9d8b80871 100644 --- a/src/types/api/transform.ts +++ b/src/types/api/transform.ts @@ -20,18 +20,18 @@ export const DestinationFormats = { }; export enum TransformationFormats { - APIMATIC = 'Apimatic', - WADL2009 = 'Wadl2009', - WSDL = 'Wsdl', - SWAGGER10 = 'Swagger10', - SWAGGER20 = 'Swagger20', - SWAGGERYAML = 'Swaggeryaml', - OAS3 = 'Oas3', - OPENAPI3YAML = 'Openapi3Yaml', - APIBLUEPRINT = 'Apiblueprint', - RAML = 'Raml', - RAML10 = 'Raml10', - POSTMAN10 = 'Postman10', - POSTMAN20 = 'Postman20', - GRAPHQLSCHEMA = 'Graphqlschema', + apimatic = 'Apimatic', + wadl2009 = 'Wadl2009', + wsdl = 'Wsdl', + swagger10 = 'Swagger10', + swagger20 = 'Swagger20', + swaggeryaml = 'Swaggeryaml', + oas3 = 'Oas3', + openapi3yaml = 'Openapi3Yaml', + apiblueprint = 'Apiblueprint', + raml = 'Raml', + raml10 = 'Raml10', + postman10 = 'Postman10', + postman20 = 'Postman20', + graphqlschema = 'Graphqlschema', } From 9320a07dca5763950a568839486b83137a381e40 Mon Sep 17 00:00:00 2001 From: saeedjamshaid Date: Fri, 5 Sep 2025 16:12:15 +0500 Subject: [PATCH 76/77] update next step messages in quickstart and recipe command --- src/prompts/portal/quickstart.ts | 15 ++++++++------- src/prompts/portal/recipe/new-recipe.ts | 1 + 2 files changed, 9 insertions(+), 7 deletions(-) diff --git a/src/prompts/portal/quickstart.ts b/src/prompts/portal/quickstart.ts index 4905ae648..75b9075ad 100644 --- a/src/prompts/portal/quickstart.ts +++ b/src/prompts/portal/quickstart.ts @@ -18,6 +18,7 @@ export class PortalQuickstartPrompts { public welcomeMessage() { log.info(`Welcome to the Portal Quickstart Wizard.`); const message = `This wizard will help you set up an API Portal via APIMatic's Docs as Code workflow in 4 simple steps. + Let's get started!`; log.message(message); } @@ -146,14 +147,14 @@ Let's get started!`; } public noInputDirectoryProvided() { - log.error("No build directory was provided."); + log.error("No source directory was provided."); } public downloadBuildDirectory(fn: Promise>) { return withSpinner( - "Downloading build directory", - `Build directory downloaded successfully`, - "Unable to download build directory", + "Setting up source directory", + `Source directory set up successfully`, + "Unable to set up source directory", fn ); } @@ -163,9 +164,9 @@ Let's get started!`; } public nextSteps(): void { - const message = `Use the API Playground or an SDK to call your API. -Customize the Portal theme, add API recipes and enable AI features -${f.link(referenceDocumentationUrl)}`; + const message = `- Use the API Playground or an SDK to call your API. +- Customize the Portal theme, add API recipes and enable AI features + ${f.link(referenceDocumentationUrl)}`; note(message, "Next steps"); } diff --git a/src/prompts/portal/recipe/new-recipe.ts b/src/prompts/portal/recipe/new-recipe.ts index 31bfd62b9..e31e61d37 100644 --- a/src/prompts/portal/recipe/new-recipe.ts +++ b/src/prompts/portal/recipe/new-recipe.ts @@ -13,6 +13,7 @@ export class PortalRecipePrompts { An API Recipe is a collection of steps that allows you to define a single use case for your API Documentation portal. Learn more: ${f.link("https://docs.apimatic.io/platform-api/#/http/guides/generating-on-prem-api-portal/api-recipes")} + Let's get started!`; log.info(message); } From 44348ac9b95521bac6b8f67e747e16195a5288b1 Mon Sep 17 00:00:00 2001 From: Ali Asghar <75574550+aliasghar98@users.noreply.github.com> Date: Fri, 5 Sep 2025 16:56:18 +0500 Subject: [PATCH 77/77] chore: updated readme and added annotations to ignore sonar warnings --- README.md | 134 ++++++++++-------- src/commands/auth/logout.ts | 2 +- .../services/file-download-service.ts | 4 +- 3 files changed, 81 insertions(+), 59 deletions(-) diff --git a/README.md b/README.md index bc5974d9f..1b79ffafa 100644 --- a/README.md +++ b/README.md @@ -24,7 +24,7 @@ $ npm install -g @apimatic/cli $ apimatic COMMAND running command... $ apimatic (--version) -@apimatic/cli/1.1.0-alpha.21 win32-x64 node-v23.4.0 +@apimatic/cli/1.1.0-alpha.22 win32-x64 node-v23.4.0 $ apimatic --help [COMMAND] USAGE $ apimatic COMMAND @@ -51,39 +51,41 @@ USAGE ## `apimatic api transform` -Transform API specifications from one format to another. Supports [10+ different formats](https://www.apimatic.io/transformer/#supported-formats) including OpenApi/Swagger, RAML, WSDL and Postman Collections. +Transform API specifications between different formats. ``` USAGE - $ apimatic api transform --format [--file ] [--url ] [-d ] [-f] [-k ] + $ apimatic api transform --format + apimatic|wadl2009|wsdl|swagger10|swagger20|swaggeryaml|oas3|openapi3yaml|apiblueprint|raml|raml10|postman10|postman2 + 0|graphqlschema [--file ] [--url ] [-d ] [-f] [-k ] FLAGS - -d, --destination= [default: ./] directory to download transformed file to + -d, --destination= [default: ./] directory to save the transformed file to -f, --force overwrite changes without asking for user consent. -k, --auth-key= override current authentication state with an authentication key. --file= path to the API specification file to transform - --format= (required) specification format to transform API specification into - APIMATIC|WADL2009|WSDL|SWAGGER10|SWAGGER20|SWAGGERYAML|OAS3|OPENAPI3YAML|APIBLUEPRINT|RAML| - RAML10|POSTMAN10|POSTMAN20|GRAPHQLSCHEMA - --url= URL to the API specification file to transform. Can be used in place of the --file option - if the API specification is publicly available. + --format=