├── .editorconfig ├── .github └── workflows │ ├── check_build.yml │ ├── npm-tag-latest.yml │ └── release.yml ├── .gitignore ├── .husky ├── .gitignore ├── commit-msg └── pre-commit ├── .npmrc ├── .prettierrc ├── .vscode ├── extensions.json ├── launch.json └── settings.json ├── CHANGELOG.md ├── README.md ├── bin ├── run.cmd └── run.js ├── commitlint.config.js ├── eslint.config.js ├── package-lock.json ├── package.json ├── release.config.cjs ├── src ├── actions │ ├── action-result.ts │ ├── api │ │ ├── transform.ts │ │ └── validate.ts │ ├── auth │ │ ├── login.ts │ │ ├── logout.ts │ │ └── status.ts │ ├── portal │ │ ├── copilot.ts │ │ ├── generate.ts │ │ ├── quickstart.ts │ │ ├── recipe │ │ │ └── new-recipe.ts │ │ ├── serve.ts │ │ └── toc │ │ │ └── new-toc.ts │ ├── quickstart.ts │ └── sdk │ │ ├── generate.ts │ │ └── quickstart.ts ├── application │ └── portal │ │ ├── recipe │ │ ├── portal-recipe.ts │ │ └── recipe-generator.ts │ │ └── toc │ │ ├── toc-content-parser.ts │ │ └── toc-structure-generator.ts ├── client-utils │ └── auth-manager.ts ├── commands │ ├── api │ │ ├── transform.ts │ │ └── validate.ts │ ├── auth │ │ ├── login.ts │ │ ├── logout.ts │ │ └── status.ts │ ├── portal │ │ ├── copilot.ts │ │ ├── generate.ts │ │ ├── recipe │ │ │ └── new.ts │ │ ├── serve.ts │ │ └── toc │ │ │ └── new.ts │ ├── quickstart.ts │ └── sdk │ │ └── generate.ts ├── config │ └── axios-config.ts ├── hooks │ ├── not-found.ts │ └── utils.ts ├── index.ts ├── infrastructure │ ├── debounce-service.ts │ ├── env-info.ts │ ├── file-service.ts │ ├── launcher-service.ts │ ├── network-service.ts │ ├── service-error.ts │ ├── services │ │ ├── api-client-factory.ts │ │ ├── api-service.ts │ │ ├── auth-service.ts │ │ ├── file-download-service.ts │ │ ├── portal-service.ts │ │ ├── telemetry-service.ts │ │ ├── transformation-service.ts │ │ └── validation-service.ts │ ├── tmp-extensions.ts │ └── zip-service.ts ├── prompts │ ├── api │ │ ├── transform.ts │ │ └── validate.ts │ ├── auth │ │ ├── login.ts │ │ ├── logout.ts │ │ └── status.ts │ ├── format.ts │ ├── portal │ │ ├── copilot.ts │ │ ├── generate.ts │ │ ├── quickstart.ts │ │ ├── recipe │ │ │ └── new-recipe.ts │ │ ├── serve.ts │ │ └── toc │ │ │ └── new-toc.ts │ ├── prompt.ts │ ├── quickstart.ts │ └── sdk │ │ ├── generate.ts │ │ └── quickstart.ts ├── types │ ├── api │ │ ├── account.ts │ │ └── transform.ts │ ├── build-context.ts │ ├── build │ │ └── build.ts │ ├── common │ │ └── command-metadata.ts │ ├── events │ │ ├── domain-event.ts │ │ ├── quickstart-completed.ts │ │ ├── quickstart-initiated.ts │ │ ├── recipe-creation-failed.ts │ │ └── toc-creation-failed.ts │ ├── file │ │ ├── directory.ts │ │ ├── directoryPath.ts │ │ ├── fileName.ts │ │ ├── filePath.ts │ │ ├── resource-input.ts │ │ └── urlPath.ts │ ├── flags-provider.ts │ ├── portal-context.ts │ ├── recipe-context.ts │ ├── recipe │ │ └── recipe.ts │ ├── resource-context.ts │ ├── sdk-context.ts │ ├── sdk │ │ └── generate.ts │ ├── sdl │ │ └── sdl.ts │ ├── spec-context.ts │ ├── temp-context.ts │ ├── toc-context.ts │ ├── toc │ │ └── toc.ts │ ├── transform-context.ts │ └── utils.ts └── utils │ ├── string-utils.ts │ └── utils.ts ├── test ├── actions │ └── portal │ │ ├── serve.test.ts │ │ └── toc │ │ └── new-toc.test.ts ├── application │ └── portal │ │ ├── recipe │ │ └── new │ │ │ ├── portal-recipe.test.ts │ │ │ └── recipe-generator.test.ts │ │ ├── serve │ │ ├── portal-watcher.test.ts │ │ ├── serve-handler.test.ts │ │ └── watcher-handler.test.ts │ │ └── toc │ │ └── new │ │ ├── sdl-parser.test.ts │ │ ├── toc-content-parser.test.ts │ │ └── toc-structure-generator.test.ts ├── commands │ ├── examples-parse.test.ts │ └── portal │ │ └── generate.test.ts ├── mocha.opts ├── resources │ └── build-inputs │ │ └── default │ │ ├── APIMATIC-BUILD.json │ │ ├── content │ │ ├── guides │ │ │ ├── guide1.md │ │ │ └── toc.yml │ │ └── toc.yml │ │ ├── spec │ │ ├── APIMATIC-META.json │ │ └── Apimatic-Calculator.json │ │ └── static │ │ └── images │ │ └── logo.png └── tsconfig.json └── tsconfig.json /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = space 5 | indent_size = 2 6 | charset = utf-8 7 | trim_trailing_whitespace = true 8 | insert_final_newline = true 9 | 10 | [*.md] 11 | trim_trailing_whitespace = false 12 | -------------------------------------------------------------------------------- /.github/workflows/check_build.yml: -------------------------------------------------------------------------------- 1 | # This is a basic workflow to help you get started with Actions 2 | 3 | name: Run and check build for current commit 4 | 5 | # Controls when the workflow will run 6 | on: 7 | # Triggers the workflow on pull request events but only for the alpha and dev branches 8 | pull_request: 9 | branches: [ alpha, dev ] 10 | # Allows you to run this workflow manually from the Actions tab 11 | workflow_dispatch: 12 | 13 | # A workflow run is made up of one or more jobs that can run sequentially or in parallel 14 | jobs: 15 | build: 16 | runs-on: ubuntu-latest 17 | strategy: 18 | matrix: 19 | node: [ '18', '20', '22', '24' ] 20 | name: Node ${{ matrix.node }} sample 21 | steps: 22 | - name: checkout cli 23 | uses: actions/checkout@v4 24 | with: 25 | repository: apimatic/apimatic-cli 26 | token: ${{ secrets.GITHUB_TOKEN }} 27 | persist-credentials: false 28 | fetch-depth: 0 29 | path: cli 30 | 31 | - name: setup node 32 | uses: actions/setup-node@v2 33 | 34 | - name: Install dependencies 35 | working-directory: cli 36 | run: npm install 37 | 38 | - name: Check Build 39 | working-directory: 'cli' 40 | run: npm run build 41 | -------------------------------------------------------------------------------- /.github/workflows/npm-tag-latest.yml: -------------------------------------------------------------------------------- 1 | name: Set Latest Tag 2 | 3 | on: 4 | workflow_dispatch: 5 | 6 | jobs: 7 | release: 8 | runs-on: ubuntu-latest 9 | strategy: 10 | matrix: 11 | node: [ '14' ] 12 | name: Release with Node version ${{ matrix.node }} 13 | steps: 14 | - name: change-latest-tag 15 | env: 16 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 17 | NPM_TOKEN: ${{ secrets.NPM_TOKEN }} 18 | run: npm dist-tag add @apimatic/cli@${LATEST_VERSION} latest 19 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | push: 5 | branches: [ alpha, beta ] 6 | 7 | jobs: 8 | release: 9 | runs-on: ubuntu-latest 10 | strategy: 11 | matrix: 12 | node: [ '20' ] 13 | name: Release with Node version ${{ matrix.node }} 14 | steps: 15 | - name: Checkout CLI 16 | uses: actions/checkout@v4 17 | with: 18 | repository: apimatic/apimatic-cli 19 | token: ${{ secrets.GITHUB_TOKEN }} 20 | persist-credentials: false 21 | fetch-depth: 0 22 | path: cli 23 | 24 | - name: setup node 25 | uses: actions/setup-node@v4 26 | 27 | - name: Install dependencies 28 | working-directory: cli 29 | run: | 30 | npm install 31 | npm install --save-dev @semantic-release/changelog @semantic-release/git 32 | - name: Check Build 33 | working-directory: 'cli' 34 | run: npm run build 35 | 36 | - name: Release 37 | working-directory: cli 38 | env: 39 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 40 | NPM_TOKEN: ${{ secrets.NPM_TOKEN }} 41 | run: npx semantic-release 42 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # File created using '.gitignore Generator' for Visual Studio Code: https://bit.ly/vscode-gig 2 | 3 | # Created by https://www.toptal.com/developers/gitignore/api/windows,visualstudiocode,node 4 | # Edit at https://www.toptal.com/developers/gitignore?templates=windows,visualstudiocode,node 5 | 6 | ### Node ### 7 | # Logs 8 | logs 9 | *.log 10 | npm-debug.log* 11 | yarn-debug.log* 12 | yarn-error.log* 13 | lerna-debug.log* 14 | .pnpm-debug.log* 15 | 16 | # Diagnostic reports (https://nodejs.org/api/report.html) 17 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 18 | 19 | # Runtime data 20 | pids 21 | *.pid 22 | *.seed 23 | *.pid.lock 24 | 25 | # Directory for instrumented libs generated by jscoverage/JSCover 26 | lib-cov 27 | 28 | # Coverage directory used by tools like istanbul 29 | coverage 30 | *.lcov 31 | 32 | # nyc test coverage 33 | .nyc_output 34 | 35 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 36 | .grunt 37 | 38 | # Bower dependency directory (https://bower.io/) 39 | bower_components 40 | 41 | # node-waf configuration 42 | .lock-wscript 43 | 44 | # Compiled binary addons (https://nodejs.org/api/addons.html) 45 | build/Release 46 | 47 | # Dependency directories 48 | node_modules/ 49 | jspm_packages/ 50 | 51 | # Snowpack dependency directory (https://snowpack.dev/) 52 | web_modules/ 53 | 54 | # TypeScript cache 55 | *.tsbuildinfo 56 | 57 | # Optional npm cache directory 58 | .npm 59 | 60 | # Optional eslint cache 61 | .eslintcache 62 | 63 | # Microbundle cache 64 | .rpt2_cache/ 65 | .rts2_cache_cjs/ 66 | .rts2_cache_es/ 67 | .rts2_cache_umd/ 68 | 69 | # Optional REPL history 70 | .node_repl_history 71 | 72 | # Output of 'npm pack' 73 | *.tgz 74 | 75 | # Yarn Integrity file 76 | .yarn-integrity 77 | 78 | # dotenv environment variables file 79 | .env 80 | .env.test 81 | .env.production 82 | 83 | # parcel-bundler cache (https://parceljs.org/) 84 | .cache 85 | .parcel-cache 86 | 87 | # Next.js build output 88 | .next 89 | out 90 | 91 | # Nuxt.js build / generate output 92 | .nuxt 93 | dist 94 | 95 | # Gatsby files 96 | .cache/ 97 | # Comment in the public line in if your project uses Gatsby and not Next.js 98 | # https://nextjs.org/blog/next-9-1#public-directory-support 99 | # public 100 | 101 | # vuepress build output 102 | .vuepress/dist 103 | 104 | # Serverless directories 105 | .serverless/ 106 | 107 | # FuseBox cache 108 | .fusebox/ 109 | 110 | # DynamoDB Local files 111 | .dynamodb/ 112 | 113 | # TernJS port file 114 | .tern-port 115 | 116 | # Stores VSCode versions used for testing VSCode extensions 117 | .vscode-test 118 | 119 | # yarn v2 120 | .yarn/cache 121 | .yarn/unplugged 122 | .yarn/build-state.yml 123 | .yarn/install-state.gz 124 | .pnp.* 125 | 126 | ### Node Patch ### 127 | # Serverless Webpack directories 128 | .webpack/ 129 | 130 | ### VisualStudioCode ### 131 | .vscode/* 132 | !.vscode/settings.json 133 | !.vscode/tasks.json 134 | !.vscode/launch.json 135 | !.vscode/extensions.json 136 | *.code-workspace 137 | 138 | # Local History for Visual Studio Code 139 | .history/ 140 | 141 | ### VisualStudioCode Patch ### 142 | # Ignore all local history of files 143 | .history 144 | .ionide 145 | 146 | ### Windows ### 147 | # Windows thumbnail cache files 148 | Thumbs.db 149 | Thumbs.db:encryptable 150 | ehthumbs.db 151 | ehthumbs_vista.db 152 | 153 | # Dump file 154 | *.stackdump 155 | 156 | # Folder config file 157 | [Dd]esktop.ini 158 | 159 | # Recycle Bin used on file shares 160 | $RECYCLE.BIN/ 161 | 162 | # Windows Installer files 163 | *.cab 164 | *.msi 165 | *.msix 166 | *.msm 167 | *.msp 168 | 169 | # Windows shortcuts 170 | *.lnk 171 | 172 | # End of https://www.toptal.com/developers/gitignore/api/windows,visualstudiocode,node 173 | 174 | # Custom rules (everything added below won't be overriden by 'Generate .gitignore File' if you use 'Update' option) 175 | 176 | lib/ 177 | tmp/ 178 | .vscode/launch.json 179 | test-source/ 180 | test-destination/ 181 | test-portal/ 182 | /.idea/ 183 | .github/copilot-instructions.md -------------------------------------------------------------------------------- /.husky/.gitignore: -------------------------------------------------------------------------------- 1 | _ 2 | -------------------------------------------------------------------------------- /.husky/commit-msg: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | . "$(dirname "$0")/_/husky.sh" 3 | 4 | npx --no-install commitlint --edit $1 5 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | . "$(dirname "$0")/_/husky.sh" 3 | 4 | npx lint-staged 5 | -------------------------------------------------------------------------------- /.npmrc: -------------------------------------------------------------------------------- 1 | @apimatic:registry=https://registry.npmjs.org/ -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "parser": "typescript", 3 | "printWidth": 120, 4 | "singleQuote": false, 5 | "trailingComma": "none", 6 | "tabWidth": 2 7 | } 8 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": ["dbaeumer.vscode-eslint", "esbenp.prettier-vscode"] 3 | } -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "0.2.0", 3 | "configurations": [ 4 | { 5 | "type": "node", 6 | "request": "launch", 7 | "name": "Debug APIMatic CLI", 8 | "skipFiles": ["/"], 9 | "program": "${workspaceFolder}/bin/run", 10 | "args": [ 11 | // "api:validate", 12 | // "--file=C:/Users/User/Desktop/spec/Errors.yaml", 13 | // "--file=https://petstore.swagger.io/v2/swagger.json" 14 | // "api:transform", 15 | // "--format=OpenApi3Json", 16 | // "--file=C:/Users/User/Desktop/test-cli/spec/Calculator.json", 17 | // "--destination=C:/Users/User/Desktop/test-cli/spec" 18 | // "auth:logout", 19 | // "auth:login", 20 | // "auth:status" 21 | // "portal:quickstart", 22 | // "portal:generate", 23 | // "portal:toc:new", 24 | //"--expand-endpoints", 25 | //"--expand-models", 26 | // "--force", 27 | // "--folder", 28 | // "test-source", 29 | // "--destination", 30 | // "test-portal", 31 | // "portal:serve", 32 | // "api:transform", 33 | // "--help" 34 | // "--folder=C:/Users/User/Desktop/demo/portal", 35 | // "--destination=C:\\Users\\User\\Desktop\\demo\\portal\\api-portal" 36 | // "--source=C:/Users/User/Desktop/demo/portal/", 37 | // "--destination=C:/Users/User/Desktop/demo/portal/api-portal2", 38 | // "--source=C:/Users/User/Desktop/demo/portal", 39 | // "--source=\"C:\\Users\\User\\Desktop\\test-cli\"", 40 | // "--destination=\"C:\\Users\\User\\Desktop\\demo\\portal\\api-portal\"", 41 | // "--destination=\"C:\\Users\\User\\Desktop\\demo\\api-portal\"", 42 | // "--source=C:/Users/User/Desktop/test-cli", 43 | // "--destination=C:/Users/User/Desktop/test-cli/api-portal", 44 | // "--source=C:\\Users\\User\\Desktop\\test-cli-2", 45 | // "--destination=C:\\Users\\User\\Desktop\\test-cli-2\\generated_portal" 46 | // "--port=70000" 47 | // "--ignore=C:/Users/User/Desktop/demo/portal/content,C:/Users/User/Desktop/demo/portal/static/images/logo.png", 48 | // "--no-reload", 49 | // "--auth-key=\"invalidApiKey\"", 50 | // "--help" 51 | ], 52 | "console": "externalTerminal", 53 | "preLaunchTask": "tsc: build - tsconfig.json" 54 | }, 55 | { 56 | "type": "node", 57 | "request": "launch", 58 | "name": "Debug CLI Tests", 59 | "program": "${workspaceFolder}/node_modules/tsx/dist/cli.mjs", 60 | "args": [ 61 | "${workspaceFolder}/node_modules/mocha/bin/_mocha", 62 | "--forbid-only", 63 | "test/**/*.test.ts", 64 | // "--ignore", 65 | // "test/commands/portal/generate.test.ts", 66 | // "${file}", // Run Tests for the currently open test file. 67 | "--timeout", 68 | "99999" 69 | ], 70 | "cwd": "${workspaceFolder}", 71 | "runtimeArgs": [ 72 | "--nolazy", 73 | "--inspect-brk", 74 | "--trace-warnings" 75 | ], 76 | "internalConsoleOptions": "openOnSessionStart", 77 | "skipFiles": ["/**"], 78 | "console": "integratedTerminal" 79 | } 80 | ] 81 | } -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "cSpell.words": [ 3 | "ANGULARJAVASCRIPTLIB", 4 | "CSNETSTANDARDLIB", 5 | "CSPORTABLENETLIB", 6 | "CSUNIVERSALWINDOWSPLATFORMLIB", 7 | "GOGENERICLIB", 8 | "HTTPCURLV", 9 | "JAVAECLIPSEJRELIB", 10 | "JAVAGRADLEANDROIDLIB", 11 | "Multipartformdata", 12 | "NODEJAVASCRIPTLIB", 13 | "OBJCCOCOATOUCHIOSLIB", 14 | "oclif", 15 | "paren", 16 | "PHPGENERICLIB", 17 | "postpack", 18 | "posttest", 19 | "PYTHONGENERICLIB", 20 | "RAML", 21 | "RUBYGENERICLIB", 22 | "transformvia", 23 | "unzipper", 24 | "WADL" 25 | ], 26 | "typescript.tsdk": "node_modules\\typescript\\lib" 27 | } 28 | -------------------------------------------------------------------------------- /bin/run.cmd: -------------------------------------------------------------------------------- 1 | @echo off 2 | 3 | node "%~dp0\run" %* 4 | -------------------------------------------------------------------------------- /bin/run.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | import {execute} from '@oclif/core' 4 | 5 | await execute({dir: import.meta.url}) -------------------------------------------------------------------------------- /commitlint.config.js: -------------------------------------------------------------------------------- 1 | // eslint-disable-next-line no-undef 2 | module.exports = { 3 | extends: ["@commitlint/config-conventional"] 4 | }; 5 | -------------------------------------------------------------------------------- /eslint.config.js: -------------------------------------------------------------------------------- 1 | import js from "@eslint/js"; 2 | import tseslint from "@typescript-eslint/eslint-plugin"; 3 | import tsparser from "@typescript-eslint/parser"; 4 | 5 | export default [ 6 | js.configs.recommended, 7 | { 8 | files: ["**/*.ts", "**/*.tsx"], 9 | languageOptions: { 10 | parser: tsparser, 11 | ecmaVersion: 2021, 12 | sourceType: "module", 13 | globals: { 14 | process: "readonly", 15 | setTimeout: "readonly", 16 | clearTimeout: "readonly", 17 | URL: "readonly", 18 | NodeJS: true, 19 | }, 20 | }, 21 | plugins: { 22 | "@typescript-eslint": tseslint, 23 | }, 24 | rules: { 25 | ...tseslint.configs.recommended.rules, 26 | }, 27 | }, 28 | { 29 | ignores: [ 30 | "lib", 31 | "node_modules" 32 | ] 33 | }, 34 | ]; 35 | -------------------------------------------------------------------------------- /release.config.cjs: -------------------------------------------------------------------------------- 1 | // eslint-disable-next-line no-undef 2 | module.exports = { 3 | branches: [ 4 | "v3", 5 | { 6 | name: "alpha", 7 | prerelease: true 8 | }, 9 | { 10 | name: "beta", 11 | prerelease: true 12 | } 13 | ], 14 | plugins: [ 15 | "@semantic-release/commit-analyzer", 16 | "@semantic-release/release-notes-generator", 17 | [ 18 | "@semantic-release/changelog", 19 | { 20 | changelogFile: "CHANGELOG.md" 21 | } 22 | ], 23 | "@semantic-release/npm", 24 | "@semantic-release/github", 25 | [ 26 | "@semantic-release/git", 27 | { 28 | assets: ["CHANGELOG.md", "package.json"], 29 | message: 30 | "chore(release): set `package.json` to ${nextRelease.version} [skip ci]\n\n${nextRelease.notes}" 31 | } 32 | ] 33 | ] 34 | }; 35 | -------------------------------------------------------------------------------- /src/actions/action-result.ts: -------------------------------------------------------------------------------- 1 | enum ResultType { 2 | Success= 0, 3 | Cancel = 130, 4 | Failure= 1 5 | } 6 | 7 | export class ActionResult { 8 | private readonly message: string; 9 | private readonly resultType: ResultType; 10 | 11 | private constructor(resultType: ResultType, message: string) { 12 | this.resultType = resultType; 13 | this.message = message; 14 | } 15 | 16 | static success() { 17 | return new ActionResult(ResultType.Success, " Succeeded "); 18 | } 19 | 20 | static failed() { 21 | return new ActionResult(ResultType.Failure, " Failed "); 22 | } 23 | 24 | static cancelled() { 25 | return new ActionResult(ResultType.Cancel, " Cancelled "); 26 | } 27 | 28 | static stopped() { 29 | return new ActionResult(ResultType.Cancel, " Stopped "); 30 | } 31 | 32 | public getMessage() { 33 | return this.message; 34 | } 35 | 36 | public getExitCode() { 37 | return this.resultType.valueOf(); 38 | } 39 | 40 | public isFailed() { 41 | return this.resultType === ResultType.Failure; 42 | } 43 | 44 | public mapAll(onSuccess: () => T, onFailure: () => T, onCancel: () => T): T { 45 | switch (this.resultType) { 46 | case ResultType.Success: 47 | return onSuccess(); 48 | case ResultType.Failure: 49 | return onFailure(); 50 | case ResultType.Cancel: 51 | return onCancel(); 52 | } 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /src/actions/api/transform.ts: -------------------------------------------------------------------------------- 1 | import { DirectoryPath } from "../../types/file/directoryPath.js"; 2 | import { ActionResult } from "../action-result.js"; 3 | import { ApiTransformPrompts } from "../../prompts/api/transform.js"; 4 | import { withDirPath } from "../../infrastructure/tmp-extensions.js"; 5 | import { TransformationService } from "../../infrastructure/services/transformation-service.js"; 6 | import { ExportFormats } from "@apimatic/sdk"; 7 | import { ApiValidatePrompts } from "../../prompts/api/validate.js"; 8 | import { CommandMetadata } from "../../types/common/command-metadata.js"; 9 | import { TransformContext } from "../../types/transform-context.js"; 10 | import { ResourceInput } from "../../types/file/resource-input.js"; 11 | import { ResourceContext } from "../../types/resource-context.js"; 12 | 13 | export class TransformAction { 14 | private readonly prompts: ApiTransformPrompts = new ApiTransformPrompts(); 15 | private readonly validatePrompts: ApiValidatePrompts = new ApiValidatePrompts(); 16 | private readonly transformationService: TransformationService = new TransformationService(); 17 | private readonly configDir: DirectoryPath; 18 | private readonly commandMetadata: CommandMetadata; 19 | private readonly authKey: string | null; 20 | 21 | constructor(configDir: DirectoryPath, commandMetadata: CommandMetadata, authKey: string | null = null) { 22 | this.configDir = configDir; 23 | this.commandMetadata = commandMetadata; 24 | this.authKey = authKey; 25 | } 26 | 27 | public readonly execute = async ( 28 | resourcePath: ResourceInput, 29 | format: ExportFormats, 30 | destination: DirectoryPath, 31 | force: boolean 32 | ): Promise => { 33 | return await withDirPath(async (tempDirectory) => { 34 | const resourceContext = new ResourceContext(tempDirectory); 35 | const specFileDirResult = await resourceContext.resolveTo(resourcePath); 36 | if (specFileDirResult.isErr()) { 37 | this.prompts.networkError(specFileDirResult.error); 38 | return ActionResult.failed(); 39 | } 40 | const transformContext = new TransformContext(specFileDirResult.value, format, destination); 41 | if (!force && (await transformContext.exists()) && !(await this.prompts.overwriteApi(destination))) { 42 | this.prompts.transformedApiAlreadyExists(); 43 | return ActionResult.cancelled(); 44 | } 45 | 46 | const result = await this.prompts.transformApi( 47 | this.transformationService.transformViaFile({ 48 | file: specFileDirResult.value, 49 | format: format, 50 | configDir: this.configDir, 51 | commandMetadata: this.commandMetadata, 52 | authKey: this.authKey 53 | }) 54 | ); 55 | 56 | if (result.isErr()) { 57 | this.prompts.logTransformationError(result.error); 58 | return ActionResult.failed(); 59 | } 60 | 61 | const transformedFilePath = await transformContext.save(result.value.stream); 62 | this.validatePrompts.displayValidationMessages(result.value.apiValidationSummary); 63 | 64 | this.validatePrompts.transformedApiSaved(transformedFilePath); 65 | 66 | return ActionResult.success(); 67 | }); 68 | }; 69 | } 70 | -------------------------------------------------------------------------------- /src/actions/api/validate.ts: -------------------------------------------------------------------------------- 1 | import { DirectoryPath } from "../../types/file/directoryPath.js"; 2 | import { ActionResult } from "../action-result.js"; 3 | import { ApiValidatePrompts } from "../../prompts/api/validate.js"; 4 | import { ValidationService } from "../../infrastructure/services/validation-service.js"; 5 | import { CommandMetadata } from "../../types/common/command-metadata.js"; 6 | import { ResourceInput } from "../../types/file/resource-input.js"; 7 | import { withDirPath } from "../../infrastructure/tmp-extensions.js"; 8 | import { ResourceContext } from "../../types/resource-context.js"; 9 | 10 | export class ValidateAction { 11 | private readonly prompts: ApiValidatePrompts = new ApiValidatePrompts(); 12 | private readonly validationService: ValidationService; 13 | private readonly authKey: string | null; 14 | private readonly commandMetadata: CommandMetadata; 15 | 16 | constructor(configDir: DirectoryPath, commandMetadata: CommandMetadata, authKey: string | null = null) { 17 | this.authKey = authKey; 18 | this.validationService = new ValidationService(configDir); 19 | this.commandMetadata = commandMetadata; 20 | } 21 | 22 | public readonly execute = async ( 23 | resourcePath: ResourceInput, 24 | displayValidationSummary = true 25 | ): Promise => { 26 | return await withDirPath(async (tempDirectory) => { 27 | const resourceContext = new ResourceContext(tempDirectory); 28 | const specFileDirResult = await resourceContext.resolveTo(resourcePath); 29 | if (specFileDirResult.isErr()) { 30 | this.prompts.networkError(specFileDirResult.error); 31 | return ActionResult.failed(); 32 | } 33 | const validationSummaryResult = await this.prompts.validateApi( 34 | this.validationService.validateViaFile({ 35 | file: specFileDirResult.value, 36 | commandMetadata: this.commandMetadata, 37 | authKey: this.authKey 38 | }) 39 | ); 40 | 41 | if (validationSummaryResult.isErr()) { 42 | this.prompts.logValidationError(validationSummaryResult.error); 43 | return ActionResult.failed(); 44 | } 45 | const validationSummary = validationSummaryResult.value; 46 | if (displayValidationSummary) { 47 | this.prompts.displayValidationMessages(validationSummary); 48 | } 49 | if (!validationSummary.success) { 50 | return ActionResult.failed(); 51 | } 52 | 53 | return ActionResult.success(); 54 | }); 55 | }; 56 | } 57 | -------------------------------------------------------------------------------- /src/actions/auth/login.ts: -------------------------------------------------------------------------------- 1 | import { AuthService } from "../../infrastructure/services/auth-service.js"; 2 | import { ApiService } from "../../infrastructure/services/api-service.js"; 3 | import { DirectoryPath } from "../../types/file/directoryPath.js"; 4 | import { v4 as uuid } from "uuid"; 5 | import open from "open"; 6 | import { setAuthInfo } from "../../client-utils/auth-manager.js"; 7 | import { LoginPrompts } from "../../prompts/auth/login.js"; 8 | import { CommandMetadata } from "../../types/common/command-metadata.js"; 9 | import { ActionResult } from "../action-result.js"; 10 | import { err, ok, Result } from "neverthrow"; 11 | 12 | type LoginTimeout = "TIMEOUT"; 13 | 14 | export class LoginAction { 15 | private readonly authService = new AuthService(); 16 | private readonly apiService = new ApiService(); 17 | private readonly prompts = new LoginPrompts(); 18 | 19 | constructor(private readonly configDir: DirectoryPath, private readonly commandMetadata: CommandMetadata) {} 20 | 21 | public async execute(apiKey: string | undefined = undefined): Promise { 22 | const apiKeyResult = await this.pollDeviceToken(apiKey, this.commandMetadata.shell); 23 | if (apiKeyResult.isErr()) { 24 | this.prompts.loginTimeout(); 25 | return ActionResult.failed(); 26 | } 27 | 28 | // TODO: Use status endpoint here 29 | // Problem, we don't have email info here, just the key and required for setAuthInfo 30 | const accountInfoResult = 31 | await this.prompts.accountInfoSpinner( 32 | this.apiService.getAccountInfo(this.configDir, this.commandMetadata.shell, apiKeyResult.value) 33 | ); 34 | if (accountInfoResult.isErr()) { 35 | this.prompts.invalidKeyProvided(accountInfoResult.error); 36 | return ActionResult.failed(); 37 | } 38 | await setAuthInfo(accountInfoResult.value.Email, apiKeyResult.value, false, this.configDir); 39 | this.prompts.loginSuccessful(accountInfoResult.value.Email); 40 | return ActionResult.success(); 41 | } 42 | 43 | private async pollDeviceToken(apiKey: string | undefined, shell: string): Promise> { 44 | if (apiKey) return ok(apiKey); 45 | const state = uuid(); 46 | this.prompts.openBrowser(); 47 | await open(this.authService.getDeviceLoginUrl(state)); 48 | 49 | const timeoutDuration = 5 * 60 * 1000; // 5 minutes in milliseconds 50 | const startTime = Date.now(); 51 | const delayMs = 3 * 1000; 52 | 53 | while (true) { 54 | if (Date.now() - startTime > timeoutDuration) { 55 | return err("TIMEOUT"); 56 | } 57 | const result = await this.authService.getDeviceLoginToken(state, shell); 58 | if (result.isOk()) { 59 | return ok(result.value.apiKey); 60 | } 61 | await new Promise((resolve) => setTimeout(resolve, delayMs)); 62 | } 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /src/actions/auth/logout.ts: -------------------------------------------------------------------------------- 1 | import { DirectoryPath } from "../../types/file/directoryPath.js"; 2 | import { ActionResult } from "../action-result.js"; 3 | import { removeAuthInfo } from "../../client-utils/auth-manager.js"; 4 | import { LogoutPrompts } from "../../prompts/auth/logout.js"; 5 | 6 | export class LogoutAction { 7 | private readonly prompts = new LogoutPrompts(); 8 | 9 | constructor(private readonly configDir: DirectoryPath) {} 10 | 11 | public async execute(): Promise { 12 | await removeAuthInfo(this.configDir); 13 | this.prompts.removeAuthInfo(); 14 | return ActionResult.success(); 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /src/actions/auth/status.ts: -------------------------------------------------------------------------------- 1 | import { ActionResult } from "../action-result.js"; 2 | import { DirectoryPath } from "../../types/file/directoryPath.js"; 3 | import { CommandMetadata } from "../../types/common/command-metadata.js"; 4 | import { getAuthInfo } from "../../client-utils/auth-manager.js"; 5 | import { StatusPrompts } from "../../prompts/auth/status.js"; 6 | import { ApiService } from "../../infrastructure/services/api-service.js"; 7 | 8 | export class StatusAction { 9 | private readonly prompts = new StatusPrompts(); 10 | private readonly apiService = new ApiService(); 11 | 12 | constructor(private readonly configDir: DirectoryPath, private readonly commandMetadata: CommandMetadata) {} 13 | 14 | public async execute(authKey: string | null): Promise { 15 | const accountInfo = await getAuthInfo(this.configDir.toString()); 16 | if (accountInfo === null) { 17 | return ActionResult.failed(); 18 | } 19 | const result = await this.prompts.accountInfoSpinner( 20 | this.apiService.getAccountInfo(this.configDir, this.commandMetadata.shell, authKey) 21 | ); 22 | if (result.isErr()) { 23 | this.prompts.invalidKeyProvided(result.error); 24 | return ActionResult.failed(); 25 | } 26 | this.prompts.showAccountInfo(result.value); 27 | return ActionResult.success(); 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/actions/portal/copilot.ts: -------------------------------------------------------------------------------- 1 | import { ApiService } from "../../infrastructure/services/api-service.js"; 2 | import { PortalCopilotPrompts } from "../../prompts/portal/copilot.js"; 3 | import { DirectoryPath } from "../../types/file/directoryPath.js"; 4 | import { ActionResult } from "../action-result.js"; 5 | import { SubscriptionInfo } from "../../types/api/account.js"; 6 | import { BuildContext } from "../../types/build-context.js"; 7 | import { withDirPath } from "../../infrastructure/tmp-extensions.js"; 8 | import { FilePath } from "../../types/file/filePath.js"; 9 | import { FileName } from "../../types/file/fileName.js"; 10 | import { FileService } from "../../infrastructure/file-service.js"; 11 | import { LauncherService } from "../../infrastructure/launcher-service.js"; 12 | import { CommandMetadata } from "../../types/common/command-metadata.js"; 13 | import { err, ok, Result } from "neverthrow"; 14 | 15 | type SelectKeyFailure = "failed" | "cancelled"; 16 | type SelectKeyResult = Result; 17 | 18 | export class CopilotAction { 19 | private readonly apiService = new ApiService(); 20 | private readonly fileService = new FileService(); 21 | private readonly launcherService = new LauncherService(); 22 | private readonly prompts = new PortalCopilotPrompts(); 23 | private readonly configDir: DirectoryPath; 24 | private readonly commandMetadata: CommandMetadata; 25 | private readonly authKey: string | null; 26 | 27 | constructor(configDir: DirectoryPath, commandMetadata: CommandMetadata, authKey: string | null = null) { 28 | this.configDir = configDir; 29 | this.commandMetadata = commandMetadata; 30 | this.authKey = authKey; 31 | } 32 | 33 | public readonly execute = async ( 34 | buildDirectory: DirectoryPath, 35 | force: boolean, 36 | enable: boolean 37 | ): Promise => { 38 | const buildContext = new BuildContext(buildDirectory); 39 | 40 | if (!(await buildContext.validate())) { 41 | this.prompts.srcDirectoryEmpty(buildDirectory); 42 | return ActionResult.failed(); 43 | } 44 | 45 | const buildJson = await buildContext.getBuildFileContents(); 46 | 47 | if (!force && buildJson.apiCopilotConfig != null && !(await this.prompts.confirmOverwrite())) { 48 | this.prompts.cancelled(); 49 | return ActionResult.cancelled(); 50 | } 51 | 52 | const response = await this.prompts.spinnerAccountInfo( 53 | this.apiService.getAccountInfo(this.configDir, this.commandMetadata.shell, this.authKey) 54 | ); 55 | 56 | if (response.isErr()) { 57 | this.prompts.serviceError(response.error); 58 | return ActionResult.failed(); 59 | } 60 | 61 | const apiCopilotKeyResult = await this.selectCopilotKey(response.value, force); 62 | if (apiCopilotKeyResult.isErr()) { 63 | if (apiCopilotKeyResult.error === "cancelled") return ActionResult.cancelled(); 64 | return ActionResult.failed(); 65 | } 66 | 67 | const welcomeMessage = await this.prepareWelcomeMessage(); 68 | 69 | buildJson.apiCopilotConfig = { 70 | isEnabled: enable, 71 | key: apiCopilotKeyResult.value, 72 | welcomeMessage: welcomeMessage 73 | }; 74 | 75 | await buildContext.updateBuildFileContents(buildJson); 76 | 77 | this.prompts.copilotConfigured(enable, apiCopilotKeyResult.value); 78 | 79 | return ActionResult.success(); 80 | }; 81 | 82 | private async selectCopilotKey(subscription: SubscriptionInfo, force: boolean): Promise { 83 | if (subscription.ApiCopilotKeys === undefined || subscription.ApiCopilotKeys.length === 0) { 84 | this.prompts.noCopilotKeyFound(); 85 | return err("failed"); 86 | } 87 | 88 | if (subscription.ApiCopilotKeys.length === 1) { 89 | if (force || (await this.prompts.confirmSingleKeyUsage(subscription.ApiCopilotKeys[0]))) 90 | return ok(subscription.ApiCopilotKeys[0]); 91 | this.prompts.noCopilotKeySelected(); 92 | return err("cancelled"); 93 | } 94 | 95 | const key = await this.prompts.selectCopilotKey(subscription.ApiCopilotKeys); 96 | if (key === null) { 97 | this.prompts.noCopilotKeySelected(); 98 | return err("cancelled"); 99 | } 100 | await this.prompts.displayApiCopilotKeyUsageWarning(); 101 | return ok(key); 102 | } 103 | 104 | private async prepareWelcomeMessage(): Promise { 105 | return await withDirPath(async (tempDir) => { 106 | const tempFile = new FilePath(tempDir, new FileName("welcome-message.md")); 107 | const defaultContent = 108 | "Hi there! I'm your API Integration Assistant, here to help you learn and integrate with this API.\n" + 109 | "\n" + 110 | "Ask me anything about this API or try one of these example prompts:\n" + 111 | "\n" + 112 | "- `What authentication methods does this API support?`\n" + 113 | "- `[Enter another prompt here]`"; 114 | await this.fileService.writeContents(tempFile, defaultContent); 115 | this.prompts.openWelcomeMessageEditor(); 116 | await this.launcherService.openInEditor(tempFile); 117 | const welcomeMessage = await this.fileService.getContents(tempFile); 118 | return welcomeMessage.replace(/\r\n|\r/g, "\n"); 119 | }); 120 | } 121 | } 122 | -------------------------------------------------------------------------------- /src/actions/portal/generate.ts: -------------------------------------------------------------------------------- 1 | import { PortalGeneratePrompts } from "../../prompts/portal/generate.js"; 2 | import { PortalService } from "../../infrastructure/services/portal-service.js"; 3 | import { DirectoryPath } from "../../types/file/directoryPath.js"; 4 | import { ActionResult } from "../action-result.js"; 5 | import { BuildContext } from "../../types/build-context.js"; 6 | import { PortalContext } from "../../types/portal-context.js"; 7 | import { withDirPath } from "../../infrastructure/tmp-extensions.js"; 8 | import { LauncherService } from "../../infrastructure/launcher-service.js"; 9 | import { TempContext } from "../../types/temp-context.js"; 10 | import { CommandMetadata } from "../../types/common/command-metadata.js"; 11 | import { ServiceError } from "../../infrastructure/service-error.js"; 12 | 13 | export class GenerateAction { 14 | private readonly prompts: PortalGeneratePrompts = new PortalGeneratePrompts(); 15 | private readonly launcherService: LauncherService = new LauncherService(); 16 | private readonly portalService: PortalService = new PortalService(); 17 | private readonly configDir: DirectoryPath; 18 | private readonly commandMetadata: CommandMetadata; 19 | private readonly authKey: string | null; 20 | 21 | constructor(configDir: DirectoryPath, commandMetadata: CommandMetadata, authKey: string | null = null) { 22 | this.configDir = configDir; 23 | this.commandMetadata = commandMetadata; 24 | this.authKey = authKey; 25 | } 26 | 27 | public readonly execute = async ( 28 | buildDirectory: DirectoryPath, 29 | portalDirectory: DirectoryPath, 30 | force: boolean, 31 | zipPortal: boolean, 32 | displayMessages: boolean = true 33 | ): Promise => { 34 | if (buildDirectory.isEqual(portalDirectory)) { 35 | this.prompts.directoryCannotBeSame(portalDirectory); 36 | return ActionResult.failed(); 37 | } 38 | 39 | const buildContext = new BuildContext(buildDirectory); 40 | if (!(await buildContext.validate())) { 41 | this.prompts.srcDirectoryEmpty(buildDirectory); 42 | return ActionResult.failed(); 43 | } 44 | 45 | const portalContext = new PortalContext(portalDirectory); 46 | if (!force && (await portalContext.exists()) && !(await this.prompts.overwritePortal(portalDirectory))) { 47 | this.prompts.portalDirectoryNotEmpty(); 48 | return ActionResult.cancelled(); 49 | } 50 | 51 | return await withDirPath(async (tempDirectory) => { 52 | const tempContext = new TempContext(tempDirectory); 53 | const buildZipPath = await tempContext.zip(buildDirectory); 54 | 55 | const response = await this.prompts.generatePortal( 56 | this.portalService.generatePortal(buildZipPath, this.configDir, this.commandMetadata, this.authKey) 57 | ); 58 | 59 | if (response.isErr()) { 60 | const error = response.error; 61 | if (error instanceof ServiceError) { 62 | this.prompts.portalGenerationServiceError(error); 63 | } 64 | else if (typeof error === "string") { 65 | this.prompts.portalGenerationError(error); 66 | } else { 67 | const errorZipPath = await tempContext.save(error); 68 | const reportPath = await portalContext.saveError(errorZipPath); 69 | await this.launcherService.openFile(reportPath); 70 | this.prompts.portalGenerationErrorWithReport(reportPath); 71 | } 72 | return ActionResult.failed(); 73 | } 74 | 75 | const tempPortalZipPath = await tempContext.save(response.value); 76 | await portalContext.save(tempPortalZipPath, zipPortal); 77 | 78 | if (displayMessages) { 79 | this.prompts.portalGenerated(portalDirectory); 80 | } 81 | 82 | return ActionResult.success(); 83 | }); 84 | }; 85 | } 86 | -------------------------------------------------------------------------------- /src/actions/portal/toc/new-toc.ts: -------------------------------------------------------------------------------- 1 | import { ok } from "neverthrow"; 2 | import { PortalNewTocPrompts } from "../../../prompts/portal/toc/new-toc.js"; 3 | import { TocStructureGenerator } from "../../../application/portal/toc/toc-structure-generator.js"; 4 | import { TocEndpoint, TocGroup, TocModel } from "../../../types/toc/toc.js"; 5 | import { DirectoryPath } from "../../../types/file/directoryPath.js"; 6 | import { CommandMetadata } from "../../../types/common/command-metadata.js"; 7 | import { ActionResult } from "../../action-result.js"; 8 | import { TocContext } from "../../../types/toc-context.js"; 9 | import { FileService } from "../../../infrastructure/file-service.js"; 10 | import { BuildContext } from "../../../types/build-context.js"; 11 | import { getEndpointGroupsAndModels } from "../../../types/sdl/sdl.js"; 12 | import { withDirPath } from "../../../infrastructure/tmp-extensions.js"; 13 | import { TempContext } from "../../../types/temp-context.js"; 14 | import { PortalService } from "../../../infrastructure/services/portal-service.js"; 15 | 16 | export class ContentContext { 17 | private readonly fileService = new FileService(); 18 | 19 | constructor(private readonly contentDirectory: DirectoryPath) {} 20 | 21 | public async exists(): Promise { 22 | return this.fileService.directoryExists(this.contentDirectory); 23 | } 24 | 25 | public async extractContentGroups(): Promise { 26 | const directory = await this.fileService.getDirectory(this.contentDirectory); 27 | return await directory.parseContentFolder(this.contentDirectory); 28 | } 29 | } 30 | 31 | type SdlComponents = { 32 | endpointGroups: Map; 33 | models: TocModel[]; 34 | }; 35 | 36 | export class PortalNewTocAction { 37 | private readonly prompts: PortalNewTocPrompts = new PortalNewTocPrompts(); 38 | private readonly tocGenerator: TocStructureGenerator = new TocStructureGenerator(); 39 | private readonly fileService = new FileService(); 40 | private readonly portalService = new PortalService(); 41 | 42 | constructor(private readonly configDirectory: DirectoryPath, private readonly commandMetadata: CommandMetadata) {} 43 | 44 | public async execute( 45 | buildDirectory: DirectoryPath, 46 | tocDirectory?: DirectoryPath, 47 | force: boolean = false, 48 | expandEndpoints: boolean = false, 49 | expandModels: boolean = false 50 | ): Promise { 51 | // Validate build directory 52 | const buildContext = new BuildContext(buildDirectory); 53 | if (!(await buildContext.validate())) { 54 | this.prompts.invalidBuildDirectory(buildDirectory); 55 | return ActionResult.failed(); 56 | } 57 | const buildConfig = await buildContext.getBuildFileContents(); 58 | const contentDirectory = buildDirectory.join(buildConfig.generatePortal?.contentFolder ?? "content"); 59 | 60 | const tocDir = tocDirectory ?? contentDirectory; 61 | const tocContext = new TocContext(tocDir); 62 | 63 | if (!force && (await tocContext.exists()) && !(await this.prompts.overwriteToc(tocContext.tocPath))) { 64 | this.prompts.tocFileAlreadyExists(); 65 | return ActionResult.cancelled(); 66 | } 67 | 68 | let sdlComponents: SdlComponents = { endpointGroups: new Map(), models: [] }; 69 | if (expandEndpoints || expandModels) { 70 | const specDirectory = buildDirectory.join("spec"); 71 | 72 | if (!(await this.fileService.directoryExists(specDirectory))) { 73 | this.prompts.fallingBackToDefault(); 74 | } else { 75 | const sdlResult = await withDirPath(async (tempDirectory) => { 76 | const tempContext = new TempContext(tempDirectory); 77 | const specZipPath = await tempContext.zip(specDirectory); 78 | const specFileStream = await this.fileService.getStream(specZipPath); 79 | try { 80 | const result = await this.prompts.extractEndpointGroupsAndModels( 81 | this.portalService.generateSdl(specFileStream, this.configDirectory, this.commandMetadata) 82 | ); 83 | if (result.isErr()) { 84 | this.prompts.fallingBackToDefault(); 85 | return ok({ endpointGroups: new Map(), models: [] } as SdlComponents); 86 | } 87 | return ok(getEndpointGroupsAndModels(result.value)); 88 | } finally { 89 | specFileStream.close(); 90 | } 91 | }); 92 | 93 | if (sdlResult.isErr()) { 94 | this.prompts.logError(sdlResult.error); 95 | return ActionResult.failed(); 96 | } 97 | sdlComponents = sdlResult.value; 98 | } 99 | } 100 | const contentContext = new ContentContext(contentDirectory); 101 | const contentExists = await contentContext.exists(); 102 | 103 | let contentGroups: TocGroup[]; 104 | if (!contentExists) { 105 | this.prompts.contentDirectoryNotFound(contentDirectory); 106 | contentGroups = []; 107 | } else { 108 | contentGroups = await contentContext.extractContentGroups(); 109 | } 110 | 111 | 112 | const toc = this.tocGenerator.createTocStructure( 113 | sdlComponents.endpointGroups, 114 | sdlComponents.models, 115 | expandEndpoints, 116 | expandModels, 117 | contentGroups 118 | ); 119 | 120 | const yamlString = this.tocGenerator.transformToYaml(toc); 121 | const tocFilePath = await tocContext.save(yamlString); 122 | 123 | 124 | this.prompts.tocCreated(tocFilePath) 125 | 126 | return ActionResult.success(); 127 | } 128 | } 129 | -------------------------------------------------------------------------------- /src/actions/quickstart.ts: -------------------------------------------------------------------------------- 1 | import { QuickstartPrompts } from "../prompts/quickstart.js"; 2 | import { CommandMetadata } from "../types/common/command-metadata.js"; 3 | import { DirectoryPath } from "../types/file/directoryPath.js"; 4 | import { ActionResult } from "./action-result.js"; 5 | import { PortalQuickstartAction } from "./portal/quickstart.js"; 6 | import { SdkQuickstartAction } from "./sdk/quickstart.js"; 7 | 8 | export class QuickstartAction { 9 | private readonly prompts = new QuickstartPrompts(); 10 | 11 | public constructor(private readonly configDir: DirectoryPath, private readonly commandMetadata: CommandMetadata) {} 12 | 13 | public readonly execute = async (): Promise => { 14 | this.prompts.welcomeMessage(); 15 | const selectedFlow = await this.prompts.selectQuickstartFlow(); 16 | switch (selectedFlow) { 17 | case "portal": { 18 | const action = new PortalQuickstartAction(this.configDir, this.commandMetadata); 19 | return await action.execute(); 20 | } 21 | case "sdk": { 22 | const action = new SdkQuickstartAction(this.configDir, this.commandMetadata); 23 | return await action.execute(); 24 | } 25 | case undefined: { 26 | this.prompts.noQuickstartFlowSelected(); 27 | return ActionResult.cancelled(); 28 | } 29 | } 30 | }; 31 | } 32 | -------------------------------------------------------------------------------- /src/actions/sdk/generate.ts: -------------------------------------------------------------------------------- 1 | import { PortalService } from "../../infrastructure/services/portal-service.js"; 2 | import { DirectoryPath } from "../../types/file/directoryPath.js"; 3 | import { ActionResult } from "../action-result.js"; 4 | import { withDirPath } from "../../infrastructure/tmp-extensions.js"; 5 | import { SdkContext } from "../../types/sdk-context.js"; 6 | import { SpecContext } from "../../types/spec-context.js"; 7 | import { SdkGeneratePrompts } from "../../prompts/sdk/generate.js"; 8 | import { CommandMetadata } from "../../types/common/command-metadata.js"; 9 | import { TempContext } from "../../types/temp-context.js"; 10 | import { Language } from "../../types/sdk/generate.js"; 11 | 12 | export class GenerateAction { 13 | private readonly prompts: SdkGeneratePrompts = new SdkGeneratePrompts(); 14 | private readonly portalService: PortalService = new PortalService(); 15 | private readonly configDir: DirectoryPath; 16 | private readonly commandMetadata: CommandMetadata; 17 | private readonly authKey: string | null; 18 | 19 | constructor(configDir: DirectoryPath, commandMetadata: CommandMetadata, authKey: string | null = null) { 20 | this.configDir = configDir; 21 | this.commandMetadata = commandMetadata; 22 | this.authKey = authKey; 23 | } 24 | 25 | public readonly execute = async ( 26 | specDirectory: DirectoryPath, 27 | sdkDirectory: DirectoryPath, 28 | language: Language, 29 | force: boolean, 30 | zipSdk: boolean 31 | ): Promise => { 32 | if (specDirectory.isEqual(sdkDirectory)) { 33 | this.prompts.sameSpecAndSdkDir(specDirectory); 34 | return ActionResult.failed(); 35 | } 36 | 37 | const specContext = new SpecContext(specDirectory); 38 | if (!(await specContext.validate())) { 39 | this.prompts.invalidSpecDirectory(specDirectory); 40 | return ActionResult.failed(); 41 | } 42 | 43 | const sdkContext = new SdkContext(sdkDirectory, language); 44 | if (!force && (await sdkContext.exists()) && !(await this.prompts.overwriteSdk(sdkContext.sdkLanguageDirectory))) { 45 | this.prompts.destinationDirNotEmpty(); 46 | return ActionResult.cancelled(); 47 | } 48 | 49 | return await withDirPath(async (tempDirectory) => { 50 | const tempContext = new TempContext(tempDirectory); 51 | const specZipPath = await tempContext.zip(specDirectory); 52 | 53 | const response = await this.prompts.generateSDK( 54 | this.portalService.generateSdk(specZipPath, language, this.configDir, this.commandMetadata, this.authKey) 55 | ); 56 | 57 | // TODO: this should be service error 58 | if (response.isErr()) { 59 | this.prompts.logGenerationError(response.error); 60 | return ActionResult.failed(); 61 | } 62 | 63 | const tempSdkFilePath = await tempContext.save(response.value); 64 | const sdkLanguageDirectory = await sdkContext.save(tempSdkFilePath, zipSdk); 65 | 66 | this.prompts.sdkGenerated(sdkLanguageDirectory); 67 | 68 | return ActionResult.success(); 69 | }); 70 | }; 71 | } 72 | -------------------------------------------------------------------------------- /src/application/portal/recipe/portal-recipe.ts: -------------------------------------------------------------------------------- 1 | import { SerializableRecipe, StepType } from "../../../types/recipe/recipe.js"; 2 | 3 | export class PortalRecipe { 4 | private readonly recipe: SerializableRecipe; 5 | 6 | constructor(name: string) { 7 | this.recipe = { 8 | name, 9 | steps: [] 10 | }; 11 | } 12 | 13 | addContentStep(key: string, content: string) { 14 | this.recipe.steps.push({ 15 | key, 16 | name: key, //TODO: Check if key is required 17 | type: StepType.Content, 18 | config: { content } 19 | }); 20 | } 21 | 22 | addEndpointStep(key: string, description: string, endpointGroupName: string, endpointName: string) { 23 | const endpointPermalink = `$e/${[endpointGroupName, endpointName].map(encodeURIComponent).join("/")}`; 24 | this.recipe.steps.push({ 25 | key, 26 | name: key, //TODO: Check if key is required 27 | type: StepType.Endpoint, 28 | config: { 29 | description, 30 | endpointPermalink 31 | } 32 | }); 33 | } 34 | 35 | toSerializableRecipe(): SerializableRecipe { 36 | return this.recipe; 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/application/portal/toc/toc-content-parser.ts: -------------------------------------------------------------------------------- 1 | import * as path from "path"; 2 | import * as fs from "fs/promises"; 3 | import { TocGroup, TocCustomPage } from "../../../types/toc/toc.js"; 4 | 5 | export class TocContentParser { 6 | async parseContentFolder(contentFolderPath: string, workingDirectory: string): Promise { 7 | const items = await fs.readdir(contentFolderPath); 8 | const contentItems: (TocGroup | TocCustomPage)[] = []; 9 | 10 | for (const item of items) { 11 | const itemPath = path.join(contentFolderPath, item); 12 | const stats = await fs.stat(itemPath); 13 | 14 | if (stats.isDirectory()) { 15 | const subItems = await this.parseContentFolder(itemPath, workingDirectory); 16 | if (subItems.length > 0) { 17 | contentItems.push({ 18 | group: item, 19 | items: subItems[0].items // Take items from the Custom Content group 20 | }); 21 | } 22 | } else if (stats.isFile() && item.endsWith(".md")) { 23 | const relativePath = path.relative(workingDirectory, itemPath); 24 | const pageName = path.basename(item, ".md"); 25 | 26 | contentItems.push({ 27 | page: pageName, 28 | file: this.normalizePath(relativePath) 29 | }); 30 | } 31 | } 32 | 33 | // Return empty array if no markdown files were found 34 | if (contentItems.length === 0) { 35 | return []; 36 | } 37 | 38 | // Wrap everything under a "Custom Content" group 39 | return [ 40 | { 41 | group: "Custom Content", 42 | items: contentItems 43 | } 44 | ]; 45 | } 46 | 47 | private normalizePath(path: string): string { 48 | return path.replace(/\\/g, "/"); 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /src/application/portal/toc/toc-structure-generator.ts: -------------------------------------------------------------------------------- 1 | import { stringify } from "yaml"; 2 | import { Toc, TocGroup, TocEndpoint, TocModel, TocEndpointGroupOverview } from "../../../types/toc/toc.js"; 3 | 4 | export class TocStructureGenerator { 5 | createTocStructure( 6 | endpointGroups: Map, 7 | models: TocModel[], 8 | expandEndpoints: boolean = false, 9 | expandModels: boolean = false, 10 | contentGroups: TocGroup[] = [] 11 | ): Toc { 12 | const tocStructure: Toc = { 13 | toc: [] 14 | }; 15 | 16 | // Add Getting Started section 17 | tocStructure.toc.push({ 18 | group: "Getting Started", 19 | items: [ 20 | { 21 | generate: "How to Get Started", 22 | from: "getting-started" 23 | } 24 | ] 25 | }); 26 | 27 | // Add content groups 28 | if (contentGroups.length > 0) { 29 | tocStructure.toc.push(...contentGroups); 30 | } 31 | 32 | // Add API Endpoints section 33 | if (!expandEndpoints || endpointGroups.size === 0) { 34 | tocStructure.toc.push({ 35 | generate: "API Endpoints", 36 | from: "endpoints" 37 | }); 38 | } else { 39 | tocStructure.toc.push({ 40 | group: "API Endpoints", 41 | items: Array.from(endpointGroups).map(([groupName, endpoints]) => ({ 42 | group: groupName, 43 | items: [ 44 | { 45 | generate: null, 46 | from: "endpoint-group-overview", 47 | endpointGroup: groupName 48 | } as TocEndpointGroupOverview, 49 | ...endpoints 50 | ] 51 | })) 52 | }); 53 | } 54 | 55 | // Add Models section 56 | if (!expandModels || models.length === 0) { 57 | tocStructure.toc.push({ 58 | generate: "Models", 59 | from: "models" 60 | }); 61 | } else { 62 | tocStructure.toc.push({ 63 | group: "Models", 64 | items: models 65 | }); 66 | } 67 | 68 | //Add Sdk Infra section 69 | tocStructure.toc.push({ 70 | generate: "SDK Infrastructure", 71 | from: "sdk-infra" 72 | }); 73 | 74 | return tocStructure; 75 | } 76 | 77 | transformToYaml(toc: Toc): string { 78 | const transformedToc = this.transformKeys(toc); 79 | return stringify(transformedToc, { 80 | indent: 2, 81 | nullStr: "" 82 | }); 83 | } 84 | 85 | private transformKeys(obj: any): any { 86 | if (Array.isArray(obj)) { 87 | return obj.map((item) => this.transformKeys(item)); 88 | } 89 | if (obj !== null && typeof obj === "object") { 90 | return Object.fromEntries( 91 | Object.entries(obj).map(([key, value]) => [ 92 | key.replace(/([a-z0-9])([A-Z])/g, "$1-$2").toLowerCase(), 93 | this.transformKeys(value) 94 | ]) 95 | ); 96 | } 97 | return obj; 98 | } 99 | } 100 | -------------------------------------------------------------------------------- /src/client-utils/auth-manager.ts: -------------------------------------------------------------------------------- 1 | import * as path from "path"; 2 | import fs from "fs-extra"; 3 | import { DirectoryPath } from "../types/file/directoryPath.js"; 4 | 5 | export type AuthInfo = { 6 | email: string; 7 | authKey: string; 8 | APIMATIC_CLI_TELEMETRY_OPTOUT?: string; 9 | }; 10 | /** 11 | * 12 | * @param {string} configDir <- Directory with user configuration 13 | * //Function to get credentials 14 | */ 15 | export async function getAuthInfo(configDir: string): Promise { 16 | try { 17 | return JSON.parse(await fs.readFile(path.join(configDir, "config.json"), "utf8")); 18 | } catch { 19 | return null; 20 | } 21 | } 22 | 23 | /** 24 | * 25 | * @param {string} email 26 | * @param {string} authKey 27 | * @param {string} isTelemetryOptedOut 28 | * @param {string} configDir <- Directory with user configuration 29 | * //Function to set credentials. 30 | */ 31 | export async function setAuthInfo( 32 | email: string, 33 | authKey: string, 34 | isTelemetryOptedOut: boolean, 35 | configDir: DirectoryPath 36 | ): Promise { 37 | const credentials: AuthInfo = { 38 | email, 39 | authKey, 40 | APIMATIC_CLI_TELEMETRY_OPTOUT: isTelemetryOptedOut ? "1" : "0" 41 | }; 42 | const configFilePath = path.join(configDir.toString(), "config.json"); 43 | 44 | if (!fs.existsSync(configFilePath)) fs.createFileSync(configFilePath); 45 | 46 | return await fs.writeFile(configFilePath, JSON.stringify(credentials)); 47 | } 48 | 49 | export async function removeAuthInfo(configDir: DirectoryPath): Promise { 50 | const credentials: AuthInfo = { 51 | email: "", 52 | authKey: "" 53 | }; 54 | const configFilePath = path.join(configDir.toString(), "config.json"); 55 | 56 | if (!fs.existsSync(configFilePath)) fs.createFileSync(configFilePath); 57 | 58 | return await fs.writeFile(configFilePath, JSON.stringify(credentials)); 59 | } 60 | -------------------------------------------------------------------------------- /src/commands/api/transform.ts: -------------------------------------------------------------------------------- 1 | import { Command, Flags } from "@oclif/core"; 2 | import { DirectoryPath } from "../../types/file/directoryPath.js"; 3 | import { FlagsProvider } from "../../types/flags-provider.js"; 4 | import { TransformAction } from "../../actions/api/transform.js"; 5 | import { CommandMetadata } from "../../types/common/command-metadata.js"; 6 | import { format, intro, outro } from "../../prompts/format.js"; 7 | import { createResourceInput } from "../../types/file/resource-input.js"; 8 | import { TransformationFormats } from "../../types/api/transform.js"; 9 | import { ExportFormats } from "@apimatic/sdk"; 10 | 11 | export default class Transform extends Command { 12 | static readonly summary = "Transform API specifications between different formats"; 13 | 14 | static readonly description = `Transform API specifications from one format to another. 15 | Supports multiple formats including OpenAPI/Swagger, RAML, WSDL, and Postman Collections.`; 16 | 17 | static readonly cmdTxt = format.cmd("apimatic", "api", "transform"); 18 | 19 | static examples = [ 20 | `${Transform.cmdTxt} ${format.flag("format", "openapi3yaml")} ${format.flag( 21 | "file", 22 | "./specs/sample.json" 23 | )} ${format.flag("destination", "./")}`, 24 | `${Transform.cmdTxt} ${format.flag("format", "raml")} ${format.flag( 25 | "url", 26 | '"https://petstore.swagger.io/v2/swagger.json"' 27 | )} ${format.flag("destination", "./")}` 28 | ]; 29 | 30 | static flags = { 31 | format: Flags.string({ 32 | required: true, 33 | description: "Specification format to transform API specification into", 34 | options: Object.keys(TransformationFormats) 35 | }), 36 | file: Flags.string({ 37 | description: "Path to the API specification file to transform" 38 | }), 39 | url: Flags.string({ 40 | description: "URL to the API specification file to transform (publicly accessible)" 41 | }), 42 | destination: Flags.string({ 43 | char: "d", 44 | description: "Directory to save the transformed file to", 45 | default: "./" 46 | }), 47 | ...FlagsProvider.force, 48 | ...FlagsProvider.authKey 49 | }; 50 | 51 | async run() { 52 | const { 53 | flags: { format, file, url, destination, force, "auth-key": authKey } 54 | } = await this.parse(Transform); 55 | 56 | const workingDirectory = DirectoryPath.createInput(destination); 57 | const transformedApiDirectory = workingDirectory.join("transformations"); 58 | const specFile = createResourceInput(file, url); 59 | // Directly map the format flag to ExportFormats using TransformationFormats 60 | const key = format as keyof typeof TransformationFormats; 61 | const transformationFormat = TransformationFormats[key] as keyof typeof ExportFormats; 62 | const parsedFormat = ExportFormats[transformationFormat]; 63 | 64 | const commandMetadata: CommandMetadata = { 65 | commandName: Transform.id, 66 | shell: this.config.shell 67 | }; 68 | 69 | intro("Transform API"); 70 | const action = new TransformAction(this.getConfigDir(), commandMetadata, authKey); 71 | const result = await action.execute(specFile, parsedFormat, transformedApiDirectory, force); 72 | outro(result); 73 | } 74 | 75 | private readonly getConfigDir = () => { 76 | return new DirectoryPath(this.config.configDir); 77 | }; 78 | } 79 | -------------------------------------------------------------------------------- /src/commands/api/validate.ts: -------------------------------------------------------------------------------- 1 | import { Command, Flags } from "@oclif/core"; 2 | import { DirectoryPath } from "../../types/file/directoryPath.js"; 3 | import { FlagsProvider } from "../../types/flags-provider.js"; 4 | import { ValidateAction } from "../../actions/api/validate.js"; 5 | import { CommandMetadata } from "../../types/common/command-metadata.js"; 6 | import { format, intro, outro } from "../../prompts/format.js"; 7 | import { createResourceInput } from "../../types/file/resource-input.js"; 8 | 9 | export default class Validate extends Command { 10 | static readonly summary = "Validate API specification for syntactic and semantic correctness"; 11 | 12 | static readonly description = `Validate your API specification to ensure it adheres to syntactic and semantic standards.`; 13 | 14 | static readonly cmdTxt = format.cmd("apimatic", "api", "validate"); 15 | 16 | static examples = [ 17 | `${Validate.cmdTxt} ${format.flag("file", "./specs/sample.json")}`, 18 | `${Validate.cmdTxt} ${format.flag("url", '"https://petstore.swagger.io/v2/swagger.json"')}` 19 | ]; 20 | 21 | static flags = { 22 | file: Flags.string({ description: "Path to the API specification file to validate" }), 23 | url: Flags.string({ description: "URL to the API specification file to validate (publicly accessible)" }), 24 | ...FlagsProvider.authKey 25 | }; 26 | 27 | async run() { 28 | const { 29 | flags: { file, url, "auth-key": authKey } 30 | } = await this.parse(Validate); 31 | 32 | const commandMetadata: CommandMetadata = { 33 | commandName: Validate.id, 34 | shell: this.config.shell 35 | }; 36 | 37 | const action = new ValidateAction(this.getConfigDir(), commandMetadata, authKey); 38 | const resourceInput = createResourceInput(file, url); 39 | 40 | intro("Validate API"); 41 | const result = await action.execute(resourceInput); 42 | outro(result); 43 | } 44 | 45 | private readonly getConfigDir = () => { 46 | return new DirectoryPath(this.config.configDir); 47 | }; 48 | } 49 | -------------------------------------------------------------------------------- /src/commands/auth/login.ts: -------------------------------------------------------------------------------- 1 | import { Command, Flags } from "@oclif/core"; 2 | import { DirectoryPath } from "../../types/file/directoryPath.js"; 3 | import { LoginAction } from "../../actions/auth/login.js"; 4 | import { CommandMetadata } from "../../types/common/command-metadata.js"; 5 | import { format, intro, outro } from "../../prompts/format.js"; 6 | 7 | export default class Login extends Command { 8 | static summary = "Login to your APIMatic account"; 9 | 10 | static description = "Login using your APIMatic credentials or an API Key"; 11 | 12 | private static cmdTxt = format.cmd('apimatic', 'auth' ,'login'); 13 | static examples = [ 14 | Login.cmdTxt, 15 | `${Login.cmdTxt} ${format.flag('auth-key', '{api-key}')}`]; 16 | 17 | static flags = { 18 | "auth-key": Flags.string({ 19 | char: "k", 20 | description: "Sets authentication key for all commands." 21 | }) 22 | }; 23 | 24 | 25 | async run() { 26 | const { 27 | flags: { "auth-key": authKey } 28 | } = await this.parse(Login); 29 | 30 | if (authKey === "") { 31 | this.error("Flag --auth-key must not be empty when provided."); 32 | } 33 | 34 | const commandMetadata: CommandMetadata = { 35 | commandName: Login.id, 36 | shell: this.config.shell 37 | }; 38 | 39 | intro("Login"); 40 | const loginAction = new LoginAction(new DirectoryPath(this.config.configDir), commandMetadata); 41 | const result = await loginAction.execute(authKey); 42 | outro(result); 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/commands/auth/logout.ts: -------------------------------------------------------------------------------- 1 | import { Command } from "@oclif/core"; 2 | import { format, intro, outro } from "../../prompts/format.js"; 3 | import { LogoutAction } from "../../actions/auth/logout.js"; 4 | import { DirectoryPath } from "../../types/file/directoryPath.js"; 5 | 6 | export default class Logout extends Command { 7 | static summary = "Clears the local login credentials."; 8 | 9 | static description = "Clears the local login credentials. This will also clear any cached credentials from the CLI."; 10 | 11 | private static cmdTxt = format.cmd('apimatic', 'auth' ,'logout'); 12 | static examples = [Logout.cmdTxt]; 13 | 14 | async run() { 15 | 16 | intro("Logout"); 17 | const actionResult = await new LogoutAction(new DirectoryPath(this.config.configDir)).execute(); 18 | outro(actionResult) 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/commands/auth/status.ts: -------------------------------------------------------------------------------- 1 | import { Command } from "@oclif/core"; 2 | 3 | import { format, intro, outro } from "../../prompts/format.js"; 4 | import { StatusAction } from "../../actions/auth/status.js"; 5 | import { DirectoryPath } from "../../types/file/directoryPath.js"; 6 | import { CommandMetadata } from "../../types/common/command-metadata.js"; 7 | 8 | 9 | export default class Status extends Command { 10 | static description = "View the currently logged in user."; 11 | 12 | private static cmdTxt = format.cmd('apimatic', 'auth' ,'status'); 13 | static examples = [Status.cmdTxt]; 14 | 15 | async run() { 16 | 17 | const commandMetadata: CommandMetadata = { 18 | commandName: Status.id, 19 | shell: this.config.shell 20 | }; 21 | 22 | intro('Status') 23 | const statusAction = new StatusAction(new DirectoryPath(this.config.configDir), commandMetadata); 24 | const actionResult = await statusAction.execute(null); 25 | outro(actionResult) 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/commands/portal/copilot.ts: -------------------------------------------------------------------------------- 1 | import { Command, Flags } from "@oclif/core"; 2 | import { DirectoryPath } from "../../types/file/directoryPath.js"; 3 | import { FlagsProvider } from "../../types/flags-provider.js"; 4 | import { CopilotAction } from "../../actions/portal/copilot.js"; 5 | import { CommandMetadata } from "../../types/common/command-metadata.js"; 6 | import { format, intro, outro } from "../../prompts/format.js"; 7 | 8 | export default class PortalCopilot extends Command { 9 | static summary = "Configure API Copilot for your API Documentation portal"; 10 | 11 | static description = 12 | `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`; 13 | 14 | static flags = { 15 | ...FlagsProvider.input, 16 | disable: Flags.boolean({ 17 | default: false, 18 | description: "marks the API Copilot as disabled in the configuration" 19 | }), 20 | ...FlagsProvider.force, 21 | ...FlagsProvider.authKey 22 | }; 23 | 24 | static cmdTxt = format.cmd("apimatic", "portal", "copilot"); 25 | static examples = [ 26 | `${this.cmdTxt} ${format.flag("input", './')}`, 27 | `${this.cmdTxt} ${format.flag("input", './')} ${format.flag("disable")}` 28 | ]; 29 | 30 | async run(): Promise { 31 | const { 32 | flags: { input, "auth-key": authKey, disable, force } 33 | } = await this.parse(PortalCopilot); 34 | 35 | const commandMetadata: CommandMetadata = { 36 | commandName: PortalCopilot.id, 37 | shell: this.config.shell 38 | }; 39 | 40 | intro("Configure API Copilot"); 41 | const buildDirectory = DirectoryPath.createInput(input).join("src"); 42 | const copilotConfigAction = new CopilotAction(new DirectoryPath(this.config.configDir), commandMetadata, authKey); 43 | const result = await copilotConfigAction.execute(buildDirectory, force, !disable); 44 | outro(result); 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /src/commands/portal/generate.ts: -------------------------------------------------------------------------------- 1 | import { Command, Flags } from "@oclif/core"; 2 | import { DirectoryPath } from "../../types/file/directoryPath.js"; 3 | import { GenerateAction } from "../../actions/portal/generate.js"; 4 | import { FlagsProvider } from "../../types/flags-provider.js"; 5 | import { format, intro, outro } from "../../prompts/format.js"; 6 | import { CommandMetadata } from "../../types/common/command-metadata.js"; 7 | 8 | export class PortalGenerate extends Command { 9 | static summary = "Generate an API Documentation portal"; 10 | 11 | static description = 12 | "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)"; 13 | 14 | static flags = { 15 | ...FlagsProvider.input, 16 | ...FlagsProvider.destination("portal", "portal"), 17 | ...FlagsProvider.force, 18 | zip: Flags.boolean({ 19 | default: false, 20 | description: "Download the generated portal as a .zip archive" 21 | }), 22 | ...FlagsProvider.authKey 23 | }; 24 | 25 | static cmdTxt = format.cmd("apimatic", "portal", "generate"); 26 | static examples = [ 27 | this.cmdTxt, 28 | `${this.cmdTxt} ${format.flag("input", '"./"')} ${format.flag("destination", '"./portal"')}` 29 | ]; 30 | 31 | async run(): Promise { 32 | const { 33 | flags: { input, destination, force, zip: zipPortal, "auth-key": authKey } 34 | } = await this.parse(PortalGenerate); 35 | 36 | const workingDirectory = DirectoryPath.createInput(input); 37 | const buildDirectory = input ? new DirectoryPath(input, "src") : workingDirectory.join("src"); 38 | const portalDirectory = destination ? new DirectoryPath(destination) : workingDirectory.join("portal"); 39 | const commandMetadata: CommandMetadata = { 40 | commandName: PortalGenerate.id, 41 | shell: this.config.shell 42 | }; 43 | 44 | intro("Generate Portal"); 45 | const action = new GenerateAction(new DirectoryPath(this.config.configDir), commandMetadata, authKey); 46 | const result = await action.execute(buildDirectory, portalDirectory, force, zipPortal); 47 | outro(result); 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /src/commands/portal/recipe/new.ts: -------------------------------------------------------------------------------- 1 | import { Command, Flags } from "@oclif/core"; 2 | import { PortalRecipeAction } from "../../../actions/portal/recipe/new-recipe.js"; 3 | import { TelemetryService } from "../../../infrastructure/services/telemetry-service.js"; 4 | import { RecipeCreationFailedEvent } from "../../../types/events/recipe-creation-failed.js"; 5 | import { DirectoryPath } from "../../../types/file/directoryPath.js"; 6 | import { FlagsProvider } from "../../../types/flags-provider.js"; 7 | import { CommandMetadata } from "../../../types/common/command-metadata.js"; 8 | import { format, intro, outro } from "../../../prompts/format.js"; 9 | 10 | export default class PortalRecipeNew extends Command { 11 | static summary = "Add an API Recipe to your API documentation portal."; 12 | 13 | static description = `This command adds a new API Recipe file to your documentation portal. 14 | 15 | To learn more about API Recipes, visit: 16 | ${format.link( 17 | "https://docs.apimatic.io/platform-api/#/http/guides/generating-on-prem-api-portal/api-recipes" 18 | )}`; 19 | 20 | static flags = { 21 | name: Flags.string({ description: "name for the recipe" }), 22 | ...FlagsProvider.input, 23 | ...FlagsProvider.force 24 | }; 25 | 26 | static readonly cmdTxt = format.cmd("apimatic", "portal", "recipe", "new"); 27 | static readonly examples = [ 28 | `${this.cmdTxt}`, 29 | `${this.cmdTxt} ${format.flag("name", '"My API Recipe"')} ${format.flag("input", '"./"')}` 30 | ]; 31 | 32 | async run(): Promise { 33 | const { 34 | flags: { name, input, force } 35 | } = await this.parse(PortalRecipeNew); 36 | 37 | const workingDirectory = DirectoryPath.createInput(input); 38 | const buildDirectory = input ? new DirectoryPath(input, "src") : workingDirectory.join("src"); 39 | 40 | const commandMetadata: CommandMetadata = { 41 | commandName: PortalRecipeNew.id, 42 | shell: this.config.shell 43 | }; 44 | 45 | intro("New Recipe"); 46 | const action = new PortalRecipeAction(new DirectoryPath(this.config.configDir), commandMetadata); 47 | const result = await action.execute(buildDirectory, name); 48 | outro(result); 49 | 50 | result.mapAll( 51 | () => {}, 52 | async () => { 53 | const telemetryService = new TelemetryService(new DirectoryPath(this.config.configDir)); 54 | await telemetryService.trackEvent( 55 | new RecipeCreationFailedEvent("error", PortalRecipeNew.id, { 56 | name, 57 | input, 58 | force 59 | }), 60 | commandMetadata.shell 61 | ); 62 | }, 63 | () => {} 64 | ); 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /src/commands/portal/serve.ts: -------------------------------------------------------------------------------- 1 | import { Command, Flags } from "@oclif/core"; 2 | import { PortalServeAction } from "../../actions/portal/serve.js"; 3 | import { DirectoryPath } from "../../types/file/directoryPath.js"; 4 | import { FlagsProvider } from "../../types/flags-provider.js"; 5 | import { CommandMetadata } from "../../types/common/command-metadata.js"; 6 | import { format, intro, outro } from "../../prompts/format.js"; 7 | 8 | export default class PortalServe extends Command { 9 | static summary = "Generate and serve an API Documentation Portal with hot reload."; 10 | 11 | static description = 12 | "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."; 13 | 14 | static flags = { 15 | port: Flags.integer({ 16 | char: "p", 17 | description: "port to serve the portal.", 18 | default: 3000, 19 | helpValue: "3000" 20 | }), 21 | ...FlagsProvider.input, 22 | ...FlagsProvider.destination("portal", "portal"), 23 | open: Flags.boolean({ 24 | char: "o", 25 | description: "open the portal in the default browser.", 26 | default: false 27 | }), 28 | "no-reload": Flags.boolean({ 29 | description: "disable hot reload.", 30 | default: false 31 | }), 32 | ...FlagsProvider.authKey 33 | }; 34 | 35 | static cmdTxt = format.cmd("apimatic", "portal", "serve"); 36 | static examples = [ 37 | this.cmdTxt, 38 | `${this.cmdTxt} ` + 39 | `${format.flag("input", './')} ` + 40 | `${format.flag("destination", './portal')} ` + 41 | `${format.flag("port", "3000")} ` + 42 | `${format.flag("open")} ` + 43 | `${format.flag("no-reload")}` 44 | ]; 45 | 46 | public async run() { 47 | const { 48 | flags: { input, destination, port, open, "no-reload": noReload, "auth-key": authKey } 49 | } = await this.parse(PortalServe); 50 | 51 | const workingDirectory = DirectoryPath.createInput(input); 52 | const buildDirectory = input ? new DirectoryPath(input, "src") : workingDirectory.join("src"); 53 | const portalDirectory = destination ? new DirectoryPath(destination) : workingDirectory.join("portal"); 54 | const commandMetadata: CommandMetadata = { 55 | commandName: PortalServe.id, 56 | shell: this.config.shell 57 | }; 58 | 59 | intro("Portal Serve"); 60 | const portalServeAction = new PortalServeAction(this.getConfigDir(), commandMetadata, authKey); 61 | const result = await portalServeAction.execute(buildDirectory, portalDirectory, port, open, !noReload); 62 | outro(result); 63 | } 64 | 65 | private getConfigDir(): DirectoryPath { 66 | return new DirectoryPath(this.config.configDir); 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /src/commands/portal/toc/new.ts: -------------------------------------------------------------------------------- 1 | import { Command, Flags } from "@oclif/core"; 2 | import { PortalNewTocAction } from "../../../actions/portal/toc/new-toc.js"; 3 | import { TelemetryService } from "../../../infrastructure/services/telemetry-service.js"; 4 | import { TocCreationFailedEvent } from "../../../types/events/toc-creation-failed.js"; 5 | import { DirectoryPath } from "../../../types/file/directoryPath.js"; 6 | import { FlagsProvider } from "../../../types/flags-provider.js"; 7 | import { CommandMetadata } from "../../../types/common/command-metadata.js"; 8 | import { format, intro, outro } from "../../../prompts/format.js"; 9 | 10 | export default class PortalTocNew extends Command { 11 | static summary = "Generate a Table of Contents (TOC) file for your API documentation portal"; 12 | 13 | static description = `This command generates a new Table of Contents (TOC) file used in the 14 | generation of your API documentation portal. 15 | 16 | The output is a YAML file with the .yml extension. 17 | 18 | To learn more about the TOC file and APIMatic build directory structure, visit: 19 | ${format.link( 20 | "https://docs.apimatic.io/platform-api/#/http/guides/generating-on-prem-api-portal/overview-generating-api-portal" 21 | )}`; 22 | 23 | static flags = { 24 | ...FlagsProvider.destination("src/content", `toc.yml`), 25 | ...FlagsProvider.input, 26 | ...FlagsProvider.force, 27 | "expand-endpoints": Flags.boolean({ 28 | default: false, 29 | description: `include individual entries for each endpoint in the generated ${format.var( 30 | "toc.yml" 31 | )}. Requires a valid API specification in the working directory.` 32 | }), 33 | "expand-models": Flags.boolean({ 34 | default: false, 35 | description: `include individual entries for each model in the generated ${format.var( 36 | "toc.yml" 37 | )}. Requires a valid API specification in the working directory.` 38 | }) 39 | }; 40 | 41 | static cmdTxt = format.cmd("apimatic", "portal", "toc", "new"); 42 | static examples = [ 43 | `${this.cmdTxt} ${format.flag("destination", './src/content/')}`, 44 | `${this.cmdTxt} ${format.flag("input", './')}`, 45 | `${this.cmdTxt} ${format.flag("input", './')} ${format.flag("destination", './src/content/')}` 46 | ]; 47 | 48 | async run(): Promise { 49 | const { 50 | flags: { input, destination, force, "expand-endpoints": expandEndpoints, "expand-models": expandModels } 51 | } = await this.parse(PortalTocNew); 52 | 53 | const workingDirectory = DirectoryPath.createInput(input); 54 | const buildDirectory = input ? new DirectoryPath(input, "src") : workingDirectory.join("src"); 55 | const tocDirectory = destination ? new DirectoryPath(destination) : undefined; 56 | 57 | const commandMetadata: CommandMetadata = { 58 | commandName: PortalTocNew.id, 59 | shell: this.config.shell 60 | }; 61 | 62 | intro("New TOC"); 63 | const action = new PortalNewTocAction(new DirectoryPath(this.config.configDir), commandMetadata); 64 | const result = await action.execute(buildDirectory, tocDirectory, force, expandEndpoints, expandModels); 65 | outro(result); 66 | 67 | result.mapAll( 68 | () => {}, 69 | async () => { 70 | const telemetryService = new TelemetryService(new DirectoryPath(this.config.configDir)); 71 | await telemetryService.trackEvent( 72 | // TODO: fix Toc error message 73 | new TocCreationFailedEvent("error", PortalTocNew.id, { 74 | input, 75 | destination, 76 | force, 77 | "expand-endpoints": expandEndpoints, 78 | "expand-models": expandModels 79 | }), 80 | commandMetadata.shell 81 | ); 82 | }, 83 | () => {} 84 | ); 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /src/commands/quickstart.ts: -------------------------------------------------------------------------------- 1 | import { Command } from "@oclif/core"; 2 | import { format, intro, outro } from "../prompts/format.js"; 3 | import { TelemetryService } from "../infrastructure/services/telemetry-service.js"; 4 | import { DirectoryPath } from "../types/file/directoryPath.js"; 5 | import { CommandMetadata } from "../types/common/command-metadata.js"; 6 | import { QuickstartInitiatedEvent } from "../types/events/quickstart-initiated.js"; 7 | import { QuickstartAction } from "../actions/quickstart.js"; 8 | import { QuickstartCompletedEvent } from "../types/events/quickstart-completed.js"; 9 | 10 | export default class Quickstart extends Command { 11 | static description = "Get started with your first SDK or API Portal in four easy steps."; 12 | 13 | static summary = "Create your first SDK or API Portal using APIMatic."; 14 | 15 | static cmdTxt = format.cmd("apimatic", "quickstart"); 16 | 17 | static examples = [this.cmdTxt]; 18 | 19 | async run() { 20 | const telemetryService = new TelemetryService(this.getConfigDir()); 21 | const commandMetadata: CommandMetadata = { 22 | commandName: Quickstart.id, 23 | shell: this.config.shell 24 | }; 25 | 26 | await telemetryService.trackEvent(new QuickstartInitiatedEvent(), commandMetadata.shell); 27 | 28 | intro("Quickstart"); 29 | const action = new QuickstartAction(this.getConfigDir(), commandMetadata); 30 | const result = await action.execute(); 31 | outro(result); 32 | 33 | // TODO: Remove this, find a solution for tracking. 34 | await result.mapAll( 35 | async () => await telemetryService.trackEvent(new QuickstartCompletedEvent(), commandMetadata.shell), 36 | () => new Promise(() => {}), 37 | () => new Promise(() => {}) 38 | ); 39 | } 40 | 41 | private readonly getConfigDir = () => { 42 | return new DirectoryPath(this.config.configDir); 43 | }; 44 | } 45 | -------------------------------------------------------------------------------- /src/commands/sdk/generate.ts: -------------------------------------------------------------------------------- 1 | import { Command, Flags } from "@oclif/core"; 2 | import { DirectoryPath } from "../../types/file/directoryPath.js"; 3 | import { FlagsProvider } from "../../types/flags-provider.js"; 4 | import { GenerateAction } from "../../actions/sdk/generate.js"; 5 | import { Language } from "../../types/sdk/generate.js"; 6 | import { CommandMetadata } from "../../types/common/command-metadata.js"; 7 | import { format, intro, outro } from "../../prompts/format.js"; 8 | 9 | export default class SdkGenerate extends Command { 10 | static readonly summary = "Generate an SDK for your API"; 11 | 12 | static readonly description = `Generate Software Development Kits (SDKs) from API specifications. 13 | Supports multiple programming languages including Java, C#, Python, JavaScript, and more.`; 14 | 15 | static readonly cmdTxt = format.cmd("apimatic", "sdk", "generate"); 16 | 17 | static flags = { 18 | language: Flags.string({ 19 | char: "l", 20 | required: true, 21 | description: "Programming language for SDK generation", 22 | options: Object.values(Language).map((p) => p.valueOf()), 23 | }), 24 | spec: Flags.string({ 25 | description: "Path to the folder containing the API specification file", 26 | default: "./src/spec" 27 | }), 28 | destination: Flags.string({ 29 | char: "d", 30 | description: "Directory where the SDK will be generated" 31 | }), 32 | ...FlagsProvider.force, 33 | zip: Flags.boolean({ 34 | default: false, 35 | description: "Download the generated SDK as a .zip archive" 36 | }), 37 | ...FlagsProvider.authKey 38 | }; 39 | 40 | static examples = [ 41 | `${SdkGenerate.cmdTxt} ${format.flag("language", "java")}`, 42 | `${SdkGenerate.cmdTxt} ${format.flag("language", "csharp")} ${format.flag("spec", "./src/spec")}`, 43 | `${SdkGenerate.cmdTxt} ${format.flag("language", "python")} ${format.flag("destination", "./sdk")} ${format.flag( 44 | "zip" 45 | )}` 46 | ]; 47 | 48 | async run() { 49 | const { 50 | flags: { language, spec, destination, force, zip: zipSdk, "auth-key": authKey } 51 | } = await this.parse(SdkGenerate); 52 | 53 | const specDirectory = new DirectoryPath(spec); 54 | const sdkDirectory = destination ? new DirectoryPath(destination) : DirectoryPath.default.join("sdk"); 55 | 56 | const commandMetadata: CommandMetadata = { 57 | commandName: SdkGenerate.id, 58 | shell: this.config.shell 59 | }; 60 | 61 | intro("Generate SDK"); 62 | const action = new GenerateAction(this.getConfigDir(), commandMetadata, authKey); 63 | const result = await action.execute(specDirectory, sdkDirectory, language as Language, force, zipSdk); 64 | outro(result); 65 | } 66 | 67 | private readonly getConfigDir = () => { 68 | return new DirectoryPath(this.config.configDir); 69 | }; 70 | } 71 | -------------------------------------------------------------------------------- /src/config/axios-config.ts: -------------------------------------------------------------------------------- 1 | import axios from "axios"; 2 | 3 | const fiftyMBsInBytes = 50 * 1024 * 1024; 4 | const fiveMinutesInMilliseconds = 5 * 60 * 1000; 5 | 6 | const axiosInstance = axios.create({ 7 | maxContentLength: fiftyMBsInBytes, 8 | maxBodyLength: fiftyMBsInBytes, 9 | timeout: fiveMinutesInMilliseconds 10 | }); 11 | 12 | export default axiosInstance; 13 | -------------------------------------------------------------------------------- /src/hooks/not-found.ts: -------------------------------------------------------------------------------- 1 | // This code was originally forked from https://github.com/oclif/plugin-not-found/blob/main/src/index.ts 2 | import { Hook, toConfiguredId } from "@oclif/core"; 3 | import { cyan, yellow } from "ansis"; 4 | 5 | import utils from "./utils.js"; 6 | 7 | const hook: Hook.CommandNotFound = async function (opts) { 8 | const hiddenCommandIds = new Set(opts.config.commands.filter((c) => c.hidden).map((c) => c.id)); 9 | 10 | const commandIDs = [...opts.config.commandIDs, ...opts.config.commands.flatMap((c) => c.aliases)].filter( 11 | (c) => !hiddenCommandIds.has(c) 12 | ); 13 | 14 | if (commandIDs.length === 0) return; 15 | 16 | let binHelp = `${opts.config.bin} help`; 17 | const idSplit = opts.id.split(":"); 18 | if (opts.config.findTopic(idSplit[0])) { 19 | binHelp = `${binHelp} ${idSplit[0]}`; 20 | } 21 | 22 | let suggestion: string | null; 23 | if (/:?help:?/.test(opts.id)) { 24 | suggestion = ["help", ...opts.id.split(":").filter((cmd) => cmd !== "help")].join(":"); 25 | } else { 26 | suggestion = utils.closest(opts.id, commandIDs); 27 | } 28 | 29 | const readableSuggestion = suggestion ? toConfiguredId(suggestion, this.config) : null; 30 | 31 | const originalCmd = toConfiguredId(opts.id, this.config); 32 | this.warn(`${yellow(originalCmd)} is not a ${opts.config.bin} command.`); 33 | 34 | if (!process.stdin.isTTY || !suggestion) { 35 | this.error(`Run ${cyan.bold(binHelp)} for a list of available commands.`, { 36 | exit: 127 37 | }); 38 | } 39 | 40 | let response: boolean; 41 | try { 42 | response = await utils.getConfirmation(readableSuggestion!); 43 | } catch { 44 | response = false; 45 | } 46 | 47 | if (response) { 48 | const confirmedSuggestion = suggestion!; 49 | let argv = opts.argv?.length ? opts.argv : opts.id.split(":").slice(confirmedSuggestion.split(":").length); 50 | 51 | if (confirmedSuggestion.startsWith("help:")) { 52 | argv = confirmedSuggestion.split(":").slice(1); 53 | suggestion = "help"; 54 | } 55 | 56 | return this.config.runCommand(confirmedSuggestion, argv); 57 | } 58 | 59 | this.error(`Run ${cyan.bold(binHelp)} for a list of available commands.`, { 60 | exit: 127 61 | }); 62 | 63 | }; 64 | 65 | export default hook; 66 | -------------------------------------------------------------------------------- /src/hooks/utils.ts: -------------------------------------------------------------------------------- 1 | // This code was originally forked from https://github.com/oclif/plugin-not-found/blob/main/src/utils.ts 2 | import readline from 'node:readline'; 3 | import { blueBright, reset } from 'ansis'; 4 | import levenshtein from 'fast-levenshtein'; 5 | 6 | const getConfirmation = async (suggestion: string): Promise => { 7 | if (!process.stdin.isTTY) return false; 8 | 9 | const question = `${reset('Did you mean ' + blueBright(suggestion) + '?')} (Y/n) `; 10 | 11 | return new Promise((resolve) => { 12 | const rl = readline.createInterface({ 13 | input: process.stdin, 14 | output: process.stdout, 15 | }); 16 | 17 | let settled = false; 18 | 19 | const cleanup = () => { 20 | rl.close(); 21 | clearTimeout(timeout); 22 | }; 23 | 24 | const finish = (result: boolean) => { 25 | if (!settled) { 26 | settled = true; 27 | cleanup(); 28 | resolve(result); 29 | } 30 | }; 31 | 32 | const timeout = setTimeout(() => { 33 | finish(false); 34 | }, 10_000); 35 | 36 | rl.question(question, (answer) => { 37 | const a = (answer ?? '').trim().toLowerCase(); 38 | finish(a === '' || a === 'y' || a === 'yes'); 39 | }); 40 | }); 41 | }; 42 | 43 | const closest = (target: string, possibilities: string[]): string | null => { 44 | let best: string | null = null; 45 | let bestDistance = Infinity; 46 | 47 | for (const id of possibilities) { 48 | const distance = levenshtein.get(target, id, { useCollator: true }); 49 | if (distance < bestDistance) { 50 | bestDistance = distance; 51 | best = id; 52 | } 53 | } 54 | 55 | return best; 56 | }; 57 | 58 | export default { 59 | closest, 60 | getConfirmation, 61 | }; 62 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export { run } from "@oclif/core"; 2 | -------------------------------------------------------------------------------- /src/infrastructure/debounce-service.ts: -------------------------------------------------------------------------------- 1 | import { clearTimeout, setTimeout } from "timers"; 2 | 3 | export class DebounceService { 4 | private isProcessing = false; 5 | private latestHandler: (() => Promise) | null = null; 6 | private debounceTimer: NodeJS.Timeout | null = null; 7 | private readonly debounceMs: number; 8 | 9 | constructor(debounceMs: number = 500) { 10 | this.debounceMs = debounceMs; 11 | } 12 | 13 | async batchSingleRequest(handler: () => Promise): Promise { 14 | // Always store the latest handler 15 | this.latestHandler = handler; 16 | 17 | // If already processing, don't start a new timer. Just update the latest handler 18 | if (this.isProcessing) { 19 | return; 20 | } 21 | 22 | // Clear any existing timer 23 | if (this.debounceTimer) { 24 | clearTimeout(this.debounceTimer); 25 | } 26 | 27 | // Set up debounced execution 28 | this.scheduleExecution(); 29 | } 30 | 31 | private scheduleExecution(): void { 32 | this.debounceTimer = setTimeout(async () => { 33 | if (this.isProcessing) { 34 | return; 35 | } 36 | 37 | this.isProcessing = true; 38 | this.debounceTimer = null; 39 | 40 | try { 41 | // Execute the latest handler if it exists 42 | if (this.latestHandler) { 43 | const currentHandler = this.latestHandler; 44 | this.latestHandler = null; 45 | await currentHandler(); 46 | } 47 | } finally { 48 | this.isProcessing = false; 49 | } 50 | }, this.debounceMs); 51 | } 52 | 53 | // Method to clear any pending execution. 54 | public close(): void { 55 | if (this.debounceTimer) { 56 | clearTimeout(this.debounceTimer); 57 | this.debounceTimer = null; 58 | } 59 | this.latestHandler = null; 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /src/infrastructure/env-info.ts: -------------------------------------------------------------------------------- 1 | import os from "os"; 2 | import { fileURLToPath } from "url"; 3 | import { dirname, join } from "path"; 4 | import fs from "fs-extra"; 5 | 6 | class EnvInfo { 7 | private static cachedCliVersion: string | null = null; 8 | private static cachedUserAgent: string | null = null; 9 | private static cachedBaseUrl: string | undefined; 10 | private static cachedAuthBaseUrl: string | undefined; 11 | 12 | public getUserAgent(shell: string): string { 13 | if (!EnvInfo.cachedUserAgent) { 14 | const osInfo = `${os.platform()} ${os.release()}`; 15 | const engine = "Node.js"; 16 | const engineVersion = process.version; 17 | EnvInfo.cachedUserAgent = `APIMATIC CLI/${this.getCLIVersion()} - (OS: ${osInfo}, Engine: ${engine}/${engineVersion}, Shell: ${shell})`; 18 | } 19 | return EnvInfo.cachedUserAgent; 20 | } 21 | 22 | public getCLIVersion(): string { 23 | if (EnvInfo.cachedCliVersion) { 24 | return EnvInfo.cachedCliVersion; 25 | } 26 | 27 | try { 28 | const __filename = fileURLToPath(import.meta.url); 29 | const __dirname = dirname(__filename); 30 | const pkgPath = join(__dirname, "../../package.json"); 31 | const pkgJson = fs.readFileSync(pkgPath, "utf-8"); 32 | const pkg = JSON.parse(pkgJson); 33 | const version = pkg.version || "unknown"; 34 | EnvInfo.cachedCliVersion = version; 35 | return version; 36 | } catch { 37 | return "unknown"; 38 | } 39 | } 40 | 41 | public getBaseUrl(): string | undefined { 42 | if (EnvInfo.cachedBaseUrl) { 43 | return EnvInfo.cachedBaseUrl; 44 | } 45 | const envBaseUrls = process.env.APIMATIC_BASE_URL; 46 | if (envBaseUrls) { 47 | EnvInfo.cachedBaseUrl = envBaseUrls.split(";")[0]; 48 | } 49 | return EnvInfo.cachedBaseUrl; 50 | } 51 | 52 | public getAuthBaseUrl(): string | undefined { 53 | if (EnvInfo.cachedAuthBaseUrl) { 54 | return EnvInfo.cachedAuthBaseUrl; 55 | } 56 | const envBaseUrls = process.env.APIMATIC_BASE_URL; 57 | if (envBaseUrls) { 58 | const baseUrls = envBaseUrls.split(";"); 59 | EnvInfo.cachedAuthBaseUrl = baseUrls.length === 2 ? baseUrls[1] : undefined; 60 | } 61 | return EnvInfo.cachedAuthBaseUrl; 62 | } 63 | } 64 | export const envInfo = new EnvInfo(); 65 | -------------------------------------------------------------------------------- /src/infrastructure/file-service.ts: -------------------------------------------------------------------------------- 1 | import fs from "fs"; 2 | import fsExtra from "fs-extra"; 3 | import * as path from "path"; 4 | import { pipeline } from "stream"; 5 | import { promisify } from "util"; 6 | import { FilePath } from "../types/file/filePath.js"; 7 | import { DirectoryPath } from "../types/file/directoryPath.js"; 8 | import { Directory } from "../types/file/directory.js"; 9 | import { FileName } from "../types/file/fileName.js"; 10 | 11 | export class FileService { 12 | public async fileExists(file: FilePath): Promise { 13 | try { 14 | const stat = await fsExtra.stat(file.toString()); 15 | return stat.isFile(); 16 | } catch { 17 | return false; 18 | } 19 | } 20 | 21 | public async directoryExists(dir: DirectoryPath): Promise { 22 | try { 23 | const stat = await fsExtra.stat(dir.toString()); 24 | return stat.isDirectory(); 25 | } catch { 26 | return false; 27 | } 28 | } 29 | 30 | public async directoryEmpty(dir: DirectoryPath): Promise { 31 | try { 32 | const files = await fsExtra.readdir(dir.toString()); 33 | return files.filter((file) => !file.startsWith(".")).length === 0; 34 | } catch (error) { 35 | return error instanceof Error && "code" in error && error.code === "ENOENT"; 36 | } 37 | } 38 | 39 | public async cleanDirectory(dir: DirectoryPath): Promise { 40 | await fsExtra.ensureDir(dir.toString()); 41 | await fsExtra.emptyDir(dir.toString()); // removes everything inside, keeps the dir 42 | } 43 | 44 | public async createDirectoryIfNotExists(dir: DirectoryPath): Promise { 45 | await fsExtra.ensureDir(dir.toString()); 46 | } 47 | 48 | public async getDirectory(directoryPath: DirectoryPath): Promise { 49 | const entries = await fsExtra.readdir(directoryPath.toString()); 50 | const results = await Promise.all( 51 | entries.map(async (entry) => { 52 | const fullPath = path.join(directoryPath.toString(), entry); 53 | const stat = await fsExtra.stat(fullPath); 54 | return stat.isDirectory() ? await this.getDirectory(new DirectoryPath(fullPath)) : new FileName(entry); 55 | }) 56 | ); 57 | return new Directory(directoryPath, results); 58 | } 59 | 60 | public async copyDirectoryContents(source: DirectoryPath, destination: DirectoryPath) { 61 | const entries = await fsExtra.readdir(source.toString()); 62 | await Promise.all( 63 | entries.map(async (entry) => { 64 | const srcEntry = path.join(source.toString(), entry); 65 | const destEntry = path.join(destination.toString(), entry); 66 | await fsExtra.copy(srcEntry, destEntry); 67 | }) 68 | ); 69 | } 70 | 71 | public async deleteFile(filePath: FilePath): Promise { 72 | const exists = await this.fileExists(filePath); 73 | if (exists) { 74 | await fsExtra.remove(filePath.toString()); 75 | } 76 | } 77 | 78 | public async deleteDirectory(dirPath: DirectoryPath): Promise { 79 | const exists = await this.directoryExists(dirPath); 80 | if (exists) { 81 | await fsExtra.remove(dirPath.toString()); 82 | } 83 | } 84 | 85 | public getRelativePath(filePath: FilePath, basePath: DirectoryPath): string { 86 | const filePathStr = filePath.toString(); 87 | const basePathStr = basePath.toString(); 88 | 89 | if (filePathStr.startsWith(basePathStr)) { 90 | const relativePath = filePathStr.substring(basePathStr.length).replace(/^[/\\]/, ""); 91 | return relativePath.replace(/\\/g, "/"); 92 | } 93 | 94 | // Normalize the full path if it doesn't start with basePath 95 | return filePathStr.replace(/\\/g, "/"); 96 | } 97 | 98 | public async getStream(filePath: FilePath) { 99 | return fs.createReadStream(filePath.toString()); 100 | } 101 | 102 | public async getContents(filePath: FilePath): Promise { 103 | return await fsExtra.readFile(filePath.toString(), "utf-8"); 104 | } 105 | 106 | public async writeFile(filePath: FilePath, stream: NodeJS.ReadableStream) { 107 | const writeStream = fs.createWriteStream(filePath.toString()); 108 | await streamPipeline(stream, writeStream); 109 | } 110 | 111 | public async ensurePathExists(filePath: FilePath) { 112 | await fsExtra.ensureFile(filePath.toString()); 113 | } 114 | 115 | public async writeContents(filePath: FilePath, contents: string) { 116 | await fsExtra.writeFile(filePath.toString(), contents, "utf-8"); 117 | } 118 | 119 | public async copy(source: FilePath, destination: FilePath) { 120 | await fsExtra.copyFile(source.toString(), destination.toString()); 121 | } 122 | 123 | public async copyToDir(source: FilePath, destination: DirectoryPath) { 124 | await fsExtra.copyFile(source.toString(), source.replaceDirectory(destination).toString()); 125 | } 126 | 127 | public async isZipFile(filePath: FilePath): Promise { 128 | try { 129 | const buffer = await fsExtra.readFile(filePath.toString()); 130 | return ( 131 | buffer.length >= 4 && 132 | buffer[0] === 0x50 && // P 133 | buffer[1] === 0x4b && // K 134 | buffer[2] === 0x03 && // \x03 135 | buffer[3] === 0x04 136 | ); // \x04 137 | } catch { 138 | return false; 139 | } 140 | } 141 | } 142 | 143 | const streamPipeline = promisify(pipeline); 144 | -------------------------------------------------------------------------------- /src/infrastructure/launcher-service.ts: -------------------------------------------------------------------------------- 1 | import { FilePath } from "../types/file/filePath.js"; 2 | import { execa } from "execa"; 3 | import os from "os"; 4 | import { spawn } from "child_process"; 5 | import open from "open"; 6 | import { UrlPath } from "../types/file/urlPath.js"; 7 | import isInCi from "is-in-ci"; 8 | import { DirectoryPath } from "../types/file/directoryPath.js"; 9 | 10 | export class LauncherService { 11 | public async openFolderInIde(directoryPath: DirectoryPath, fileToOpen: FilePath): Promise { 12 | if (isInCi) return false; 13 | try { 14 | await execa("code", [directoryPath.toString(), fileToOpen.toString()]); 15 | return true; 16 | } catch { 17 | return false; 18 | } 19 | } 20 | 21 | public async openInEditor(filePath: FilePath): Promise { 22 | if (isInCi) return; 23 | try { 24 | await execa("code", ["--wait", filePath.toString()]); 25 | } catch { 26 | // TODO: check for fallback (start) 27 | if (process.platform === "win32") { 28 | await execa("cmd", ["/c", "start", "/wait", "notepad", filePath.toString()], { stdio: "ignore" }); 29 | } else if (process.platform === "darwin") { 30 | await execa("vim", [filePath.toString()], { stdio: "inherit" }); 31 | } 32 | } 33 | } 34 | 35 | public async openFile(filePath: FilePath): Promise { 36 | const targetPath = filePath.toString(); 37 | 38 | // Determine the command and args without using the shell 39 | let command: string; 40 | let args: string[]; 41 | 42 | switch (os.platform()) { 43 | case "win32": 44 | command = "cmd"; 45 | args = ["/c", "start", "", targetPath]; 46 | break; 47 | case "darwin": 48 | command = "open"; 49 | args = [targetPath]; 50 | break; 51 | default: 52 | command = "xdg-open"; 53 | args = [targetPath]; 54 | break; 55 | } 56 | 57 | try { 58 | const child = spawn(command, args, { stdio: "ignore", detached: true }); 59 | child.unref(); // Let it run without blocking 60 | } catch { 61 | // Silently ignore errors 62 | } 63 | } 64 | 65 | public openUrlInBrowser(url: UrlPath) { 66 | if (isInCi) return; 67 | try { 68 | return open(url.toString()); 69 | } catch { 70 | // Silently ignore errors 71 | } 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /src/infrastructure/network-service.ts: -------------------------------------------------------------------------------- 1 | import getPort from "get-port"; 2 | 3 | export class NetworkService { 4 | public async getServerPort(preferredPorts: number[]): Promise { 5 | return await getPort({ port: preferredPorts }); 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /src/infrastructure/service-error.ts: -------------------------------------------------------------------------------- 1 | import axios from "axios"; 2 | import { format as f } from "../prompts/format.js"; 3 | 4 | export class ServiceError { 5 | private static defaultErrorMessage = `An unexpected error occurred, please try again later. If the problem persists, please reach out to our team at ${f.var( 6 | "support@apimatic.io" 7 | )}`; 8 | 9 | static readonly NotFound = new ServiceError("NOT_FOUND", "Resource not found."); 10 | static readonly ServerError = new ServiceError("SERVER_ERROR", this.defaultErrorMessage); 11 | static readonly NetworkError = new ServiceError("NETWORK_ERROR", "Unable to connect to the server."); 12 | static readonly InvalidResponse = new ServiceError("INVALID_RESPONSE", this.defaultErrorMessage); 13 | static readonly UnAuthorized = new ServiceError("UNAUTHORIZED", "Unauthorized access."); 14 | static badRequest(customMessage: string): ServiceError { 15 | return new ServiceError("BAD_REQUEST", customMessage); 16 | } 17 | static forbidden(customMessage: string): ServiceError { 18 | return new ServiceError("FORBIDDEN", customMessage); 19 | } 20 | 21 | static readonly values: ServiceError[] = [ 22 | ServiceError.NotFound, 23 | ServiceError.ServerError, 24 | ServiceError.NetworkError, 25 | ServiceError.InvalidResponse, 26 | ServiceError.UnAuthorized 27 | ]; 28 | 29 | private constructor(public readonly code: string, private readonly defaultMessage: string) {} 30 | 31 | public get errorMessage(): string { 32 | return this.defaultMessage; 33 | } 34 | } 35 | 36 | export function handleServiceError(error: unknown): ServiceError { 37 | if (axios.isAxiosError(error)) { 38 | const status = error.response?.status; 39 | if (status === 401) return ServiceError.UnAuthorized; 40 | if (status === 404) return ServiceError.NotFound; 41 | if (status === 500) return ServiceError.ServerError; 42 | 43 | if (error.code === "ECONNABORTED" || error.code === "ECONNREFUSED" || error.code === "ENOTFOUND") { 44 | return ServiceError.NetworkError; 45 | } 46 | } 47 | 48 | return ServiceError.ServerError; 49 | } 50 | -------------------------------------------------------------------------------- /src/infrastructure/services/api-client-factory.ts: -------------------------------------------------------------------------------- 1 | import { Client, Environment } from "@apimatic/sdk"; 2 | import { envInfo } from "../env-info.js"; 3 | 4 | export class ApiClientFactory { 5 | private readonly TIMEOUT = 0; 6 | 7 | public createApiClient = (authorizationHeader: string, shell: string): Client => { 8 | const baseConfig = { 9 | customHeaderAuthenticationCredentials: { 10 | Authorization: authorizationHeader 11 | }, 12 | userAgent: envInfo.getUserAgent(shell), 13 | timeout: this.TIMEOUT 14 | }; 15 | 16 | const baseUrl = envInfo.getBaseUrl(); 17 | return new Client({ 18 | ...baseConfig, 19 | environment: baseUrl ? Environment.Testing : Environment.Production, 20 | ...(baseUrl && { customUrl: baseUrl }) 21 | }); 22 | }; 23 | } 24 | 25 | export const apiClientFactory = new ApiClientFactory(); 26 | -------------------------------------------------------------------------------- /src/infrastructure/services/api-service.ts: -------------------------------------------------------------------------------- 1 | import axios from "axios"; 2 | import { AuthInfo, getAuthInfo } from "../../client-utils/auth-manager.js"; 3 | import { DirectoryPath } from "../../types/file/directoryPath.js"; 4 | import { SubscriptionInfo } from "../../types/api/account.js"; 5 | import { envInfo } from "../env-info.js"; 6 | import { err, ok, Result } from "neverthrow"; 7 | import { handleServiceError, ServiceError } from "../service-error.js"; 8 | import { PortalGenerationStatusResponse } from "@apimatic/sdk"; 9 | 10 | export class ApiService { 11 | private readonly apiBaseUrl = "https://api.apimatic.io" as const; 12 | 13 | public async getAccountInfo( 14 | configDir: DirectoryPath, 15 | shell: string, 16 | authKey: string | null 17 | ): Promise> { 18 | const authInfo: AuthInfo | null = await getAuthInfo(configDir.toString()); 19 | if (authInfo === null && !authKey) { 20 | return err(ServiceError.UnAuthorized); 21 | } 22 | 23 | try { 24 | const token = authKey || authInfo?.authKey; 25 | const response = await this.axiosInstance(shell, token).get("/account/profile"); 26 | 27 | if (response.status === 200) { 28 | return ok(response.data as SubscriptionInfo); 29 | } 30 | return err(ServiceError.InvalidResponse); 31 | } catch (error: unknown) { 32 | return err(handleServiceError(error)); 33 | } 34 | } 35 | 36 | public async getPortalGenerationStatus( 37 | requestId: string, 38 | configDir: DirectoryPath, 39 | shell: string, 40 | authKey: string | null 41 | ): Promise> { 42 | const authInfo: AuthInfo | null = await getAuthInfo(configDir.toString()); 43 | if (authInfo === null && !authKey) { 44 | return err(ServiceError.UnAuthorized); 45 | } 46 | 47 | try { 48 | const token = authKey || authInfo?.authKey; 49 | const response = await this.axiosInstance(shell, token).get(`/portal/v2/${requestId}/status`, { 50 | headers: { Accept: "application/json" }, 51 | maxRedirects: 0, 52 | validateStatus: () => true 53 | }); 54 | 55 | if (response.status === 200) { 56 | return ok(response.data as PortalGenerationStatusResponse); 57 | } 58 | 59 | if (response.status === 302) { 60 | return ok({ status: "Completed" } as PortalGenerationStatusResponse); 61 | } 62 | 63 | return err(ServiceError.InvalidResponse); 64 | } catch (error: unknown) { 65 | return err(handleServiceError(error)); 66 | } 67 | } 68 | 69 | public async sendTelemetry(payload: string, authKey: string, shell: string): Promise> { 70 | try { 71 | const response = await this.axiosInstance(shell, authKey).post("/telemetry/track", payload, { 72 | headers: { "Content-Type": "application/json" } 73 | }); 74 | 75 | if (response.status === 200) { 76 | return ok("telemetry sent"); 77 | } 78 | return err("Failed to send telemetry data"); 79 | } catch (error: unknown) { 80 | return err(handleServiceError(error)); 81 | } 82 | } 83 | 84 | private axiosInstance(shell: string, apiKey: string | undefined) { 85 | const headers: Record = { 86 | "User-Agent": envInfo.getUserAgent(shell) 87 | }; 88 | 89 | if (apiKey) { 90 | headers.Authorization = `X-Auth-Key ${apiKey}`; 91 | } 92 | 93 | return axios.create({ 94 | baseURL: envInfo.getBaseUrl() ?? this.apiBaseUrl, 95 | headers 96 | }); 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /src/infrastructure/services/auth-service.ts: -------------------------------------------------------------------------------- 1 | import axios, { AxiosInstance } from "axios"; 2 | import { envInfo } from "../env-info.js"; 3 | import { err, ok, Result } from "neverthrow"; 4 | import { ServiceError, handleServiceError } from "../service-error.js"; 5 | 6 | export interface DeviceAuthToken { 7 | apiKey: string; 8 | } 9 | 10 | export class AuthService { 11 | private readonly apiBaseUrl = "https://auth.apimatic.io" as const; 12 | 13 | private axiosInstance(shell: string): AxiosInstance { 14 | return axios.create({ 15 | baseURL: envInfo.getAuthBaseUrl() ?? this.apiBaseUrl, 16 | timeout: 20000, 17 | headers: { 18 | "User-Agent": envInfo.getUserAgent(shell) 19 | } 20 | }); 21 | } 22 | 23 | public getDeviceLoginUrl(state: string): string { 24 | return `${envInfo.getAuthBaseUrl() ?? this.apiBaseUrl}/device-auth/login?state=${state}`; 25 | } 26 | public async getDeviceLoginToken(state: string, shell: string): Promise> { 27 | try { 28 | const response = await this.axiosInstance(shell).get(`/device-auth/token?state=${state}`); 29 | if (response.status === 200 && response.data?.apiKey) { 30 | return ok({ apiKey: response.data.apiKey }); 31 | } 32 | return err(ServiceError.InvalidResponse); 33 | } catch (error) { 34 | return err(handleServiceError(error)); 35 | } 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/infrastructure/services/file-download-service.ts: -------------------------------------------------------------------------------- 1 | import axios from "axios"; 2 | import { UrlPath } from "../../types/file/urlPath.js"; 3 | import { err, ok, Result } from "neverthrow"; 4 | import { handleServiceError, ServiceError } from "../service-error.js"; 5 | import { FileName } from "../../types/file/fileName.js"; 6 | 7 | export type FileDownloadResponse = { 8 | stream: NodeJS.ReadableStream; 9 | filename: FileName; 10 | }; 11 | 12 | export class FileDownloadService { 13 | public async downloadFile(url: UrlPath): Promise> { 14 | try { 15 | const response = await axios.get(url.toString(), { 16 | responseType: "stream" 17 | }); 18 | 19 | const contentDisposition = response.headers["content-disposition"]; 20 | let filename: FileName | undefined; 21 | 22 | // Try to parse filename from Content-Disposition (supports filename* as per RFC 5987 and plain filename) 23 | if (contentDisposition) { 24 | const parsed = this.parseFilenameFromContentDisposition(contentDisposition); 25 | if (parsed) { 26 | filename = new FileName(parsed); 27 | } 28 | } 29 | 30 | // ... existing code ... 31 | // If no filename derived from headers, fallback to URL 32 | if (!filename) { 33 | const fromUrl = this.getFilenameFromUrl(url.toString()); 34 | if (fromUrl) { 35 | filename = new FileName(fromUrl); 36 | } else { 37 | filename = new FileName("file"); 38 | } 39 | } 40 | 41 | // Basic guard — responseType: "stream" should always yield a stream 42 | const data = response.data as unknown; 43 | const isReadableStream = data && typeof (data as NodeJS.ReadableStream).pipe === "function"; 44 | 45 | if (!isReadableStream) { 46 | return err(ServiceError.InvalidResponse); 47 | } 48 | 49 | return ok({ 50 | stream: response.data as NodeJS.ReadableStream, 51 | filename 52 | }); 53 | } catch (error: unknown) { 54 | return err(handleServiceError(error)); 55 | } 56 | } 57 | 58 | private parseFilenameFromContentDisposition(headerValue: string): string | null { 59 | // Try RFC 5987: filename*=UTF-8''encoded%20name.ext 60 | const filenameStarMatch = headerValue.match(/filename\*\s*=\s*([^']*)''([^;]+)/i); // NOSONAR - safe regex for CLI use 61 | if (filenameStarMatch) { 62 | const encoded = filenameStarMatch[2].trim(); 63 | try { 64 | const decoded = decodeURIComponent(encoded); 65 | return this.sanitizeFilename(decoded); 66 | } catch { 67 | // fall through to other strategies 68 | } 69 | } 70 | 71 | // Try plain: filename="name.ext" or filename=name.ext 72 | const filenameMatch = headerValue.match(/filename\s*=\s*"?([^";]+)"?/i); 73 | if (filenameMatch) { 74 | return this.sanitizeFilename(filenameMatch[1]); 75 | } 76 | 77 | return null; 78 | } 79 | 80 | private getFilenameFromUrl(rawUrl: string): string | null { 81 | try { 82 | const u = new URL(rawUrl); 83 | const last = u.pathname.split("/").filter(Boolean).pop(); 84 | if (!last) return null; 85 | 86 | // Remove any spurious trailing spaces and decode 87 | const decoded = this.safeDecodeURIComponent(last.trim()); 88 | return this.sanitizeFilename(decoded); 89 | } catch { 90 | // Fallback for non-URL-safe strings 91 | const parts = rawUrl.split("/").filter(Boolean); 92 | const last = parts.pop(); 93 | if (!last) return null; 94 | return this.sanitizeFilename(this.safeDecodeURIComponent(last)); 95 | } 96 | } 97 | 98 | private safeDecodeURIComponent(value: string): string { 99 | try { 100 | return decodeURIComponent(value); 101 | } catch { 102 | return value; 103 | } 104 | } 105 | 106 | private sanitizeFilename(name: string): string { 107 | // Replace characters not allowed in common filesystems and trim dots/spaces 108 | const sanitized = name 109 | .replace('/[<>:"/\\|?*\x00-\x1F]/g', "_") 110 | .replace(/\s+/g, " ") 111 | .trim(); 112 | // Avoid names that are empty or only dots/spaces 113 | const safe = sanitized.replace(/^[. ]+|[. ]+$/g, ""); // NOSONAR - safe regex for CLI use 114 | return safe.length > 0 ? safe : "file"; 115 | } 116 | } 117 | -------------------------------------------------------------------------------- /src/infrastructure/services/telemetry-service.ts: -------------------------------------------------------------------------------- 1 | import process from "process"; 2 | import os from "os"; 3 | import fs from "fs-extra"; 4 | import { DomainEvent } from "../../types/events/domain-event.js"; 5 | import { AuthInfo } from "../../client-utils/auth-manager.js"; 6 | import { envInfo } from "../env-info.js"; 7 | import path from "path"; 8 | import { ApiService } from "./api-service.js"; 9 | import { DirectoryPath } from "../../types/file/directoryPath.js"; 10 | 11 | type TelemetryPayload = { 12 | payload: DomainEvent; 13 | timestamp: string; 14 | cliVersion: string; 15 | platform: string; 16 | releaseVersion: string; 17 | nodeVersion: string; 18 | }; 19 | 20 | export class TelemetryService { 21 | private readonly apiService = new ApiService(); 22 | 23 | constructor(private readonly configDirectory: DirectoryPath) {} 24 | 25 | public async trackEvent(event: T, shell: string): Promise { 26 | const authInfo = await this.getAuthInfo(this.configDirectory.toString()); 27 | const telemetryOptedOut = process.env.APIMATIC_CLI_TELEMETRY_OPTOUT === "1"; 28 | const authKey = authInfo?.authKey; 29 | 30 | if (telemetryOptedOut || authInfo?.APIMATIC_CLI_TELEMETRY_OPTOUT === "1" || !authKey) { 31 | return; 32 | } 33 | 34 | const payload: TelemetryPayload = { 35 | payload: event, 36 | timestamp: new Date().toISOString(), 37 | cliVersion: envInfo.getCLIVersion(), 38 | platform: os.platform(), 39 | releaseVersion: os.release(), 40 | nodeVersion: process.version 41 | }; 42 | 43 | await this.apiService.sendTelemetry(JSON.stringify(payload), authKey, shell); 44 | } 45 | 46 | private async getAuthInfo(configDirectory: string): Promise { 47 | try { 48 | return JSON.parse(await fs.readFile(path.join(configDirectory, "config.json"), "utf8")); 49 | } catch { 50 | return null; 51 | } 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /src/infrastructure/services/transformation-service.ts: -------------------------------------------------------------------------------- 1 | import fsExtra from "fs-extra"; 2 | import { 3 | ApiResponse, 4 | ContentType, 5 | ExportFormats, 6 | FileWrapper, 7 | TransformationController, 8 | Transformation, 9 | ApiError, 10 | ApiValidationSummary 11 | } from "@apimatic/sdk"; 12 | 13 | import { AuthInfo, getAuthInfo } from "../../client-utils/auth-manager.js"; 14 | import { TransformationData } from "../../types/api/transform.js"; 15 | import { DirectoryPath } from "../../types/file/directoryPath.js"; 16 | import { apiClientFactory } from "./api-client-factory.js"; 17 | import { FilePath } from "../../types/file/filePath.js"; 18 | import { CommandMetadata } from "../../types/common/command-metadata.js"; 19 | import { err, ok, Result} from "neverthrow"; 20 | 21 | export interface TransformViaFileParams { 22 | file: FilePath; 23 | format: ExportFormats; 24 | configDir: DirectoryPath; 25 | commandMetadata: CommandMetadata; 26 | authKey?: string | null; 27 | } 28 | 29 | export interface TransformationResultData { 30 | stream: NodeJS.ReadableStream; 31 | apiValidationSummary: ApiValidationSummary; 32 | } 33 | 34 | export class TransformationService { 35 | private readonly CONTENT_TYPE = ContentType.EnumMultipartformdata; 36 | 37 | public async transformViaFile({ 38 | file, 39 | format, 40 | configDir, 41 | commandMetadata, 42 | authKey 43 | }: TransformViaFileParams): Promise> { 44 | const authInfo: AuthInfo | null = await getAuthInfo(configDir.toString()); 45 | const authorizationHeader = this.createAuthorizationHeader(authInfo, authKey ?? null); 46 | const client = apiClientFactory.createApiClient(authorizationHeader, commandMetadata.shell); 47 | const transformationController = new TransformationController(client); 48 | 49 | try { 50 | const fileStream = fsExtra.createReadStream(file.toString()); 51 | const fileWrapper = new FileWrapper(fileStream); 52 | const generation: ApiResponse = await transformationController.transformViaFile( 53 | this.CONTENT_TYPE, 54 | fileWrapper, 55 | format as ExportFormats, 56 | this.createOriginQueryParameter(commandMetadata.commandName) 57 | ); 58 | 59 | const { id, apiValidationSummary } = generation.result; 60 | const { result }: TransformationData = await transformationController.downloadTransformedFile(id); 61 | 62 | return ok({ 63 | stream: result as NodeJS.ReadableStream, 64 | apiValidationSummary 65 | }); 66 | } catch (error) { 67 | return err(await this.handleTransformationErrors(error)); 68 | } 69 | } 70 | 71 | private createAuthorizationHeader(authInfo: AuthInfo | null, overrideAuthKey: string | null): string { 72 | const key = overrideAuthKey || authInfo?.authKey; 73 | return `X-Auth-Key ${key ?? ""}`; 74 | } 75 | 76 | private readonly createOriginQueryParameter = (commandName: string): Record => { 77 | return { 78 | origin: `APIMATIC CLI ${commandName}` 79 | }; 80 | }; 81 | 82 | private readonly handleTransformationErrors = async (error: unknown): Promise => { 83 | if (error instanceof ApiError) { 84 | const apiError = error as ApiError; 85 | if (apiError.statusCode === 400) { 86 | return "Your API Definition is invalid. Please use the APIMatic VS Code Extension to fix the errors and try again."; 87 | } else if (apiError.statusCode === 401) { 88 | const message = JSON.parse(apiError.body as string).message; 89 | return `${message} You are not authorized to perform this action. Please run 'auth:login' or provide a valid auth key.`; 90 | } 91 | return `Error ${apiError.statusCode}: An error occurred during the transformation. Please try again or contact support@apimatic.io for assistance.`; 92 | } else { 93 | return "An unexpected error occurred while validating your API Definition. Please try again later. If the problem persists, please reach out to our team at support@apimatic.io"; 94 | } 95 | }; 96 | } 97 | -------------------------------------------------------------------------------- /src/infrastructure/services/validation-service.ts: -------------------------------------------------------------------------------- 1 | import fsExtra from "fs-extra"; 2 | import { 3 | ApiResponse, 4 | ApiValidationExternalApisController, 5 | ApiValidationSummary, 6 | ContentType, 7 | FileWrapper, 8 | ApiError 9 | } from "@apimatic/sdk"; 10 | 11 | import { DirectoryPath } from "../../types/file/directoryPath.js"; 12 | import { AuthInfo, getAuthInfo } from "../../client-utils/auth-manager.js"; 13 | import { apiClientFactory } from "./api-client-factory.js"; 14 | import { err, ok, Result } from "neverthrow"; 15 | import { FilePath } from "../../types/file/filePath.js"; 16 | import { CommandMetadata } from "../../types/common/command-metadata.js"; 17 | 18 | export interface ValidateViaFileParams { 19 | file: FilePath; 20 | commandMetadata: CommandMetadata; 21 | authKey?: string | null; 22 | } 23 | 24 | export class ValidationService { 25 | constructor(private readonly configDir: DirectoryPath) {} 26 | 27 | async validateViaFile({ 28 | file, 29 | commandMetadata, 30 | authKey 31 | }: ValidateViaFileParams): Promise> { 32 | const authInfo: AuthInfo | null = await getAuthInfo(this.configDir.toString()); 33 | const authorizationHeader = this.createAuthorizationHeader(authInfo, authKey ?? null); 34 | const client = apiClientFactory.createApiClient(authorizationHeader, commandMetadata.shell); 35 | const controller = new ApiValidationExternalApisController(client); 36 | 37 | try { 38 | const fileDescriptor = new FileWrapper(fsExtra.createReadStream(file.toString())); 39 | //TODO: Update spec to include origin query parameter. 40 | const validation: ApiResponse = await controller.validateApiViaFile( 41 | ContentType.EnumMultipartformdata, 42 | fileDescriptor 43 | ); 44 | 45 | return ok(validation.result as ApiValidationSummary); 46 | } catch (error) { 47 | return err(await this.handleValidationErrors(error)); 48 | } 49 | } 50 | 51 | private createAuthorizationHeader(authInfo: AuthInfo | null, overrideAuthKey: string | null): string { 52 | const key = overrideAuthKey || authInfo?.authKey; 53 | return `X-Auth-Key ${key ?? ""}`; 54 | } 55 | 56 | private async handleValidationErrors(error: unknown): Promise { 57 | if (error instanceof ApiError) { 58 | const apiError = error as ApiError; 59 | 60 | if (apiError.statusCode === 400) { 61 | return "Your API Definition is invalid. Please fix the issues and try again."; 62 | } else if (apiError.statusCode === 401) { 63 | return "You are not authorized to perform this action. Please run 'auth:login' or provide a valid auth key."; 64 | } else if (apiError.statusCode === 403) { 65 | return "You do not have permission to perform this action."; 66 | } else if (apiError.statusCode === 500) { 67 | return "An unexpected error occurred validating the API specification, please try again later. If the problem persists, please reach out to our team at support@apimatic.io"; 68 | } 69 | 70 | return `Error ${apiError.statusCode}: An error occurred during validation.`; 71 | } 72 | 73 | return "Unexpected error occurred while validating API specification."; 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /src/infrastructure/tmp-extensions.ts: -------------------------------------------------------------------------------- 1 | import { DirectoryPath } from "../types/file/directoryPath.js"; 2 | import { withDir } from "tmp-promise"; 3 | 4 | export function withDirPath( 5 | fn: (results: DirectoryPath) => Promise, 6 | ): Promise { 7 | return withDir(results => fn(new DirectoryPath(results.path)), { unsafeCleanup: true }); 8 | } 9 | -------------------------------------------------------------------------------- /src/infrastructure/zip-service.ts: -------------------------------------------------------------------------------- 1 | import fs from "fs"; 2 | import archiver from "archiver"; 3 | import extract from "extract-zip"; 4 | import { DirectoryPath } from "../types/file/directoryPath.js"; 5 | import { FilePath } from "../types/file/filePath.js"; 6 | 7 | export class ZipService { 8 | public async archive(sourceDir: DirectoryPath, outputZipPath: FilePath): Promise { 9 | return new Promise((resolve, reject) => { 10 | const output = fs.createWriteStream(outputZipPath.toString()); 11 | const archive = archiver("zip"); 12 | 13 | output.on("close", () => resolve()); 14 | archive.on("error", (err) => reject(err)); 15 | 16 | archive.pipe(output); 17 | archive.directory(sourceDir.toString(), false); // false: don't nest under folder 18 | archive.finalize(); 19 | }); 20 | } 21 | 22 | public async unArchive(sourceFile: FilePath, destinationDirectory: DirectoryPath): Promise { 23 | const MAX_FILES = 100_000; 24 | const MAX_SIZE = 1_000_000_000; // 1 GB 25 | let fileCount = 0; 26 | let totalSize = 0; 27 | 28 | await extract(sourceFile.toString(), { 29 | dir: destinationDirectory.toString(), 30 | onEntry: function (entry) { 31 | fileCount++; 32 | if (fileCount > MAX_FILES) { 33 | throw new Error("Reached max. file count"); 34 | } 35 | // The uncompressedSize comes from the zip headers, so it might not be trustworthy. 36 | // Alternatively, calculate the size from the readStream. 37 | let entrySize = entry.uncompressedSize; 38 | totalSize += entrySize; 39 | if (totalSize > MAX_SIZE) { 40 | throw new Error("Reached max. size"); 41 | } 42 | } 43 | }); 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /src/prompts/api/transform.ts: -------------------------------------------------------------------------------- 1 | import { log, isCancel, confirm } from "@clack/prompts"; 2 | import { DirectoryPath } from "../../types/file/directoryPath.js"; 3 | import { format as f } from "../format.js"; 4 | import { Result } from "neverthrow"; 5 | import { TransformationResultData } from "../../infrastructure/services/transformation-service.js"; 6 | import { ServiceError } from "../../infrastructure/service-error.js"; 7 | import { withSpinner } from "../prompt.js"; 8 | 9 | export class ApiTransformPrompts { 10 | public async overwriteApi(directory: DirectoryPath): Promise { 11 | const overwrite = await confirm({ 12 | message: `A specification file already exists at ${f.path(directory)}. Do you want to overwrite the existing file?`, 13 | initialValue: false 14 | }); 15 | 16 | if (isCancel(overwrite)) { 17 | return false; 18 | } 19 | 20 | return overwrite; 21 | } 22 | 23 | public transformedApiAlreadyExists() { 24 | const message = `Specification already exists.`; 25 | log.error(message); 26 | } 27 | 28 | public async transformApi(fn: Promise>) { 29 | return withSpinner("Transforming API", "API transformed successfully.", "API transformation failed.", fn); 30 | } 31 | 32 | logTransformationError(error: string): void { 33 | log.error(error); 34 | } 35 | 36 | public networkError(serviceError: ServiceError): void { 37 | log.error(serviceError.errorMessage); 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/prompts/api/validate.ts: -------------------------------------------------------------------------------- 1 | import { log } from "@clack/prompts"; 2 | import { replaceHTML } from "../../utils/utils.js"; 3 | import { ValidationMessages } from "../../types/utils.js"; 4 | import { Result } from "neverthrow"; 5 | import { ApiValidationSummary } from "@apimatic/sdk"; 6 | import { ServiceError } from "../../infrastructure/service-error.js"; 7 | import { FilePath } from "../../types/file/filePath.js"; 8 | import { format as f } from "../format.js"; 9 | import { withSpinner } from "../prompt.js"; 10 | 11 | export class ApiValidatePrompts { 12 | public async validateApi(fn: Promise>) { 13 | return withSpinner("Validating API", "API validation completed", "API validation failed", fn); 14 | } 15 | 16 | displayValidationMessages({ warnings, errors, messages }: ValidationMessages): void { 17 | if (messages.length > 0) { 18 | log.info("Messages"); 19 | messages.forEach((msg) => { 20 | log.message(`${replaceHTML(msg)}`); 21 | }); 22 | } 23 | if (warnings.length > 0) { 24 | log.warning("Warnings"); 25 | warnings.forEach((war) => { 26 | log.message(`${replaceHTML(war)}`); 27 | }); 28 | } 29 | if (errors.length > 0) { 30 | log.error("Errors"); 31 | errors.forEach((err) => { 32 | log.message(`${replaceHTML(err)}`); 33 | }); 34 | } 35 | } 36 | 37 | logValidationError(error: string): void { 38 | log.error(error); 39 | } 40 | 41 | public networkError(serviceError: ServiceError): void { 42 | const message = serviceError.errorMessage; 43 | log.error(message); 44 | } 45 | 46 | public transformedApiSaved(filePath: FilePath): void { 47 | log.info(`Transformed API has been saved to ${f.path(filePath)}.`); 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /src/prompts/auth/login.ts: -------------------------------------------------------------------------------- 1 | import { log } from "@clack/prompts"; 2 | import { ServiceError } from "../../infrastructure/service-error.js"; 3 | import { SubscriptionInfo } from "../../types/api/account.js"; 4 | import { Result } from "neverthrow"; 5 | import { withSpinner } from "../prompt.js"; 6 | 7 | export class LoginPrompts { 8 | public loginSuccessful(email: string) { 9 | log.success(`Successfully logged in as ${email}`); 10 | } 11 | 12 | public openBrowser() { 13 | log.info("Please continue with authentication in the opened browser window."); 14 | } 15 | 16 | public invalidKeyProvided(serviceError: ServiceError) { 17 | const message = 18 | serviceError === ServiceError.NetworkError ? "Invalid API key provided." : serviceError.errorMessage; 19 | log.error(message); 20 | } 21 | 22 | public loginTimeout() { 23 | log.error("Authentication timed out. Please try again."); 24 | } 25 | 26 | public accountInfoSpinner(fn: Promise>) { 27 | return withSpinner( 28 | "Retrieving your subscription info", 29 | "Retrieved subscription info", 30 | "Error retrieving subscription info", 31 | fn 32 | ); 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/prompts/auth/logout.ts: -------------------------------------------------------------------------------- 1 | import { log } from "@clack/prompts"; 2 | 3 | 4 | export class LogoutPrompts { 5 | 6 | public removeAuthInfo() { 7 | log.info("Logged out successfully."); 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /src/prompts/auth/status.ts: -------------------------------------------------------------------------------- 1 | import { Result } from "neverthrow"; 2 | import { SubscriptionInfo } from "../../types/api/account.js"; 3 | import { ServiceError } from "../../infrastructure/service-error.js"; 4 | import { format } from "../format.js"; 5 | import { log } from "@clack/prompts"; 6 | import { mapLanguages } from "../../types/sdk/generate.js"; 7 | import { withSpinner } from "../prompt.js"; 8 | 9 | export class StatusPrompts { 10 | public accountInfoSpinner(fn: Promise>) { 11 | return withSpinner( 12 | "Retrieving your subscription info", 13 | "Retrieved subscription info", 14 | "Error retrieving subscription info", 15 | fn 16 | ); 17 | } 18 | 19 | public invalidKeyProvided(serviceError: ServiceError) { 20 | const message = 21 | serviceError === ServiceError.UnAuthorized ? "Invalid API key provided." : serviceError.errorMessage; 22 | log.error(message); 23 | } 24 | 25 | public showAccountInfo(info: SubscriptionInfo) { 26 | const languages = mapLanguages(info.allowedLanguages); 27 | const message = `Account Information: 28 | Email: ${format.var(info.Email)} 29 | Allowed Languages: ${languages.map((language) => format.var(language)).join(", ")}`; 30 | log.info(`${message}`); 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/prompts/format.ts: -------------------------------------------------------------------------------- 1 | import pc from "picocolors"; 2 | import { intro as i, outro as o } from '@clack/prompts'; 3 | import { ActionResult } from "../actions/action-result.js"; 4 | import { Directory } from "../types/file/directory.js"; 5 | import { DirectoryPath } from "../types/file/directoryPath.js"; 6 | import { FilePath } from "../types/file/filePath.js"; 7 | 8 | export const format = { 9 | // Core element types 10 | var: (text: string) => pc.magenta(`'${text}'`), 11 | path: (text: DirectoryPath | FilePath) => pc.cyan(`'${text}'`), 12 | cmd: (cmd: string, ...args: string[]) => `${pc.blueBright(cmd)} ${args.map(arg => pc.dim(arg)).join(" ")}`, 13 | cmdAlt: (cmd: string, ...args: string[]) => `${pc.dim(pc.blueBright(cmd))} ${args.map(arg => pc.blueBright(arg)).join(" ")}`, 14 | link: (text: string) => pc.underline(pc.blueBright(text)), 15 | description: (text: string) => pc.greenBright(`${text}`), 16 | flag: (name: string, value: string | undefined = undefined) => { 17 | if (value) { 18 | const sanitizedValue = value.includes(" ") ? `'${value}'` : value; 19 | return `${pc.green(`--${name}`)}=${pc.dim(sanitizedValue)}`; 20 | } 21 | return `${pc.green(`--${name}`)}`; 22 | }, 23 | 24 | // Common message styles 25 | success: (text: string) => pc.green(text), 26 | error: (text: string) => pc.red(text), 27 | info: (text: string) => pc.cyan(text), 28 | 29 | intro: (text: string) => pc.bgCyan(text), 30 | outroSuccess: (text: string) => pc.bgGreen(text), 31 | outroFailure: (text: string) => pc.bgRed(text), 32 | outroCancelled: (text: string) => pc.bgWhite(pc.blackBright(text)), 33 | }; 34 | 35 | export function intro(text: string) { 36 | i(format.intro(` ${text} `)); 37 | } 38 | 39 | export function outro(result: ActionResult) { 40 | const exitCode = result.getExitCode(); 41 | const message = result.getMessage(); 42 | const outroMessage = result.mapAll( 43 | () => format.outroSuccess(message), 44 | () => format.outroFailure(message), 45 | () => format.outroCancelled(message) 46 | ); 47 | o(outroMessage); 48 | process.exitCode = exitCode; 49 | } 50 | 51 | export function getDirectoryTree(dir: Directory, prefix: string = "", isLast: boolean = true): string { 52 | const folderDescription: Record = { 53 | spec: "# Contains all API definition files", 54 | content: "# Includes custom documentation pages in Markdown", 55 | static: "# Includes all static files, such as images, GIFs, and PDFs" 56 | }; 57 | 58 | const fileDescriptions: Record = { 59 | "toc.yml": "# Controls the structure of the side navigation bar in the API portal", 60 | "APIMATIC-BUILD.json": "# Defines all configurations for the API portal, including programming languages and themes" 61 | }; 62 | 63 | const pointer = isLast ? "└─ " : "├─ "; 64 | const folderName = dir.directoryPath.leafName(); 65 | const description = folderDescription[folderName] ? format.description(folderDescription[folderName]) : ""; 66 | let output = `${prefix}${pointer}${folderName}${description ? " " + description : ""}\n`; 67 | 68 | const items = dir.items; 69 | const newPrefix = prefix + (isLast ? " " : "| "); 70 | 71 | items.forEach((item, index) => { 72 | const last = index === items.length - 1; 73 | 74 | if (item instanceof Directory) { 75 | output += getDirectoryTree(item, newPrefix, last); 76 | } else { 77 | const filePointer = last ? "└─ " : "├─ "; 78 | const fileName = item.toString(); 79 | const fileDescription = fileDescriptions[fileName] ? format.description(fileDescriptions[fileName]) : ""; 80 | output += `${newPrefix}${filePointer}${fileName}${fileDescription ? " " + fileDescription : ""}\n`; 81 | } 82 | }); 83 | 84 | return output; 85 | } 86 | 87 | 88 | export interface LeafNode { 89 | name: string; 90 | description?: string; 91 | } 92 | 93 | export interface TreeNode extends LeafNode { 94 | items: Array; 95 | } 96 | 97 | 98 | export function getTree( 99 | dir: TreeNode, 100 | prefix: string = "", 101 | isLast: boolean = true 102 | ): string { 103 | const pointer = isLast ? "└─ " : "├─ "; 104 | const folderName = dir.name; 105 | const description = dir.description ? format.description(dir.description) : ""; 106 | 107 | let output = `${prefix}${pointer}${folderName}${description ? " " + description : ""}\n`; 108 | 109 | const items = dir.items; 110 | const newPrefix = prefix + (isLast ? " " : "| "); 111 | 112 | items.forEach((item, index) => { 113 | const last = index === items.length - 1; 114 | 115 | if ('items' in item) { 116 | output += getTree(item as TreeNode, newPrefix, last); 117 | } else { 118 | const filePointer = last ? "└─ " : "├─ "; 119 | const fileName = item.name; 120 | const fileDescription = item.description ? format.description(item.description) : ""; 121 | output += `${newPrefix}${filePointer}${fileName}${fileDescription ? " " + fileDescription : ""}\n`; 122 | } 123 | }); 124 | 125 | return output; 126 | } 127 | 128 | 129 | -------------------------------------------------------------------------------- /src/prompts/portal/copilot.ts: -------------------------------------------------------------------------------- 1 | import { confirm, isCancel, log, select } from "@clack/prompts"; 2 | import { Result } from "neverthrow"; 3 | import { SubscriptionInfo } from "../../types/api/account.js"; 4 | import { ServiceError } from "../../infrastructure/service-error.js"; 5 | import { DirectoryPath } from "../../types/file/directoryPath.js"; 6 | import { format as f } from "../format.js"; 7 | import { noteWrapped, withSpinner } from "../prompt.js"; 8 | 9 | export class PortalCopilotPrompts { 10 | public async displayApiCopilotKeyUsageWarning() { 11 | log.warn( 12 | "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." 13 | ); 14 | } 15 | 16 | public openWelcomeMessageEditor() { 17 | log.step("Opening markdown editor for you to enter welcome message in..."); 18 | } 19 | 20 | public async selectCopilotKey(keys: string[]): Promise { 21 | const selectedKey = await select({ 22 | message: "Select the ID for the API Copilot you would like to add to this API Portal:", 23 | maxItems: 10, 24 | options: keys.map((key) => ({ 25 | value: key, 26 | label: key 27 | })) 28 | }); 29 | 30 | if (isCancel(selectedKey)) { 31 | return null; 32 | } 33 | 34 | return selectedKey; 35 | } 36 | 37 | public copilotConfigured(status: boolean, copilotId: string): void { 38 | log.info( 39 | `API Copilot configured successfully! 40 | 41 | Copilot ID: ${f.var(copilotId)} 42 | Status: ${f.var(status ? "Enabled" : "Disabled")} 43 | 44 | Configuration saved to: ${f.var("APIMATIC-BUILD.json")}` 45 | ); 46 | 47 | noteWrapped(`API Copilot will index your content the next time you run 48 | '${f.cmdAlt("apimatic", "portal", "generate")}' or '${f.cmdAlt("apimatic", "portal", "serve")}'. 49 | This process can take up to 10 minutes, depending on your API’s size. 50 | 51 | To see your copilot: If your portal is already running, refresh the page. 52 | Otherwise, run '${f.cmdAlt("apimatic", "portal", "serve")}', 53 | select any programming language in the Portal and 54 | look for the chat icon in the bottom-right corner.`, "Next Steps"); 55 | } 56 | 57 | public async confirmOverwrite(): Promise { 58 | const shouldOverwrite = await confirm({ 59 | message: "API Copilot is already configured for this Portal, do you want to overwrite it?", 60 | initialValue: false 61 | }); 62 | 63 | if (isCancel(shouldOverwrite)) { 64 | return false; 65 | } 66 | 67 | return shouldOverwrite; 68 | } 69 | 70 | public async spinnerAccountInfo(fn: Promise>) { 71 | return withSpinner( 72 | "Retrieving your subscription info", 73 | "Subscription info retrieved", 74 | "Subscription info retrieval failed", 75 | fn 76 | ); 77 | } 78 | 79 | public async confirmSingleKeyUsage(apiCopilotKey: string) { 80 | const confirmKeyUsage = await confirm({ 81 | message: 82 | "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" + 83 | `Do you want to use this key: ${f.var(apiCopilotKey)}?`, 84 | initialValue: true 85 | }); 86 | 87 | if (isCancel(confirmKeyUsage)) { 88 | return false; 89 | } 90 | 91 | return confirmKeyUsage; 92 | } 93 | 94 | public srcDirectoryEmpty(directory: DirectoryPath) { 95 | const message = `The ${f.var("src")} directory is either empty or invalid: ${f.path(directory)}`; 96 | log.error(message); 97 | } 98 | 99 | public cancelled() { 100 | log.warning("Exiting without making any change."); 101 | } 102 | 103 | public serviceError(serviceError: ServiceError) { 104 | log.error(serviceError.errorMessage); 105 | } 106 | 107 | public noCopilotKeyFound() { 108 | log.error( 109 | `No copilot key found for the current subscription. Please contact support at ${f.var("support@apimatic.io")}.` 110 | ); 111 | } 112 | 113 | public noCopilotKeySelected() { 114 | log.error("No API Copilot key was selected."); 115 | } 116 | } 117 | -------------------------------------------------------------------------------- /src/prompts/portal/generate.ts: -------------------------------------------------------------------------------- 1 | import { isCancel, confirm, log } from "@clack/prompts"; 2 | import { DirectoryPath } from "../../types/file/directoryPath.js"; 3 | import { format as f } from "../format.js"; 4 | import { Result } from "neverthrow"; 5 | import { FilePath } from "../../types/file/filePath.js"; 6 | import {ServiceError } from "../../infrastructure/service-error.js"; 7 | import { withSpinner } from "../prompt.js"; 8 | 9 | export class PortalGeneratePrompts { 10 | public async overwritePortal(directory: DirectoryPath): Promise { 11 | const overwrite = await confirm({ 12 | message: `The destination ${f.path(directory)} is not empty, do you want to overwrite?`, 13 | initialValue: false 14 | }); 15 | 16 | if (isCancel(overwrite)) { 17 | return false; 18 | } 19 | 20 | return overwrite; 21 | } 22 | 23 | public directoryCannotBeSame(directory: DirectoryPath) { 24 | const message = `The ${f.var("src")} and ${f.var("portal")} directories must be different. Current value: ${f.path( 25 | directory 26 | )}`; 27 | log.error(message); 28 | } 29 | 30 | public srcDirectoryEmpty(directory: DirectoryPath) { 31 | const message = `The ${f.var("src")} directory is either empty or invalid: ${f.path(directory)}`; 32 | log.error(message); 33 | } 34 | 35 | public portalDirectoryNotEmpty() { 36 | const message = `Please enter a different destination folder or remove the existing files and try again.`; 37 | log.error(message); 38 | } 39 | 40 | public generatePortal(fn: Promise>) { 41 | return withSpinner("Generating portal", "Portal generated successfully.", "Portal Generation failed.", fn); 42 | } 43 | 44 | public portalGenerationError(error: string) { 45 | log.error(error); 46 | } 47 | 48 | public portalGenerationServiceError(serviceError: ServiceError) { 49 | log.error(serviceError.errorMessage); 50 | } 51 | 52 | public portalGenerationErrorWithReport(reportPath: FilePath) { 53 | const message = `An error occurred during portal generation. 54 | A report has been written at the destination path ${f.path(reportPath)}`; 55 | log.error(message); 56 | } 57 | 58 | public portalGenerated(portal: DirectoryPath) { 59 | log.info(`Portal artifacts can be found at ${f.path(portal)}.`); 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /src/prompts/portal/serve.ts: -------------------------------------------------------------------------------- 1 | import { log } from "@clack/prompts"; 2 | import { format as f } from "../format.js"; 3 | import { UrlPath } from "../../types/file/urlPath.js"; 4 | import { once } from "events"; 5 | import { DirectoryPath } from "../../types/file/directoryPath.js"; 6 | import { noteWrapped } from "../prompt.js"; 7 | 8 | export class PortalServePrompts { 9 | public usingFallbackPort(currentPort: number, availablePort: number) { 10 | const message = `Port ${f.var(currentPort.toString())} is already in use. Available port ${f.var( 11 | availablePort.toString() 12 | )} will be used.`; 13 | log.step(message); 14 | } 15 | 16 | public portalServed(urlPath: UrlPath) { 17 | const message = `The portal is running at ${f.link(urlPath.toString())}`; 18 | log.message(message); 19 | } 20 | 21 | public promptForExit() { 22 | const message = "Press CTRL+C to stop the server."; 23 | log.message(message); 24 | } 25 | 26 | public changesDetected() { 27 | const message = "Changes detected..."; 28 | log.info(message); 29 | } 30 | 31 | public watcherError() { 32 | 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 ${f.var( 33 | "support@apimatic.io" 34 | )}`; 35 | log.error(message); 36 | } 37 | 38 | public async blockExecution() { 39 | await Promise.race([once(process, "SIGINT"), once(process, "SIGTERM")]); 40 | } 41 | 42 | public hotReloadEnabled(srcDirectory: DirectoryPath) { 43 | noteWrapped( 44 | `Hot reload is enabled. 45 | 46 | Watching the directory ${f.path(srcDirectory)} for any changes`, 47 | `Note` 48 | ); 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /src/prompts/portal/toc/new-toc.ts: -------------------------------------------------------------------------------- 1 | import { confirm, isCancel, log } from "@clack/prompts"; 2 | import { FilePath } from "../../../types/file/filePath.js"; 3 | import { Result } from "neverthrow"; 4 | import { format as f } from "../../format.js"; 5 | import { DirectoryPath } from "../../../types/file/directoryPath.js"; 6 | import { ServiceError } from "../../../infrastructure/service-error.js"; 7 | import { Sdl } from "../../../types/sdl/sdl.js"; 8 | import { withSpinner } from "../../prompt.js"; 9 | 10 | export class PortalNewTocPrompts { 11 | public async overwriteToc(tocPath: FilePath): Promise { 12 | const overwrite = await confirm({ 13 | message: `The destination file ${f.path(tocPath)} already exists, do you want to overwrite it?`, 14 | initialValue: false 15 | }); 16 | 17 | if (isCancel(overwrite)) { 18 | return false; 19 | } 20 | 21 | return overwrite; 22 | } 23 | 24 | public fallingBackToDefault() { 25 | log.warn(`Falling back to the default TOC structure.`); 26 | } 27 | 28 | public tocFileAlreadyExists() { 29 | log.error(`Please enter a different destination path or delete the existing toc.yml file and try again.`); 30 | } 31 | 32 | public logError(message: string) { 33 | log.error(message); 34 | } 35 | 36 | public contentDirectoryNotFound(contentFolderPath: DirectoryPath) { 37 | log.error(`Content folder not found at: ${contentFolderPath}`); 38 | } 39 | 40 | public invalidBuildDirectory(directory: DirectoryPath) { 41 | const message = `The ${f.var("src")} directory is either empty or invalid: ${f.path(directory)}`; 42 | log.error(message); 43 | } 44 | 45 | public extractEndpointGroupsAndModels(fn: Promise>) { 46 | return withSpinner( 47 | "Extracting endpoint groups and models", 48 | "Endpoint groups and models extracted", 49 | "Endpoint groups and models extraction failed", 50 | fn 51 | ); 52 | } 53 | 54 | public tocCreated(tocPath: FilePath) { 55 | log.info(`The TOC file successfully created at: ${f.path(tocPath)}`); 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /src/prompts/prompt.ts: -------------------------------------------------------------------------------- 1 | import type { Writable } from "node:stream"; 2 | import pc from "picocolors"; 3 | import { getColumns } from "@clack/core"; 4 | import { log, note, NoteOptions, S_BAR_H, S_CONNECT_LEFT, spinner } from "@clack/prompts"; 5 | import { Result } from "neverthrow"; 6 | import { stripAnsi } from "../utils/string-utils.js"; 7 | 8 | export async function withSpinner(intro: string, success: string, failure: string, fn: Promise>) { 9 | const s = spinner({ 10 | cancelMessage: "cancelled", 11 | errorMessage: "failed", 12 | frames: ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"] 13 | }); 14 | s.start(intro); 15 | const result = await fn; 16 | result.match( 17 | () => s.stop(success, 0), 18 | () => s.stop(failure, 1) 19 | ); 20 | return result; 21 | } 22 | 23 | export const noteWrapped = (message: string, title: string) => { 24 | const output: Writable = process.stdout; 25 | const columns = getColumns(output) || 80; 26 | const messages = message.split("\n"); 27 | const messageHasOverFlow = messages.some((msg) => { 28 | const clean = stripAnsi(msg); 29 | return clean.length + 6 > columns; 30 | }); 31 | if (messageHasOverFlow) { 32 | const startLine = S_BAR_H.repeat(columns - title.length - 4); 33 | log.step(`${title} ${pc.gray(startLine)}`); 34 | log.message(message); 35 | output.write(pc.gray(S_CONNECT_LEFT + S_BAR_H.repeat(columns - 1)) + "\n"); 36 | } else { 37 | const opts: NoteOptions = { 38 | format: (line) => line 39 | }; 40 | note(message, title, opts); 41 | } 42 | }; 43 | -------------------------------------------------------------------------------- /src/prompts/quickstart.ts: -------------------------------------------------------------------------------- 1 | import { isCancel, log, select } from "@clack/prompts"; 2 | 3 | export type QuickstartFlow = "sdk" | "portal" | undefined; 4 | 5 | export class QuickstartPrompts { 6 | public welcomeMessage() { 7 | log.info(`Welcome to the APIMatic quickstart wizard.`); 8 | log.message(`This wizard will guide you through creating your first SDK or API Documentation Portal in just four easy steps. 9 | Let's get started!`); 10 | } 11 | 12 | public async selectQuickstartFlow(): Promise { 13 | const option = await select({ 14 | message: "What would you like to create?", 15 | options: [ 16 | { value: "portal", label: "API Documentation Portal", hint: "Generate API docs + SDKs" }, 17 | { value: "sdk", label: "SDK" } 18 | ] 19 | }); 20 | 21 | if (isCancel(option)) { 22 | return undefined; 23 | } 24 | 25 | return option; 26 | } 27 | 28 | public noQuickstartFlowSelected() { 29 | log.error("No option was selected."); 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/prompts/sdk/generate.ts: -------------------------------------------------------------------------------- 1 | import { isCancel, confirm, log } from "@clack/prompts"; 2 | import { DirectoryPath } from "../../types/file/directoryPath.js"; 3 | import { format as f, } from "../format.js"; 4 | import { Result } from "neverthrow"; 5 | import { withSpinner } from "../prompt.js"; 6 | 7 | export class SdkGeneratePrompts { 8 | public async overwriteSdk(directory: DirectoryPath): Promise { 9 | const overwrite = await confirm({ 10 | message: `The destination ${f.path(directory)} is not empty, do you want to overwrite?`, 11 | initialValue: false 12 | }); 13 | 14 | if (isCancel(overwrite)) { 15 | return false; 16 | } 17 | 18 | return overwrite; 19 | } 20 | 21 | public sameSpecAndSdkDir(directory: DirectoryPath) { 22 | const message = `The ${f.var("src")} and ${f.var("portal")} directories must be different. Current value: ${f.path( 23 | directory 24 | )}`; this.logGenerationError(message); 25 | } 26 | 27 | public invalidSpecDirectory(directory: DirectoryPath) { 28 | const message = `The ${f.var("src")} directory is either empty or invalid: ${f.path(directory)}`; 29 | this.logGenerationError(message); 30 | } 31 | 32 | public destinationDirNotEmpty() { 33 | const message = `Please enter a different destination folder or remove the existing files and try again.`; 34 | this.logGenerationError(message); 35 | } 36 | 37 | public generateSDK(fn: Promise>) { 38 | return withSpinner("Generating SDK", "SDK generated successfully.", "SDK Generation failed.", fn); 39 | } 40 | 41 | public logGenerationError(error: string): void { 42 | log.error(error); 43 | } 44 | 45 | public sdkGenerated(sdk: DirectoryPath) { 46 | log.info(`Generated SDK can be found at ${f.path(sdk)}.`); 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /src/types/api/account.ts: -------------------------------------------------------------------------------- 1 | export interface SubscriptionInfo { 2 | Id: string; 3 | Email: string; 4 | FullName: string; 5 | SecurityStamp: string; 6 | tenantId: string; 7 | allowedLanguages: number; // might be a comma-separated string or code — clarify if needed 8 | isPackagePublishingAllowed: boolean; 9 | ApiCopilotKeys: string[]; 10 | } 11 | -------------------------------------------------------------------------------- /src/types/api/transform.ts: -------------------------------------------------------------------------------- 1 | 2 | export type TransformationData = { 3 | result: NodeJS.ReadableStream | Blob; 4 | }; 5 | 6 | export const DestinationFormats = { 7 | OpenApi3Json: "json", 8 | OpenApi3Yaml: "yaml", 9 | APIMATIC: "json", 10 | WADL2009: "xml", 11 | WSDL: "xml", 12 | Swagger10: "json", 13 | Swagger20: "json", 14 | SwaggerYaml: "yaml", 15 | RAML: "raml", 16 | RAML10: "raml", 17 | Postman10: "json", 18 | Postman20: "json", 19 | GraphQlSchema: "json" 20 | }; 21 | 22 | export enum TransformationFormats { 23 | apimatic = 'Apimatic', 24 | wadl2009 = 'Wadl2009', 25 | wsdl = 'Wsdl', 26 | swagger10 = 'Swagger10', 27 | swagger20 = 'Swagger20', 28 | swaggeryaml = 'Swaggeryaml', 29 | oas3 = 'Oas3', 30 | openapi3yaml = 'Openapi3Yaml', 31 | apiblueprint = 'Apiblueprint', 32 | raml = 'Raml', 33 | raml10 = 'Raml10', 34 | postman10 = 'Postman10', 35 | postman20 = 'Postman20', 36 | graphqlschema = 'Graphqlschema', 37 | } 38 | -------------------------------------------------------------------------------- /src/types/build-context.ts: -------------------------------------------------------------------------------- 1 | import { FileService } from "../infrastructure/file-service.js"; 2 | import { DirectoryPath } from "./file/directoryPath.js"; 3 | import { FilePath } from "./file/filePath.js"; 4 | import { FileName } from "./file/fileName.js"; 5 | import { BuildConfig } from "./build/build.js"; 6 | 7 | export class BuildContext { 8 | private readonly fileService = new FileService(); 9 | private readonly buildDirectory: DirectoryPath; 10 | 11 | constructor(buildDirectory: DirectoryPath) { 12 | this.buildDirectory = buildDirectory; 13 | } 14 | 15 | private get buildFile(): FilePath { 16 | // TODO: add checks for build file path 17 | return new FilePath(this.buildDirectory, new FileName("APIMATIC-BUILD.json")); 18 | } 19 | 20 | public async validate(): Promise { 21 | // TODO: add more checks here 22 | if (!(await this.fileService.directoryExists(this.buildDirectory))) return false; 23 | 24 | return await this.fileService.fileExists(this.buildFile); 25 | } 26 | 27 | public async exists(): Promise { 28 | return !(await this.fileService.directoryEmpty(this.buildDirectory)); 29 | } 30 | 31 | public async getBuildFileContents(): Promise { 32 | const buildFileContent = await this.fileService.getContents(this.buildFile); 33 | return JSON.parse(buildFileContent) as BuildConfig; 34 | } 35 | 36 | public async updateBuildFileContents(buildJson: BuildConfig) { 37 | await this.fileService.writeContents(this.buildFile, JSON.stringify(buildJson, null, 2)); 38 | } 39 | 40 | public async deleteWorkflowDir() { 41 | await this.fileService.deleteDirectory(this.buildDirectory.join(".github")); 42 | } 43 | } 44 | 45 | -------------------------------------------------------------------------------- /src/types/build/build.ts: -------------------------------------------------------------------------------- 1 | import { DirectoryPath } from "../file/directoryPath.js"; 2 | 3 | export interface BuildConfig { 4 | generatePortal?: PortalConfig; 5 | apiCopilotConfig?: CopilotConfig; 6 | [key: string]: unknown; 7 | } 8 | 9 | export interface PortalConfig { 10 | contentFolder?: string; 11 | languageConfig: { [key: string]: object }; 12 | [key: string]: unknown; 13 | apiSpecPath?: DirectoryPath; 14 | } 15 | 16 | export interface CopilotConfig { 17 | isEnabled: boolean; 18 | key: string; 19 | welcomeMessage: string; 20 | } 21 | 22 | export function getLanguagesConfig(selectedLanguages: string[]) { 23 | return selectedLanguages.reduce((config, lang) => { 24 | config[lang] = {}; 25 | return config; 26 | }, {} as { [key: string]: object }); 27 | } 28 | -------------------------------------------------------------------------------- /src/types/common/command-metadata.ts: -------------------------------------------------------------------------------- 1 | export interface CommandMetadata { 2 | readonly commandName: string; 3 | readonly shell: string; 4 | } 5 | -------------------------------------------------------------------------------- /src/types/events/domain-event.ts: -------------------------------------------------------------------------------- 1 | export abstract class DomainEvent { 2 | protected abstract readonly eventName: string; 3 | private readonly message: string; 4 | private readonly commandName: string; 5 | private readonly flags: string[]; 6 | 7 | protected constructor(message: string, commandName: string, flags: Record) { 8 | this.message = message; 9 | this.commandName = commandName; 10 | this.flags = this.extractFlagKeys(flags); 11 | } 12 | 13 | private extractFlagKeys(flags: Record): string[] { 14 | return Object.keys(flags); 15 | } 16 | } 17 | 18 | -------------------------------------------------------------------------------- /src/types/events/quickstart-completed.ts: -------------------------------------------------------------------------------- 1 | import { DomainEvent } from "./domain-event.js"; 2 | 3 | export class QuickstartCompletedEvent extends DomainEvent { 4 | protected readonly eventName = QuickstartCompletedEvent.name; 5 | private static readonly message = "Quickstart completed." as const; 6 | private static readonly commandName = "quickstart" as const; 7 | 8 | constructor() { 9 | super(QuickstartCompletedEvent.message, QuickstartCompletedEvent.commandName, {}); 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /src/types/events/quickstart-initiated.ts: -------------------------------------------------------------------------------- 1 | import { DomainEvent } from "./domain-event.js"; 2 | 3 | export class QuickstartInitiatedEvent extends DomainEvent { 4 | protected readonly eventName = QuickstartInitiatedEvent.name; 5 | private static readonly message = "Quickstart initiated." as const; 6 | private static readonly commandName = "quickstart" as const; 7 | 8 | constructor() { 9 | super(QuickstartInitiatedEvent.message, QuickstartInitiatedEvent.commandName, {}); 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /src/types/events/recipe-creation-failed.ts: -------------------------------------------------------------------------------- 1 | import { DomainEvent } from "./domain-event.js"; 2 | 3 | export class RecipeCreationFailedEvent extends DomainEvent { 4 | protected readonly eventName = RecipeCreationFailedEvent.name; 5 | 6 | constructor(message: string, commandName: string, flags: Record) { 7 | super(message, commandName, flags); 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /src/types/events/toc-creation-failed.ts: -------------------------------------------------------------------------------- 1 | import { DomainEvent } from "./domain-event.js"; 2 | 3 | export class TocCreationFailedEvent extends DomainEvent { 4 | protected readonly eventName = TocCreationFailedEvent.name; 5 | 6 | constructor(message: string, commandName: string, flags: Record) { 7 | super(message, commandName, flags); 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /src/types/file/directory.ts: -------------------------------------------------------------------------------- 1 | import { DirectoryPath } from "./directoryPath.js"; 2 | import { FileName } from "./fileName.js"; 3 | import { TocCustomPage, TocGroup } from "../toc/toc.js"; 4 | import { FilePath } from "./filePath.js"; 5 | import { TreeNode } from "../../prompts/format.js"; 6 | import { FileService } from "../../infrastructure/file-service.js"; 7 | 8 | export type DirectoryItem = FileName | Directory; 9 | 10 | export class Directory { 11 | public readonly directoryPath: DirectoryPath; 12 | public readonly items: DirectoryItem[]; 13 | private readonly fileService = new FileService(); 14 | 15 | public constructor(directoryPath: DirectoryPath, filePaths: DirectoryItem[]) { 16 | this.directoryPath = directoryPath; 17 | this.items = filePaths; 18 | } 19 | 20 | private static readonly folderDescriptions: Record = { 21 | spec: "# Contains all API definition files", 22 | content: "# Includes custom documentation pages in Markdown", 23 | static: "# Includes all static files, such as images, GIFs, and PDFs" 24 | }; 25 | 26 | private static readonly fileDescriptions: Record = { 27 | "toc.yml": "# Controls the structure of the side navigation bar in the API portal", 28 | "APIMATIC-BUILD.json": 29 | "# Defines all configurations for the API portal, including programming languages and themes", 30 | "APIMATIC-META.json": "# Defines customization for SDK generation", 31 | }; 32 | 33 | public toTreeNode(): TreeNode { 34 | const folderName = this.directoryPath.leafName(); 35 | const folderDescription = Directory.folderDescriptions[folderName]; 36 | 37 | return { 38 | name: folderName, 39 | description: folderDescription, 40 | items: this.items.map((item) => { 41 | if (item instanceof Directory) { 42 | return item.toTreeNode(); 43 | } 44 | 45 | // file case 46 | const fileName = item.toString(); 47 | const fileDescription = Directory.fileDescriptions[fileName]; 48 | return { 49 | name: fileName, 50 | description: fileDescription 51 | }; 52 | }) 53 | }; 54 | } 55 | 56 | public async parseContentFolder(baseContentPath: DirectoryPath): Promise { 57 | const groups: TocGroup[] = []; 58 | const pages: TocCustomPage[] = []; 59 | 60 | for (const item of this.items) { 61 | if (item instanceof Directory) { 62 | const subGroups = await item.parseContentFolder(baseContentPath); 63 | 64 | if (subGroups.length > 0) { 65 | const directoryName = item.directoryPath.leafName(); 66 | groups.push({ 67 | group: directoryName, 68 | items: subGroups 69 | }); 70 | } 71 | } else { 72 | if (item.isMarkDown()) { 73 | const currentFilePath = new FilePath(this.directoryPath, item); 74 | const relativeFilePath = this.fileService.getRelativePath(currentFilePath, baseContentPath); 75 | 76 | pages.push({ 77 | page: this.getPageName(item), 78 | file: relativeFilePath 79 | }); 80 | } 81 | } 82 | } 83 | 84 | const allItems: (TocGroup | TocCustomPage)[] = [...pages, ...groups]; 85 | 86 | if (allItems.length === 0) { 87 | return []; 88 | } 89 | 90 | if (this.isRootContentDirectory(baseContentPath)) { 91 | return [ 92 | { 93 | group: "Custom Content", 94 | items: allItems 95 | } 96 | ]; 97 | } 98 | 99 | // For subdirectories, return the items directly 100 | return allItems as TocGroup[]; 101 | } 102 | 103 | private getPageName(fileName: FileName): string { 104 | const fileNameStr = fileName.toString(); 105 | return fileNameStr.replace(/\.md$/, ""); 106 | } 107 | 108 | private isRootContentDirectory(baseContentPath: DirectoryPath): boolean { 109 | return this.directoryPath.toString() === baseContentPath.toString(); 110 | } 111 | } 112 | -------------------------------------------------------------------------------- /src/types/file/directoryPath.ts: -------------------------------------------------------------------------------- 1 | import * as path from "path"; 2 | 3 | 4 | export class DirectoryPath { 5 | private readonly directoryPath: string; 6 | 7 | constructor(directoryPath: string, ...subPaths: string[]) { 8 | this.directoryPath = path.resolve(directoryPath, ...subPaths); 9 | } 10 | 11 | public static default = new DirectoryPath("./"); 12 | 13 | public static createInput(input: string | undefined) { 14 | if (!input) { 15 | return DirectoryPath.default; 16 | } 17 | return new DirectoryPath(input); 18 | } 19 | 20 | public toString(): string { 21 | return this.directoryPath; 22 | } 23 | 24 | public join(...subPath: string[]) { 25 | return new DirectoryPath(path.join(this.directoryPath, ...subPath)); 26 | } 27 | 28 | public isEqual(other: DirectoryPath) { 29 | return this.directoryPath === other.directoryPath; 30 | } 31 | public leafName() { 32 | return path.basename(this.directoryPath); 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/types/file/fileName.ts: -------------------------------------------------------------------------------- 1 | export class FileName { 2 | private readonly name: string; 3 | 4 | constructor(name: string) { 5 | this.name = name; 6 | } 7 | 8 | public isMarkDown() { 9 | return this.name.endsWith(".md"); 10 | } 11 | 12 | public normalize(): FileName { 13 | const nameWithoutExt = this.name.replace(/\.[^/.]+$/, ""); 14 | const normalized = nameWithoutExt 15 | .toLowerCase() 16 | .replace(/[^a-z0-9-]/g, "-") 17 | .replace(/-+/g, "-") 18 | .replace(/(^-|-$)/g, ""); 19 | return new FileName(normalized); 20 | } 21 | 22 | public toString(): string { 23 | return this.name; 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/types/file/filePath.ts: -------------------------------------------------------------------------------- 1 | import path from "path"; 2 | import { FileName } from "./fileName.js"; 3 | import { DirectoryPath } from "./directoryPath.js"; 4 | 5 | export class FilePath { 6 | private readonly fileName: FileName; 7 | private readonly directoryPath: DirectoryPath; 8 | 9 | constructor(path: DirectoryPath, name: FileName) { 10 | this.fileName = name; 11 | this.directoryPath = path; 12 | } 13 | 14 | public replaceDirectory(newDirectory: DirectoryPath): FilePath { 15 | return new FilePath(newDirectory, this.fileName); 16 | } 17 | 18 | public toString(): string { 19 | return path.join(this.directoryPath.toString(), this.fileName.toString()); 20 | } 21 | 22 | public static create(filePath: string): FilePath | undefined { 23 | if (!filePath) { 24 | return undefined; 25 | } 26 | 27 | try { 28 | const normalizedPath = path.normalize(filePath); 29 | const directory = path.dirname(normalizedPath); 30 | const filename = path.basename(normalizedPath); 31 | const directoryPath = new DirectoryPath(directory); 32 | const fileName = new FileName(filename); 33 | return new FilePath(directoryPath, fileName); 34 | } catch { 35 | return undefined; 36 | } 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/types/file/resource-input.ts: -------------------------------------------------------------------------------- 1 | import path from "path"; 2 | import { DirectoryPath } from "./directoryPath.js"; 3 | import { FileName } from "./fileName.js"; 4 | import { FilePath } from "./filePath.js"; 5 | import { UrlPath } from "./urlPath.js"; 6 | import { removeQuotes } from "../../utils/string-utils.js"; 7 | 8 | export type ResourceInput = FilePath | UrlPath; 9 | 10 | // Factory function to create the discriminated union 11 | export const createResourceInput = (file?: string, url?: string): ResourceInput => { 12 | if (file && url) { 13 | throw new Error("Cannot specify both file and url. Please provide only one."); 14 | } 15 | if (!file && !url) { 16 | throw new Error("Must specify either file or url."); 17 | } 18 | 19 | if (file) { 20 | if (!file.trim()) { 21 | throw new Error("Invalid file path provided."); 22 | } 23 | return new FilePath(new DirectoryPath(path.dirname(file)), new FileName(path.basename(file))); 24 | } 25 | if (url) { 26 | if (!url.trim()) { 27 | throw new Error("Invalid URL provided."); 28 | } 29 | return new UrlPath(url); 30 | } 31 | throw new Error("Must specify either file or url."); 32 | }; 33 | 34 | export function createResourceInputFromInput(path: string): ResourceInput | undefined { 35 | const sanitizedPath = removeQuotes(path.trim() ?? "") 36 | const urlPath = UrlPath.create(sanitizedPath); 37 | if (urlPath) { 38 | return urlPath; 39 | } 40 | const filePath = FilePath.create(sanitizedPath); 41 | if (filePath) { 42 | return filePath; 43 | } 44 | return undefined; 45 | } 46 | -------------------------------------------------------------------------------- /src/types/file/urlPath.ts: -------------------------------------------------------------------------------- 1 | import { URL } from "url"; 2 | 3 | export class UrlPath { 4 | private readonly url: string; 5 | 6 | constructor(url: string) { 7 | this.url = url; 8 | } 9 | 10 | public static create(url: string): UrlPath | undefined { 11 | try { 12 | const parsed = new URL(url); 13 | if (["http:", "https:"].includes(parsed.protocol)) { 14 | return new UrlPath(url); 15 | } 16 | } catch { 17 | // Not a valid URL 18 | } 19 | return undefined; 20 | } 21 | 22 | public toString(): string { 23 | return this.url; 24 | } 25 | } -------------------------------------------------------------------------------- /src/types/flags-provider.ts: -------------------------------------------------------------------------------- 1 | import { Flags } from "@oclif/core"; 2 | 3 | export class FlagsProvider { 4 | private static readonly inputFlagName: string = "input" as const; 5 | // Common folder flag group 6 | public static input = { 7 | [FlagsProvider.inputFlagName]: Flags.string({ 8 | char: "i", 9 | description: 10 | "[default: ./] path to the parent directory containing the 'src' directory, which includes API specifications and configuration files." 11 | }) 12 | }; 13 | 14 | public static destination(artifact: string, artifactName: string) { 15 | return { 16 | destination: Flags.string({ 17 | char: "d", 18 | description: `[default: <${FlagsProvider.inputFlagName}>/${artifact}] path where the ${artifactName} will be generated.` 19 | }) 20 | }; 21 | } 22 | 23 | // Auth key group 24 | public static authKey = { 25 | "auth-key": Flags.string({ 26 | char: "k", 27 | description: "override current authentication state with an authentication key." 28 | }) 29 | }; 30 | 31 | public static force = { 32 | force: Flags.boolean({ 33 | char: "f", 34 | default: false, 35 | description: "overwrite changes without asking for user consent." 36 | }) 37 | }; 38 | } 39 | -------------------------------------------------------------------------------- /src/types/portal-context.ts: -------------------------------------------------------------------------------- 1 | import { FileService } from "../infrastructure/file-service.js"; 2 | import { DirectoryPath } from "./file/directoryPath.js"; 3 | import { FilePath } from "./file/filePath.js"; 4 | import { FileName } from "./file/fileName.js"; 5 | import { ZipService } from "../infrastructure/zip-service.js"; 6 | 7 | export class PortalContext { 8 | 9 | private readonly fileService = new FileService(); 10 | private readonly zipService = new ZipService(); 11 | 12 | constructor(private readonly portalDirectory: DirectoryPath) { 13 | } 14 | 15 | private get ZipPath(): FilePath { 16 | // TODO: add checks for build file path 17 | return new FilePath(this.portalDirectory, new FileName("portal.zip")); 18 | } 19 | 20 | private get reportPath(): FilePath { 21 | // TODO: add checks for build file path 22 | const debugPath = this.portalDirectory.join('apimatic-debug'); 23 | return new FilePath(debugPath, new FileName("apimatic-report.html")) 24 | } 25 | 26 | public async exists() { 27 | return !await this.fileService.directoryEmpty(this.portalDirectory); 28 | } 29 | 30 | public async save(tempPortalFilePath: FilePath, zipPortal: boolean) { 31 | await this.fileService.cleanDirectory(this.portalDirectory); 32 | if (zipPortal) { 33 | await this.fileService.copy(tempPortalFilePath, this.ZipPath); 34 | } else { 35 | await this.zipService.unArchive(tempPortalFilePath, this.portalDirectory); 36 | } 37 | } 38 | 39 | public async saveError(tempErrorFilePath: FilePath) { 40 | await this.fileService.cleanDirectory(this.portalDirectory); 41 | await this.zipService.unArchive(tempErrorFilePath, this.portalDirectory); 42 | return this.reportPath; 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/types/recipe-context.ts: -------------------------------------------------------------------------------- 1 | import { toPascalCase } from "../utils/utils.js"; 2 | import { FileName } from "./file/fileName.js"; 3 | import { Toc } from "./toc/toc.js"; 4 | 5 | export class RecipeContext { 6 | constructor(private readonly recipeName: string) {} 7 | 8 | public getRecipeScriptFileName(): FileName { 9 | return new FileName(toPascalCase(this.recipeName.trim()) + `.js`); 10 | } 11 | 12 | public getRecipeMarkdownFileName(): FileName { 13 | return new FileName(toPascalCase(this.recipeName.trim() + `.md`)); 14 | } 15 | 16 | public exists(tocData: Toc, recipeName: string, recipeMarkdownFileName: FileName): boolean { 17 | let apiRecipesGroup = tocData.toc?.find((item) => "group" in item && item.group === "API Recipes"); 18 | if (!apiRecipesGroup || !("items" in apiRecipesGroup)) { 19 | return false; 20 | } 21 | 22 | // Check if the recipe name or file name already exists 23 | const existingRecipe = apiRecipesGroup.items.find( 24 | (item) => 25 | "page" in item && 26 | "file" in item && 27 | (item.page === recipeName || item.file === `recipes/${recipeMarkdownFileName}`) 28 | ); 29 | return !!existingRecipe; 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/types/recipe/recipe.ts: -------------------------------------------------------------------------------- 1 | export enum StepType { 2 | Content = "content", 3 | Endpoint = "endpoint" 4 | } 5 | 6 | export interface EndpointConfig { 7 | description: string; 8 | endpointPermalink: string; 9 | } 10 | 11 | export interface SerializableRecipe { 12 | name: string; 13 | steps: SerializableStep[]; 14 | } 15 | 16 | export interface SerializableStep { 17 | key: string; 18 | name: string; 19 | type: StepType; 20 | config: ContentStepConfig | EndpointStepConfig; 21 | } 22 | 23 | export interface ContentStepConfig { 24 | content: string; 25 | } 26 | 27 | export type EndpointStepConfig = EndpointConfig 28 | -------------------------------------------------------------------------------- /src/types/resource-context.ts: -------------------------------------------------------------------------------- 1 | import * as path from "path"; 2 | import { err, ok, Result } from "neverthrow"; 3 | import { UrlPath } from "./file/urlPath.js"; 4 | import { FilePath } from "./file/filePath.js"; 5 | import { DirectoryPath } from "./file/directoryPath.js"; 6 | import { FileName } from "./file/fileName.js"; 7 | import { FileDownloadService } from "../infrastructure/services/file-download-service.js"; 8 | import { FileService } from "../infrastructure/file-service.js"; 9 | import { ResourceInput } from "./file/resource-input.js"; 10 | import { ServiceError } from "../infrastructure/service-error.js"; 11 | 12 | export class ResourceContext { 13 | private readonly fileDownloadService = new FileDownloadService(); 14 | private readonly fileService = new FileService(); 15 | 16 | constructor(private readonly tempDirectory: DirectoryPath) {} 17 | 18 | public async resolveTo(resourcePath: ResourceInput): Promise> { 19 | const fileName = new FileName(path.basename(resourcePath.toString())); 20 | const destinationFilePath = new FilePath(this.tempDirectory, fileName); 21 | 22 | if (resourcePath instanceof UrlPath) { 23 | const downloadFileResult = await this.fileDownloadService.downloadFile(resourcePath); 24 | if (downloadFileResult.isErr()) { 25 | return err(downloadFileResult.error); 26 | } 27 | await this.fileService.writeFile(destinationFilePath, downloadFileResult.value.stream); 28 | } 29 | if (resourcePath instanceof FilePath) { 30 | await this.fileService.copy(resourcePath, destinationFilePath); 31 | } 32 | return ok(destinationFilePath); 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/types/sdk-context.ts: -------------------------------------------------------------------------------- 1 | import { FileService } from "../infrastructure/file-service.js"; 2 | import { DirectoryPath } from "./file/directoryPath.js"; 3 | import { FilePath } from "./file/filePath.js"; 4 | import { FileName } from "./file/fileName.js"; 5 | import { ZipService } from "../infrastructure/zip-service.js"; 6 | import { Language } from "./sdk/generate.js"; 7 | 8 | export class SdkContext { 9 | private readonly fileService = new FileService(); 10 | private readonly zipService = new ZipService(); 11 | 12 | constructor(private readonly sdkDirectory: DirectoryPath, private readonly language: Language) { 13 | } 14 | 15 | private get zipPath(): FilePath { 16 | return new FilePath(this.sdkLanguageDirectory, new FileName(`${this.language}.zip`)); 17 | } 18 | 19 | public get sdkLanguageDirectory(): DirectoryPath { 20 | return this.sdkDirectory.join(this.language); 21 | } 22 | 23 | public async exists() { 24 | return !(await this.fileService.directoryEmpty(this.sdkLanguageDirectory)); 25 | } 26 | 27 | public async save(tempPortalFilePath: FilePath, zipPortal: boolean) { 28 | await this.fileService.cleanDirectory(this.sdkLanguageDirectory); 29 | if (zipPortal) { 30 | await this.fileService.copy(tempPortalFilePath, this.zipPath); 31 | } else { 32 | await this.zipService.unArchive(tempPortalFilePath, this.sdkLanguageDirectory); 33 | } 34 | return this.sdkLanguageDirectory; 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/types/sdk/generate.ts: -------------------------------------------------------------------------------- 1 | export enum Language { 2 | CSHARP = "csharp", 3 | JAVA = "java", 4 | PHP = "php", 5 | PYTHON = "python", 6 | RUBY = "ruby", 7 | TYPESCRIPT = "typescript", 8 | GO = "go" 9 | } 10 | 11 | const languageMap: { [key: number]: Language } = { 12 | 1: Language.CSHARP, 13 | 2: Language.GO, 14 | 4: Language.JAVA, 15 | 8: Language.PHP, 16 | 16: Language.PYTHON, 17 | 32: Language.RUBY, 18 | 128: Language.TYPESCRIPT, 19 | }; 20 | 21 | export function mapLanguages(languageFlag: number): Language[] { 22 | return Object.entries(languageMap) 23 | .filter(([flag]) => (languageFlag & parseInt(flag)) !== 0) 24 | .map(([, language]) => language); 25 | } 26 | -------------------------------------------------------------------------------- /src/types/sdl/sdl.ts: -------------------------------------------------------------------------------- 1 | import { TocEndpoint, TocModel } from "../toc/toc.js"; 2 | export type EndpointGroup = Map; 3 | export type SdlTocComponents = { endpointGroups: EndpointGroup; models: TocModel[] }; 4 | 5 | 6 | export interface Sdl { 7 | readonly Endpoints: SdlEndpoint[]; 8 | readonly CustomTypes: SdlModel[]; 9 | } 10 | 11 | export interface SdlEndpoint { 12 | readonly Name: string; 13 | readonly Description: string; 14 | readonly Group: string; 15 | } 16 | 17 | export interface SdlModel { 18 | readonly Name: string; 19 | } 20 | 21 | function extractEndpointGroupsForToc(sdl: Sdl): Map { 22 | const endpointGroups = new Map(); 23 | 24 | const endpoints = sdl.Endpoints.map( 25 | (e: SdlEndpoint): TocEndpoint => ({ 26 | generate: null, 27 | from: "endpoint", 28 | endpointName: e.Name, 29 | endpointGroup: e.Group 30 | }) 31 | ); 32 | 33 | endpoints.forEach((endpoint: TocEndpoint) => { 34 | const group = endpoint.endpointGroup; 35 | if (!endpointGroups.has(group)) { 36 | endpointGroups.set(group, []); 37 | } 38 | endpointGroups.get(group)!.push(endpoint); 39 | }); 40 | 41 | return endpointGroups; 42 | } 43 | 44 | function extractModelsForToc(sdl: Sdl): TocModel[] { 45 | return sdl.CustomTypes.map( 46 | (e: SdlModel): TocModel => ({ 47 | generate: null, 48 | from: "model", 49 | modelName: e.Name 50 | }) 51 | ); 52 | } 53 | 54 | export function getEndpointGroupsAndModels(sdl: Sdl): SdlTocComponents { 55 | const endpointGroups = extractEndpointGroupsForToc(sdl); 56 | const models = extractModelsForToc(sdl); 57 | return { endpointGroups, models }; 58 | } 59 | 60 | export function getEndpointDescription( 61 | endpointGroups: Map, 62 | endpointGroupName: string, 63 | endpointName: string 64 | ): string { 65 | return endpointGroups.get(endpointGroupName)!.find((e) => e.Name === endpointName)!.Description; 66 | } 67 | 68 | export function getEndpointGroupsFromSdl(sdl: Sdl): Map { 69 | const endpointGroups = new Map(); 70 | for (const endpoint of sdl.Endpoints) { 71 | if (!endpointGroups.has(endpoint.Group)) { 72 | endpointGroups.set(endpoint.Group, []); 73 | } 74 | 75 | endpointGroups.get(endpoint.Group)!.push({ 76 | Name: endpoint.Name, 77 | Description: endpoint.Description, 78 | Group: endpoint.Group 79 | }); 80 | } 81 | return endpointGroups; 82 | } 83 | -------------------------------------------------------------------------------- /src/types/spec-context.ts: -------------------------------------------------------------------------------- 1 | import { FileService } from "../infrastructure/file-service.js"; 2 | import { DirectoryPath } from "./file/directoryPath.js"; 3 | import { FilePath } from "./file/filePath.js"; 4 | import { FileName } from "./file/fileName.js"; 5 | import { ZipService } from "../infrastructure/zip-service.js"; 6 | 7 | export class SpecContext { 8 | private readonly fileService = new FileService(); 9 | private readonly zipService = new ZipService(); 10 | private readonly specDirectory: DirectoryPath; 11 | 12 | constructor(specDirectory: DirectoryPath) { 13 | this.specDirectory = specDirectory; 14 | } 15 | 16 | public async validate(): Promise { 17 | return !(await this.fileService.directoryEmpty(this.specDirectory)); 18 | } 19 | 20 | public async replaceDefaultSpec(specPath: FilePath) { 21 | await this.fileService.deleteFile(new FilePath(this.specDirectory, new FileName("openapi.json"))); 22 | if (await this.fileService.isZipFile(specPath)) { 23 | await this.zipService.unArchive(specPath, this.specDirectory); 24 | } else { 25 | await this.fileService.copy(specPath, specPath.replaceDirectory(this.specDirectory)); 26 | } 27 | } 28 | 29 | public async save(stream: NodeJS.ReadableStream, fileName: FileName): Promise { 30 | const filePath = new FilePath(this.specDirectory, fileName); 31 | await this.fileService.writeFile(filePath, stream); 32 | return filePath; 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/types/temp-context.ts: -------------------------------------------------------------------------------- 1 | import { FileService } from "../infrastructure/file-service.js"; 2 | import { ZipService } from "../infrastructure/zip-service.js"; 3 | import { DirectoryPath } from "./file/directoryPath.js"; 4 | import { FilePath } from "./file/filePath.js"; 5 | import { FileName } from "./file/fileName.js"; 6 | import { randomUUID } from "crypto"; 7 | 8 | export class TempContext { 9 | private readonly fileService = new FileService(); 10 | private readonly zipService = new ZipService(); 11 | 12 | constructor(private readonly tempDirectory: DirectoryPath) {} 13 | 14 | private get getTempFileName(): FilePath { 15 | const uuid = randomUUID(); 16 | return new FilePath(this.tempDirectory, new FileName(`${uuid}`)); 17 | } 18 | 19 | public async zip(buildDirectory: DirectoryPath): Promise { 20 | const tempFile = this.getTempFileName; 21 | await this.zipService.archive(buildDirectory, tempFile); 22 | return tempFile; 23 | } 24 | 25 | public async save(portalStream: NodeJS.ReadableStream): Promise { 26 | const tempFile = this.getTempFileName; 27 | await this.fileService.writeFile(tempFile, portalStream); 28 | return tempFile; 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/types/toc-context.ts: -------------------------------------------------------------------------------- 1 | import { FileService } from "../infrastructure/file-service.js"; 2 | import { DirectoryPath } from "./file/directoryPath.js"; 3 | import { FilePath } from "./file/filePath.js"; 4 | import { FileName } from "./file/fileName.js"; 5 | import { Toc } from "./toc/toc.js"; 6 | import { parse } from "yaml"; 7 | 8 | export class TocContext { 9 | private readonly fileService = new FileService(); 10 | private readonly tocFilePath: FilePath; 11 | 12 | constructor(tocDirectory: DirectoryPath) { 13 | this.tocFilePath = new FilePath(tocDirectory, new FileName("toc.yml")); 14 | } 15 | 16 | public get tocPath(): FilePath { 17 | return this.tocFilePath; 18 | } 19 | 20 | public async exists() { 21 | return await this.fileService.fileExists(this.tocFilePath); 22 | } 23 | 24 | public async parseTocData(): Promise { 25 | const tocContent = await this.fileService.getContents(this.tocFilePath); 26 | return parse(tocContent) as Toc; 27 | } 28 | 29 | public async save(contents: string) { 30 | await this.fileService.ensurePathExists(this.tocFilePath); 31 | await this.fileService.writeContents(this.tocFilePath, contents); 32 | return this.tocPath; 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/types/toc/toc.ts: -------------------------------------------------------------------------------- 1 | export interface Toc { 2 | toc: Array 3 | } 4 | 5 | export interface TocGroup { 6 | readonly group: string, 7 | readonly items: Array 8 | } 9 | 10 | export interface TocGenerated { 11 | readonly generate: string; 12 | readonly from: string; 13 | } 14 | 15 | export interface TocEndpointGroupOverview { 16 | readonly generate: null; 17 | readonly from: "endpoint-group-overview"; 18 | readonly endpointGroup: string; 19 | } 20 | 21 | export interface TocEndpoint { 22 | readonly generate: null; 23 | readonly from: "endpoint"; 24 | readonly endpointName: string; 25 | readonly endpointGroup: string; 26 | } 27 | 28 | export interface TocModel { 29 | readonly generate: null; 30 | readonly from: "model"; 31 | readonly modelName: string; 32 | } 33 | 34 | export interface TocCustomPage { 35 | readonly page: string; 36 | readonly file: string; 37 | } 38 | -------------------------------------------------------------------------------- /src/types/transform-context.ts: -------------------------------------------------------------------------------- 1 | import { ExportFormats } from "@apimatic/sdk"; 2 | import { FileService } from "../infrastructure/file-service.js"; 3 | import { getFileNameFromPath } from "../utils/utils.js"; 4 | import { DestinationFormats } from "./api/transform.js"; 5 | import { DirectoryPath } from "./file/directoryPath.js"; 6 | import { FileName } from "./file/fileName.js"; 7 | import { FilePath } from "./file/filePath.js"; 8 | 9 | export class TransformContext { 10 | private readonly fileService = new FileService(); 11 | 12 | private readonly transformedApi: FileName; 13 | 14 | constructor(specFilePath: FilePath, 15 | format: ExportFormats, 16 | private readonly destinationDirectory: DirectoryPath ) { 17 | this.transformedApi = this.parseFileName(format, specFilePath); 18 | } 19 | 20 | private get specPath(): FilePath { 21 | return new FilePath(this.destinationDirectory, this.transformedApi); 22 | } 23 | 24 | public async exists(): Promise { 25 | const transformedApiPath = new FilePath(this.destinationDirectory, this.transformedApi); 26 | return await this.fileService.fileExists(transformedApiPath); 27 | 28 | } 29 | 30 | public async save(stream: NodeJS.ReadableStream): Promise { 31 | await this.fileService.createDirectoryIfNotExists(this.destinationDirectory); 32 | await this.fileService.writeFile(this.specPath, stream); 33 | return this.specPath; 34 | } 35 | 36 | private parseFileName(format: string, file: FilePath): FileName { 37 | const destinationFileExt: string = DestinationFormats[format as keyof typeof DestinationFormats]; 38 | const destinationFilePrefix = getFileNameFromPath(file.toString()); 39 | return new FileName(`${destinationFilePrefix}_${format}.${destinationFileExt}`); 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/types/utils.ts: -------------------------------------------------------------------------------- 1 | export type ValidationMessages = { 2 | messages: string[]; 3 | warnings: string[]; 4 | errors: string[]; 5 | }; 6 | -------------------------------------------------------------------------------- /src/utils/string-utils.ts: -------------------------------------------------------------------------------- 1 | export const removeQuotes = (input: string): string => { 2 | const quotes = ['"', "'"]; 3 | 4 | for (const quote of quotes) { 5 | if (input.startsWith(quote) && input.endsWith(quote) && input.length > 1) { 6 | return removeQuotes(input.slice(1, -1)); // Recursive call 7 | } 8 | } 9 | return input; 10 | }; 11 | 12 | 13 | 14 | export function stripAnsi(str: string) { 15 | let result = ''; 16 | let i = 0; 17 | 18 | while (i < str.length) { 19 | const char = str[i]; 20 | // Detect ESC (0x1B) 21 | if (char === '\x1B' && str[i + 1] === '[') { 22 | // We’re at the start of an ANSI sequence. Skip until 'm' or end. 23 | i += 2; // skip ESC[ 24 | while (i < str.length && str[i] !== 'm') { 25 | i++; 26 | } 27 | // Skip the 'm' itself 28 | i++; 29 | } else if (char.charCodeAt(0) < 32 || char.charCodeAt(0) === 127) { 30 | // Skip other control chars (optional) 31 | i++; 32 | } else { 33 | // Normal printable char — keep it 34 | result += char; 35 | i++; 36 | } 37 | } 38 | return result; 39 | } 40 | 41 | -------------------------------------------------------------------------------- /src/utils/utils.ts: -------------------------------------------------------------------------------- 1 | import * as path from "path"; 2 | import { Buffer } from "buffer"; 3 | import stripTags from "striptags"; 4 | 5 | export const replaceHTML = (string: string) => { 6 | return stripTags(string); 7 | }; 8 | 9 | export const getFileNameFromPath = (filePath: string) => { 10 | return path.basename(filePath).split(".")[0]; 11 | }; 12 | 13 | export async function parseStreamBodyToJson(body: NodeJS.ReadableStream): Promise { 14 | const chunks: Buffer[] = []; 15 | for await (const chunk of body) { 16 | chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); 17 | } 18 | const text = Buffer.concat(chunks).toString("utf-8"); 19 | return JSON.parse(text); 20 | } 21 | 22 | export const toPascalCase = (str: string): string => { 23 | return str 24 | .split(" ") 25 | .map((word) => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase()) 26 | .join(""); 27 | }; 28 | -------------------------------------------------------------------------------- /test/actions/portal/toc/new-toc.test.ts: -------------------------------------------------------------------------------- 1 | import * as path from "path"; 2 | import fsExtra from "fs-extra"; 3 | import { expect } from "chai"; 4 | import { dir as tmpDir, DirectoryResult } from "tmp-promise"; 5 | import { PortalNewTocAction } from "../../../../src/actions/portal/toc/new-toc.js"; 6 | 7 | describe("PortalNewTocAction", () => { 8 | let TEST_WORKING_DIR: string; 9 | let TEST_CONFIG_DIR: string; 10 | let portalNewTocAction: PortalNewTocAction; 11 | let tmpDirResult: DirectoryResult; 12 | 13 | beforeEach(async () => { 14 | tmpDirResult = await tmpDir({ unsafeCleanup: true }); 15 | TEST_WORKING_DIR = tmpDirResult.path; 16 | TEST_CONFIG_DIR = path.join(TEST_WORKING_DIR, "config"); 17 | portalNewTocAction = new PortalNewTocAction(); 18 | 19 | await fsExtra.ensureDir(TEST_WORKING_DIR); 20 | await fsExtra.ensureDir(path.join(TEST_WORKING_DIR, "content")); 21 | }); 22 | 23 | afterEach(async () => { 24 | await tmpDirResult.cleanup(); 25 | }); 26 | 27 | describe("createToc", () => { 28 | it("should create TOC file at default location", async () => { 29 | const expectedTocPath = path.join(TEST_WORKING_DIR, "content", "toc.yml"); 30 | 31 | const result = await portalNewTocAction.createToc( 32 | TEST_WORKING_DIR, 33 | TEST_CONFIG_DIR, 34 | undefined, 35 | true 36 | ); 37 | 38 | expect(result.isSuccess()).to.be.true; 39 | expect(await fsExtra.pathExists(expectedTocPath)).to.be.true; 40 | 41 | const tocContent = await fsExtra.readFile(expectedTocPath, "utf8"); 42 | expect(tocContent).to.include("Getting Started"); 43 | expect(tocContent).to.include("API Endpoints"); 44 | expect(tocContent).to.include("Models"); 45 | expect(tocContent).to.include("SDK Infrastructure"); 46 | }); 47 | 48 | it("should create TOC file at custom location", async () => { 49 | const customDestination = path.join(TEST_WORKING_DIR, "custom"); 50 | await fsExtra.ensureDir(customDestination); 51 | const expectedTocPath = path.join(customDestination, "toc.yml"); 52 | 53 | const result = await portalNewTocAction.createToc( 54 | TEST_WORKING_DIR, 55 | TEST_CONFIG_DIR, 56 | customDestination, 57 | true 58 | ); 59 | 60 | expect(result.isSuccess()).to.be.true; 61 | expect(await fsExtra.pathExists(expectedTocPath)).to.be.true; 62 | 63 | await fsExtra.remove(customDestination); 64 | }); 65 | }); 66 | }); -------------------------------------------------------------------------------- /test/application/portal/recipe/new/portal-recipe.test.ts: -------------------------------------------------------------------------------- 1 | import { expect } from "chai"; 2 | import { PortalRecipe } from "../../../../../src/application/portal/recipe/portal-recipe"; 3 | 4 | describe("PortalRecipe", () => { 5 | it("should initialize with the correct name and empty steps", () => { 6 | const recipe = new PortalRecipe("Test Recipe"); 7 | const serializable = recipe.toSerializableRecipe(); 8 | expect(serializable.name).to.equal("Test Recipe"); 9 | expect(serializable.steps).to.be.an("array").that.is.empty; 10 | }); 11 | 12 | it("should add a content step with correct structure", () => { 13 | const recipe = new PortalRecipe("Test Recipe"); 14 | recipe.addContentStep("step1", "Step 1", "Some content"); 15 | const serializable = recipe.toSerializableRecipe(); 16 | expect(serializable.steps).to.have.lengthOf(1); 17 | expect(serializable.steps[0]).to.deep.equal({ 18 | key: "step1", 19 | name: "Step 1", 20 | type: "content", 21 | config: { content: "Some content" } 22 | }); 23 | }); 24 | 25 | it("should add an endpoint step with correct structure", () => { 26 | const recipe = new PortalRecipe("Test Recipe"); 27 | recipe.addEndpointStep("step2", "Step 2", "desc", "permalink"); 28 | const serializable = recipe.toSerializableRecipe(); 29 | expect(serializable.steps).to.have.lengthOf(1); 30 | expect(serializable.steps[0]).to.deep.equal({ 31 | key: "step2", 32 | name: "Step 2", 33 | type: "endpoint", 34 | config: { description: "desc", endpointPermalink: "permalink" } 35 | }); 36 | }); 37 | 38 | it("should allow chaining of addContentStep and addEndpointStep", () => { 39 | const recipe = new PortalRecipe("Test Recipe"); 40 | recipe 41 | .addContentStep("step1", "Step 1", "Some content") 42 | .addEndpointStep("step2", "Step 2", "desc", "permalink"); 43 | const serializable = recipe.toSerializableRecipe(); 44 | expect(serializable.steps).to.have.lengthOf(2); 45 | expect(serializable.steps[0].type).to.equal("content"); 46 | expect(serializable.steps[1].type).to.equal("endpoint"); 47 | }); 48 | 49 | it("toSerializableRecipe should return the correct recipe object", () => { 50 | const recipe = new PortalRecipe("Test Recipe"); 51 | recipe.addContentStep("step1", "Step 1", "Some content"); 52 | const serializable = recipe.toSerializableRecipe(); 53 | expect(serializable).to.have.property("name", "Test Recipe"); 54 | expect(serializable).to.have.property("steps").that.is.an("array"); 55 | }); 56 | }); 57 | -------------------------------------------------------------------------------- /test/application/portal/serve/serve-handler.test.ts: -------------------------------------------------------------------------------- 1 | import * as path from "path"; 2 | import fsExtra from "fs-extra"; 3 | import { expect } from "chai"; 4 | import { dir as tmpDir, DirectoryResult } from "tmp-promise"; 5 | import { ServeHandler } from "../../../../src/application/portal/serve/serve-handler.js"; 6 | import { ServeFlags, ServePaths } from "../../../../src/types/portal/serve.js"; 7 | import { Result } from "../../../../src/types/common/result.js"; 8 | 9 | describe("ServeHandler", () => { 10 | let TEST_WORKING_DIR: string; 11 | let TEST_DEST_DIR: string; 12 | let TEST_CONFIG_DIR: string; 13 | let tmpDirResult: DirectoryResult; 14 | let flags: ServeFlags; 15 | let paths: ServePaths; 16 | 17 | beforeEach(async () => { 18 | tmpDirResult = await tmpDir({ unsafeCleanup: true }); 19 | TEST_WORKING_DIR = tmpDirResult.path; 20 | TEST_DEST_DIR = path.join(TEST_WORKING_DIR, "dest"); 21 | TEST_CONFIG_DIR = path.join(TEST_WORKING_DIR, "config"); 22 | await fsExtra.ensureDir(TEST_WORKING_DIR); 23 | await fsExtra.ensureDir(TEST_DEST_DIR); 24 | await fsExtra.ensureDir(TEST_CONFIG_DIR); 25 | 26 | flags = { 27 | port: 3000, 28 | folder: TEST_WORKING_DIR, 29 | destination: TEST_DEST_DIR, 30 | ignore: "", 31 | open: false, 32 | "auth-key": "", 33 | "no-reload": false 34 | }; 35 | paths = { 36 | sourceDirectoryPath: flags.folder, 37 | destinationDirectoryPath: flags.destination, 38 | generatedPortalArtifactsDirectoryPath: path.join(flags.destination, "generated_portal"), 39 | generatedPortalArtifactsZipFilePath: path.join(flags.destination, ".generated_portal.zip") 40 | }; 41 | }); 42 | 43 | afterEach(async () => { 44 | await tmpDirResult.cleanup(); 45 | }); 46 | 47 | function createTestServeHandler(listenImpl: any, liveReloadResult = Result.success(35729)) { 48 | return new (class extends ServeHandler { 49 | // @ts-ignore 50 | app = { 51 | use: () => {}, 52 | listen: listenImpl 53 | }; 54 | // @ts-ignore 55 | async createLiveReloadServer() { return liveReloadResult; } 56 | // @ts-ignore 57 | async stopServer() {} 58 | })(); 59 | } 60 | 61 | it("can be constructed", () => { 62 | const handler = new ServeHandler(); 63 | expect(handler).to.be.instanceOf(ServeHandler); 64 | }); 65 | 66 | it("setupServer returns success for valid path (simulate live reload success)", async () => { 67 | const handler = createTestServeHandler(() => ({})); 68 | const result = await handler.setupServer(TEST_DEST_DIR); 69 | expect(result.isSuccess()).to.be.true; 70 | expect(result.value).to.include("Server is set up"); 71 | }); 72 | 73 | it("setupServer returns failure if createLiveReloadServer fails", async () => { 74 | const handler = createTestServeHandler(() => ({}), Result.failure("fail")); 75 | const result = await handler.setupServer(TEST_DEST_DIR); 76 | expect(result.isFailed()).to.be.true; 77 | expect(result.error).to.include("fail"); 78 | }); 79 | 80 | it("startServer returns success (simulate listen)", async () => { 81 | const handler = createTestServeHandler( 82 | (port: number, cb: () => void) => { 83 | setTimeout(cb, 10); 84 | return { on: () => ({}) }; 85 | } 86 | ); 87 | const result = await handler.startServer(paths, flags, [], TEST_CONFIG_DIR, false); 88 | expect(result.isSuccess()).to.be.true; 89 | }); 90 | 91 | it("startServer returns failure if port is in use (EADDRINUSE)", async () => { 92 | const handler = createTestServeHandler( 93 | (port: number, cb: () => void) => ({ 94 | on: (event: string, handler: (err: any) => void) => { 95 | if (event === "error") setTimeout(() => handler({ code: "EADDRINUSE" }), 10); 96 | return {}; 97 | } 98 | }) 99 | ); 100 | try { 101 | await handler.startServer(paths, flags, [], TEST_CONFIG_DIR, false); 102 | expect.fail("Should throw for EADDRINUSE"); 103 | } catch (err: any) { 104 | expect(err.message).to.include("Something went wrong"); 105 | } 106 | }); 107 | 108 | it("startServer returns failure for generic listen error", async () => { 109 | const handler = createTestServeHandler( 110 | (port: number, cb: () => void) => ({ 111 | on: (event: string, handler: (err: any) => void) => { 112 | if (event === "error") setTimeout(() => handler({ code: "SOME_ERROR" }), 10); 113 | return {}; 114 | } 115 | }) 116 | ); 117 | try { 118 | await handler.startServer(paths, flags, [], TEST_CONFIG_DIR, false); 119 | expect.fail("Should throw for generic error"); 120 | } catch (err: any) { 121 | expect(err.message).to.include("Something went wrong while serving your portal"); 122 | } 123 | }); 124 | }); -------------------------------------------------------------------------------- /test/application/portal/serve/watcher-handler.test.ts: -------------------------------------------------------------------------------- 1 | import { expect } from "chai"; 2 | import { WatcherHandler } from "../../../../src/application/portal/serve/watcher-handler.js"; 3 | 4 | describe("WatcherHandler", () => { 5 | it("should execute handler immediately if not processing", async () => { 6 | const handler = new WatcherHandler(); 7 | let called = false; 8 | await handler.execute(async () => { 9 | called = true; 10 | }); 11 | expect(called).to.be.true; 12 | }); 13 | 14 | it("should only run the latest handler if called multiple times while processing", async () => { 15 | const handler = new WatcherHandler(); 16 | let callOrder: string[] = []; 17 | let resolveFirst: () => void; 18 | const firstPromise = new Promise((resolve) => { 19 | resolveFirst = resolve; 20 | }); 21 | // Start first handler (will not resolve immediately) 22 | handler.execute(async () => { 23 | callOrder.push("first"); 24 | await firstPromise; 25 | }); 26 | // Queue up two more handlers 27 | await handler.execute(async () => { 28 | callOrder.push("second"); 29 | }); 30 | await handler.execute(async () => { 31 | callOrder.push("third"); 32 | }); 33 | // Now resolve the first handler 34 | resolveFirst!(); 35 | // Wait a tick for the queued handler to run 36 | await new Promise((r) => setTimeout(r, 10)); 37 | // Only the first and the last (third) should run 38 | expect(callOrder).to.deep.equal(["first", "third"]); 39 | }); 40 | }); -------------------------------------------------------------------------------- /test/application/portal/toc/new/sdl-parser.test.ts: -------------------------------------------------------------------------------- 1 | import * as path from "path"; 2 | import fsExtra from "fs-extra"; 3 | import { expect } from "chai"; 4 | import { SdlParser } from "../../../../../src/application/portal/toc/sdl-parser.js"; 5 | import { PortalService } from "../../../../../src/infrastructure/services/portal-service.js"; 6 | import { Result } from "../../../../../src/types/common/result.js"; 7 | import { Sdl } from "../../../../../src/types/sdl/sdl.js"; 8 | import { dir as tmpDir, DirectoryResult } from "tmp-promise"; 9 | 10 | describe("SdlParser", () => { 11 | let TEST_CONFIG_DIR: string; 12 | let TEST_SPEC_DIR: string; 13 | let tmpDirResult: DirectoryResult; 14 | let sdlParser: SdlParser; 15 | let portalServiceStub: Partial; 16 | 17 | beforeEach(async () => { 18 | tmpDirResult = await tmpDir({ unsafeCleanup: true }); 19 | TEST_CONFIG_DIR = tmpDirResult.path; 20 | TEST_SPEC_DIR = path.join(TEST_CONFIG_DIR, "spec"); 21 | await fsExtra.ensureDir(TEST_SPEC_DIR); 22 | 23 | const sdlContent: Sdl = { 24 | Endpoints: [ 25 | { 26 | Name: "Login", 27 | Group: "Authentication", 28 | Description: "User login endpoint" 29 | }, 30 | { 31 | Name: "Logout", 32 | Group: "Authentication", 33 | Description: "User logout endpoint" 34 | }, 35 | { 36 | Name: "GetProducts", 37 | Group: "Products", 38 | Description: "Fetches a list of products" 39 | } 40 | ], 41 | CustomTypes: [ 42 | { 43 | Name: "User" 44 | }, 45 | { 46 | Name: "Product" 47 | } 48 | ] 49 | }; 50 | await fsExtra.writeJson(path.join(TEST_SPEC_DIR, "sdl.json"), sdlContent); 51 | 52 | portalServiceStub = { 53 | generateSdl: async () => Result.success(sdlContent) 54 | }; 55 | 56 | sdlParser = new SdlParser(portalServiceStub as PortalService); 57 | }); 58 | 59 | afterEach(async () => { 60 | await tmpDirResult.cleanup(); 61 | }); 62 | 63 | describe("getTocComponentsFromSdl", () => { 64 | it("should extract endpoint groups correctly", async () => { 65 | const result = await sdlParser.getTocComponentsFromSdl(TEST_SPEC_DIR, TEST_SPEC_DIR, TEST_CONFIG_DIR); 66 | 67 | expect(result.isSuccess()).to.be.true; 68 | const { endpointGroups } = result.value!; 69 | 70 | const authEndpoints = endpointGroups.get("Authentication"); 71 | expect(authEndpoints).to.have.lengthOf(2); 72 | expect(authEndpoints![0]).to.deep.include({ 73 | generate: null, 74 | from: "endpoint", 75 | endpointName: "Login", 76 | endpointGroup: "Authentication" 77 | }); 78 | expect(authEndpoints![1]).to.deep.include({ 79 | generate: null, 80 | from: "endpoint", 81 | endpointName: "Logout", 82 | endpointGroup: "Authentication" 83 | }); 84 | 85 | const productEndpoints = endpointGroups.get("Products"); 86 | expect(productEndpoints).to.have.lengthOf(1); 87 | expect(productEndpoints![0]).to.deep.include({ 88 | generate: null, 89 | from: "endpoint", 90 | endpointName: "GetProducts", 91 | endpointGroup: "Products" 92 | }); 93 | }); 94 | 95 | it("should extract models correctly", async () => { 96 | const result = await sdlParser.getTocComponentsFromSdl(TEST_SPEC_DIR, TEST_SPEC_DIR, TEST_CONFIG_DIR); 97 | 98 | expect(result.isSuccess()).to.be.true; 99 | const { models } = result.value!; 100 | 101 | expect(models).to.have.lengthOf(2); 102 | expect(models[0]).to.deep.include({ 103 | generate: null, 104 | from: "model", 105 | modelName: "User" 106 | }); 107 | expect(models[1]).to.deep.include({ 108 | generate: null, 109 | from: "model", 110 | modelName: "Product" 111 | }); 112 | }); 113 | 114 | it("should handle empty SDL", async () => { 115 | const emptySdl: Sdl = { 116 | Endpoints: [], 117 | CustomTypes: [] 118 | }; 119 | portalServiceStub.generateSdl = async () => Result.success(emptySdl); 120 | 121 | const result = await sdlParser.getTocComponentsFromSdl(TEST_SPEC_DIR, TEST_SPEC_DIR, TEST_CONFIG_DIR); 122 | 123 | expect(result.isSuccess()).to.be.true; 124 | const { endpointGroups, models } = result.value!; 125 | expect(endpointGroups.size).to.equal(0); 126 | expect(models).to.have.lengthOf(0); 127 | }); 128 | 129 | it("should handle malformed SDL", async () => { 130 | portalServiceStub.generateSdl = async () => Result.failure("Invalid SDL"); 131 | 132 | const result = await sdlParser.getTocComponentsFromSdl(TEST_SPEC_DIR, TEST_SPEC_DIR, TEST_CONFIG_DIR); 133 | 134 | expect(result.isSuccess()).to.be.false; 135 | expect(result.error).to.contain( 136 | "Failed to extract endpoints/models from the specification." 137 | ); 138 | }); 139 | 140 | it("should maintain endpoint group ordering", async () => { 141 | const result = await sdlParser.getTocComponentsFromSdl(TEST_SPEC_DIR, TEST_SPEC_DIR, TEST_CONFIG_DIR); 142 | 143 | expect(result.isSuccess()).to.be.true; 144 | const { endpointGroups } = result.value!; 145 | const groupNames = Array.from(endpointGroups.keys()); 146 | expect(groupNames).to.deep.equal(["Authentication", "Products"]); 147 | }); 148 | }); 149 | }); -------------------------------------------------------------------------------- /test/application/portal/toc/new/toc-structure-generator.test.ts: -------------------------------------------------------------------------------- 1 | import { expect } from "chai"; 2 | import { TocStructureGenerator } from "../../../../../src/application/portal/toc/toc-structure-generator.js"; 3 | import { TocEndpoint, TocGroup, TocModel, Toc, TocCustomPage } from "../../../../../src/types/toc/toc.js"; 4 | 5 | describe("TocStructureGenerator", () => { 6 | let tocStructureGenerator: TocStructureGenerator; 7 | 8 | beforeEach(() => { 9 | tocStructureGenerator = new TocStructureGenerator(); 10 | }); 11 | 12 | describe("createTocStructure", () => { 13 | it("should create basic TOC structure with default sections", () => { 14 | const endpointGroups = new Map(); 15 | const models: TocModel[] = []; 16 | const contentGroups: TocGroup[] = []; 17 | 18 | const result = tocStructureGenerator.createTocStructure( 19 | endpointGroups, 20 | models, 21 | false, 22 | false, 23 | contentGroups 24 | ); 25 | 26 | expect(result.toc).to.be.an("array"); 27 | expect(result.toc).to.have.lengthOf(4); 28 | 29 | expect(result.toc[0]).to.deep.include({ 30 | group: "Getting Started", 31 | items: [{ 32 | generate: "How to Get Started", 33 | from: "getting-started" 34 | }] 35 | }); 36 | 37 | expect(result.toc[1]).to.deep.include({ 38 | generate: "API Endpoints", 39 | from: "endpoints" 40 | }); 41 | 42 | expect(result.toc[2]).to.deep.include({ 43 | generate: "Models", 44 | from: "models" 45 | }); 46 | 47 | expect(result.toc[3]).to.deep.include({ 48 | generate: "SDK Infrastructure", 49 | from: "sdk-infra" 50 | }); 51 | }); 52 | 53 | it("should include content groups when provided", () => { 54 | const endpointGroups = new Map(); 55 | const models: TocModel[] = []; 56 | const contentGroups: TocGroup[] = [{ 57 | group: "Custom Content", 58 | items: [{ 59 | page: "Guide 1", 60 | file: "guides/guide1.md" 61 | }] 62 | }]; 63 | 64 | const result = tocStructureGenerator.createTocStructure( 65 | endpointGroups, 66 | models, 67 | false, 68 | false, 69 | contentGroups 70 | ); 71 | 72 | expect(result.toc).to.have.lengthOf(5); 73 | expect(result.toc[1]).to.deep.equal(contentGroups[0]); 74 | }); 75 | 76 | it("should create expanded endpoints structure when flag is true", () => { 77 | const endpointGroups = new Map(); 78 | endpointGroups.set("Authentication", [{ 79 | generate: null, 80 | from: "endpoint", 81 | endpointName: "Login", 82 | endpointGroup: "Authentication" 83 | }]); 84 | 85 | const result = tocStructureGenerator.createTocStructure( 86 | endpointGroups, 87 | [], 88 | true, 89 | false, 90 | [] 91 | ); 92 | 93 | const apiEndpointsSection = result.toc.find(section => 94 | 'group' in section && section.group === "API Endpoints" 95 | ) as TocGroup; 96 | 97 | expect(apiEndpointsSection).to.exist; 98 | expect(apiEndpointsSection.items).to.be.an("array"); 99 | const authGroup = apiEndpointsSection.items[0] as TocGroup; 100 | expect(authGroup.group).to.equal("Authentication"); 101 | expect(authGroup.items).to.have.lengthOf(2); 102 | }); 103 | 104 | it("should create expanded models structure when flag is true", () => { 105 | const models: TocModel[] = [{ 106 | generate: null, 107 | from: "model", 108 | modelName: "User" 109 | }]; 110 | 111 | const result = tocStructureGenerator.createTocStructure( 112 | new Map(), 113 | models, 114 | false, 115 | true, 116 | [] 117 | ); 118 | 119 | const modelsSection = result.toc.find(section => 120 | 'group' in section && section.group === "Models" 121 | ) as TocGroup; 122 | 123 | expect(modelsSection).to.exist; 124 | expect(modelsSection.items).to.be.an("array"); 125 | expect(modelsSection.items).to.deep.include(models[0]); 126 | }); 127 | }); 128 | 129 | describe("transformToYaml", () => { 130 | it("should generate valid YAML", () => { 131 | const toc: Toc = { 132 | toc: [{ 133 | group: "Test Group", 134 | items: [{ 135 | page: "Test Page", 136 | file: "test.md" 137 | } as TocCustomPage] 138 | } as TocGroup] 139 | }; 140 | 141 | const result = tocStructureGenerator.transformToYaml(toc); 142 | 143 | expect(result).to.include("toc:"); 144 | expect(result).to.include("group: Test Group"); 145 | expect(result).to.include("page: Test Page"); 146 | expect(result).to.include("file: test.md"); 147 | }); 148 | 149 | it("should handle null values correctly", () => { 150 | const toc: Toc = { 151 | toc: [{ 152 | group: "Test Group", 153 | items: [{ 154 | generate: null, 155 | from: "endpoint", 156 | endpointName: "Test", 157 | endpointGroup: "Test Group" 158 | } as TocEndpoint] 159 | } as TocGroup] 160 | }; 161 | 162 | const result = tocStructureGenerator.transformToYaml(toc); 163 | 164 | expect(result).to.include("generate:"); 165 | expect(result).to.not.include("generate: null"); 166 | }); 167 | }); 168 | }); -------------------------------------------------------------------------------- /test/commands/examples-parse.test.ts: -------------------------------------------------------------------------------- 1 | import { expect } from "chai"; 2 | import { Parser } from "@oclif/core"; 3 | /* eslint-env mocha */ 4 | /* eslint-disable no-undef */ 5 | import * as path from "path"; 6 | import { pathToFileURL } from "node:url"; 7 | import stringArgv from "string-argv"; 8 | 9 | type CommandMapping = { 10 | id: string; 11 | fileParts: string[]; // relative to lib/ 12 | exportName?: string; // default export if omitted 13 | }; 14 | 15 | const COMMANDS: CommandMapping[] = [ 16 | { id: "api transform", fileParts: ["commands", "api", "transform.js"] }, 17 | { id: "api validate", fileParts: ["commands", "api", "validate.js"] }, 18 | { id: "auth login", fileParts: ["commands", "auth", "login.js"] }, 19 | { id: "auth logout", fileParts: ["commands", "auth", "logout.js"] }, 20 | { id: "auth status", fileParts: ["commands", "auth", "status.js"] }, 21 | { id: "portal generate", fileParts: ["commands", "portal", "generate.js"], exportName: "PortalGenerate" }, 22 | { id: "portal recipe new", fileParts: ["commands", "portal", "recipe", "new.js"] }, 23 | { id: "portal serve", fileParts: ["commands", "portal", "serve.js"] }, 24 | { id: "portal copilot", fileParts: ["commands", "portal", "copilot.js"] }, 25 | { id: "portal toc new", fileParts: ["commands", "portal", "toc", "new.js"] }, 26 | { id: "portal quickstart", fileParts: ["commands", "portal", "quickstart.js"] }, 27 | { id: "sdk generate", fileParts: ["commands", "sdk", "generate.js"] } 28 | ]; 29 | 30 | const BIN_NAME = "apimatic"; // matches package.json oclif.bin 31 | 32 | describe("all command examples parse", () => { 33 | COMMANDS.forEach(({ id, fileParts, exportName }) => { 34 | const filePath = path.join(process.cwd(), "lib", ...fileParts); 35 | const fileUrl = pathToFileURL(filePath).href; 36 | 37 | describe(id, () => { 38 | let examples: string[] = []; 39 | let ctor: any; 40 | 41 | before(async () => { 42 | const mod = await import(fileUrl); 43 | ctor = exportName ? mod[exportName] : mod.default; 44 | examples = Array.isArray(ctor?.examples) ? ctor.examples : []; 45 | }); 46 | 47 | it("has examples", () => { 48 | expect(examples, `Command ${id} has no examples`).to.be.an("array"); 49 | }); 50 | 51 | describe("parse examples", function () { 52 | before(async function () { 53 | if (!ctor) { 54 | const mod = await import(fileUrl); 55 | ctor = exportName ? mod[exportName] : mod.default; 56 | examples = Array.isArray(ctor?.examples) ? ctor.examples : []; 57 | } 58 | }); 59 | it("are valid", async function () { 60 | for (const example of examples) { 61 | const argv = toArgv(example, BIN_NAME); 62 | const argvForParser = argv[0] === id ? argv.slice(1) : argv; 63 | try { 64 | await Parser.parse(argvForParser, { 65 | flags: (ctor?.flags ?? {}) as never, 66 | args: (ctor?.args ?? {}) as never, 67 | strict: true 68 | } as never); 69 | } catch (err) { 70 | expect.fail( 71 | `Failed to parse example.\n` + 72 | `Command: ${id}\n` + 73 | `Example: ${example}\n` + 74 | `Error: ${(err as Error).message}` 75 | ); 76 | } 77 | } 78 | }); 79 | }); 80 | }); 81 | }); 82 | }); 83 | 84 | function toArgv(example: string, binName: string): string[] { 85 | const trimmed = example.trim(); 86 | const withoutBin = trimmed.startsWith(`${binName} `) 87 | ? trimmed.slice(binName.length + 1) 88 | : trimmed; 89 | return stringArgv(withoutBin); 90 | } 91 | -------------------------------------------------------------------------------- /test/mocha.opts: -------------------------------------------------------------------------------- 1 | --require ts-node/register 2 | --watch-extensions ts 3 | --recursive 4 | --reporter spec 5 | --timeout 5000 6 | -------------------------------------------------------------------------------- /test/resources/build-inputs/default/APIMATIC-BUILD.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://titan.apimatic.io/api/build/schema", 3 | "buildFileVersion": "1", 4 | "generatePortal": { 5 | "debug": { 6 | "publishReport": true 7 | }, 8 | "pageTitle": "My Portal Title", 9 | "navTitle": "My Navigation Title", 10 | "logoUrl": "static/images/logo.png", 11 | "logoLink": "https://www.apimatic.io", 12 | "languageConfig": { 13 | "http": {}, 14 | "typescript": {}, 15 | "ruby": {}, 16 | "python": {}, 17 | "java": {}, 18 | "csharp": {}, 19 | "php": {}, 20 | "go": {} 21 | }, 22 | "headIncludes": " ", 23 | "portalSettings": { 24 | "themeOverrides": { 25 | "themeType": "cool", 26 | "palette": { 27 | "primaryColor": "#94C13D" 28 | } 29 | }, 30 | "enableExport": true, 31 | "enableConsoleCalls": true 32 | } 33 | } 34 | } -------------------------------------------------------------------------------- /test/resources/build-inputs/default/content/guides/guide1.md: -------------------------------------------------------------------------------- 1 | # Calculator API 2 | 3 | This section can contain Tutorials/Guides/Changelogs or any information that API providers want to expose to their users besides what is auto-generated by Apimatic. 4 | 5 | -------------------------------------------------------------------------------- /test/resources/build-inputs/default/content/guides/toc.yml: -------------------------------------------------------------------------------- 1 | toc: 2 | - group: My Guides 3 | items: 4 | - page: Guide Page 1 5 | file: guide1.md 6 | -------------------------------------------------------------------------------- /test/resources/build-inputs/default/content/toc.yml: -------------------------------------------------------------------------------- 1 | toc: 2 | - group: Getting Started 3 | items: 4 | # 'generate' specifies that this item will be auto-generated by APIMatic. A file/directory reference is not needed here 5 | - generate: How to Get Started 6 | from: getting-started 7 | - group: Guides 8 | dir: guides 9 | # The Endpoint and Models sections contain auto-generated content. The 'generate' and 'from' directives instruct APIMatic's Docs generation service 10 | # to generate and insert these sections here. 11 | - generate: API Endpoints 12 | from: endpoints 13 | - generate: Models 14 | from: models 15 | -------------------------------------------------------------------------------- /test/resources/build-inputs/default/spec/APIMATIC-META.json: -------------------------------------------------------------------------------- 1 | { 2 | "ImportSettings": { 3 | "AutoGenerateTestCases": false, 4 | "ImportAdditionalHeader": false, 5 | "ImportAdditionalTypeCombinatorModels": false, 6 | "ImportTypeCombinatorsWithOnlyOneType": false 7 | }, 8 | "CodeGenSettings": { 9 | "Timeout": 30, 10 | "ValidateRequiredParameters": true, 11 | "AddSingleAuthDeprecatedCode": false, 12 | "EnableGlobalUserAgent": true, 13 | "UserAgent": "{language}-SDK/{version} [OS: {os-info}, Engine: {engine}/{engine-version}]", 14 | "EnableLogging": true, 15 | "EnableModelKeywordArgsInRuby": true, 16 | "SymbolizeHashKeysInRuby": true, 17 | "ReturnCompleteHttpResponse": true, 18 | "UserConfigurableRetries": true, 19 | "UseEnumPrefix": false, 20 | "ExtendedAdditionalPropertiesSupport": true, 21 | "EnforceStandardizedCasing": true, 22 | "ControllerPostfix": "Api", 23 | "DoNotSplitWords": [ 24 | "oauth" 25 | ] 26 | } 27 | } -------------------------------------------------------------------------------- /test/resources/build-inputs/default/spec/Apimatic-Calculator.json: -------------------------------------------------------------------------------- 1 | { 2 | "openapi": "3.0.0", 3 | "info": { 4 | "title": "APIMATIC Calculator-test", 5 | "description": "Simple calculator API hosted on APIMATIC for demo purposes", 6 | "contact": {}, 7 | "version": "2.3" 8 | }, 9 | "servers": [ 10 | { 11 | "url": "https://examples.apimatic.io/apps/calculator", 12 | "description": "This environment connect to the LIVE calculator API", 13 | "variables": {} 14 | } 15 | ], 16 | "paths": { 17 | "/{operation}": { 18 | "get": { 19 | "tags": [ 20 | "Simple Calculator" 21 | ], 22 | "summary": "Calculate", 23 | "description": "Calculates the expression using the specified operation.", 24 | "operationId": "Calculate", 25 | "parameters": [ 26 | { 27 | "name": "operation", 28 | "in": "path", 29 | "description": "The operator to apply on the variables", 30 | "required": true, 31 | "style": "simple", 32 | "schema": { 33 | "$ref": "#/components/schemas/OperationType" 34 | } 35 | }, 36 | { 37 | "name": "x", 38 | "in": "query", 39 | "description": "The LHS value", 40 | "required": true, 41 | "style": "form", 42 | "explode": true, 43 | "schema": { 44 | "type": "number", 45 | "format": "double" 46 | } 47 | }, 48 | { 49 | "name": "y", 50 | "in": "query", 51 | "description": "The RHS value", 52 | "required": true, 53 | "style": "form", 54 | "explode": true, 55 | "schema": { 56 | "type": "number", 57 | "format": "double" 58 | } 59 | } 60 | ], 61 | "responses": { 62 | "200": { 63 | "description": "", 64 | "headers": {}, 65 | "content": { 66 | "text/plain": { 67 | "schema": { 68 | "type": "number", 69 | "format": "double" 70 | } 71 | } 72 | } 73 | } 74 | }, 75 | "deprecated": false 76 | } 77 | } 78 | }, 79 | "components": { 80 | "schemas": { 81 | "OperationType": { 82 | "title": "OperationType", 83 | "enum": [ 84 | "SUM", 85 | "SUBTRACT", 86 | "MULTIPLY", 87 | "DIVIDE" 88 | ], 89 | "type": "string", 90 | "description": "Possible operators are sum, subtract, multiply, divide", 91 | "example": "SUM" 92 | } 93 | } 94 | }, 95 | "security": [ 96 | {} 97 | ], 98 | "tags": [ 99 | { 100 | "name": "Simple Calculator", 101 | "description": "" 102 | } 103 | ] 104 | } 105 | -------------------------------------------------------------------------------- /test/resources/build-inputs/default/static/images/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/apimatic/apimatic-cli/0ee9f5035f97803035fcc319718fe9e6c672d022/test/resources/build-inputs/default/static/images/logo.png -------------------------------------------------------------------------------- /test/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../tsconfig", 3 | "compilerOptions": { 4 | "noEmit": true, 5 | "composite": true 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "declaration": true, 4 | "importHelpers": true, 5 | "skipLibCheck": true, 6 | "module": "nodenext", 7 | "outDir": "lib", 8 | "rootDir": "src", 9 | "strict": true, 10 | "target": "es2017", 11 | "lib": ["es2018", "dom"], 12 | "sourceMap": true, 13 | "moduleResolution": "nodenext", 14 | "allowSyntheticDefaultImports": true, 15 | }, 16 | "include": [ 17 | "src/**/*" 18 | ], 19 | "ts-node": { 20 | "esm": true 21 | } 22 | } 23 | --------------------------------------------------------------------------------