From 5d509682696513d5fdd1de51b1b507b24dd8cd18 Mon Sep 17 00:00:00 2001 From: Muhammad Sohail <62895181+sohail2721@users.noreply.github.com> Date: Tue, 14 Jul 2026 12:22:40 +0500 Subject: [PATCH] feat(quickstart): prune build file to subscription before generating (#294) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(quickstart): prune the build file to the subscription before generating Fixes portal quickstart failing for Starter/Basic plans, whose scaffolded APIMATIC-BUILD.json enables AI features (API Copilot + AI context plugins) and SDK languages the plan doesn't include, causing codegen to reject the build. - Fail fast when a selected SDK language isn't on the plan (from the account's allowedLanguages), with an upgrade hint. - After writing the build file, POST it to the platform's /build-features/prune endpoint, overwrite it with the pruned result, and report what was removed. Fail closed: a prune failure aborts rather than serving an ungenerable build. Co-Authored-By: Claude Opus 4.8 (1M context) * feat(quickstart): only offer SDK languages included in the plan The quickstart language prompts now list only the SDK languages the account's subscription includes. Languages not on the plan are omitted from the choices, and a note listing them with an upgrade link is shown before the language selection — instead of failing after the fact. Both the portal (multi-select) and SDK (single-select) flows fetch account info before the language step and share a LANGUAGE_CHOICES list. Also defer the 'API Copilot is enabled' caution until after the build file is pruned, and only print it when Copilot survived the prune, so it is no longer shown for accounts whose plan doesn't include Copilot. Co-Authored-By: Claude Opus 4.8 (1M context) * chore(copilot): simplify default welcome example prompt Change the second example prompt in the default API Copilot welcome message from 'What endpoints are available in this API?' to the more approachable 'What is this API about?'. Co-Authored-By: Claude Opus 4.8 (1M context) * feat(quickstart): stop on the free plan when no SDK languages are available When the account's plan includes no SDK languages (e.g. the Free plan), both quickstart flows now stop before the language step with a clear upgrade message instead of rendering an empty selection prompt. Co-Authored-By: Claude Opus 4.8 (1M context) * fix(quickstart): check the plan before importing or pruning a spec Move the account-info fetch and free-plan guard to the start of both quickstart flows, before the spec import/validation/prune steps. A free-plan user is now stopped up front instead of first being told components will be removed 'before proceeding' and then cancelled. Co-Authored-By: Claude Opus 4.8 (1M context) * refactor(quickstart): extract shared plan resolution to remove duplication Both quickstart flows repeated the same account-info fetch and free-plan guard. Extract it into resolveQuickstartPlan, which returns the resolved plan or the ActionResult to return, cutting the duplicated block from the portal and SDK actions. Co-Authored-By: Claude Opus 4.8 (1M context) * Revert "refactor(quickstart): extract shared plan resolution to remove duplication" This reverts commit 2d383c5820ef2d608575024315855e122fa5b96b. * wrap link in to f.link * refactor(quickstart): return a rich BuildConfig from the prune service The prune endpoint response was typed unknown and written back with a raw JSON.stringify. Convert it to BuildConfig at the service boundary via a new BuildConfig.from factory and persist it through BuildContext.updateBuildFileContents. The Copilot caution now checks hasApiCopilot() on the pruned config instead of the prune report. Co-Authored-By: Claude Fable 5 * feat(prune): consume the build-config prune zip response pruneBuildFile reads the endpoint's zip (APIMATIC-BUILD.json + report.json) via adm-zip instead of a JSON body, keeping the same PruneBuildFileResponse shape. Co-Authored-By: Claude Opus 4.8 (1M context) * refactor(prune): read the prune zip via ZipService Add an in-memory readEntry to ZipService so adm-zip stays encapsulated there, and drop the direct adm-zip usage from ValidationService. Co-Authored-By: Claude Fable 5 * update msg * refactor(prune): route the prune path through BuildContext Reuse BuildContext.buildFilePath() instead of reconstructing the APIMATIC-BUILD.json FilePath in the action, removing the duplicated filename literal and leaked path knowledge. Co-Authored-By: Claude Opus 4.8 (1M context) * chore: stop tracking scratchpad directory Co-Authored-By: Claude Opus 4.8 (1M context) * feat(quickstart): gate portal quickstart on on-prem generation Portal quickstart now exits early when the plan doesn't allow on-prem generation (isOnPremGenerationAllowed from /account/profile) instead of when the plan has no SDK languages. With zero SDK languages the language menu is skipped and the portal is generated with HTTP documentation only. SDK quickstart keeps its allowed-languages gate. Co-Authored-By: Claude Fable 5 * rename * refactor(prune): extract the prune zip to disk instead of reading it in-memory Stream the /build-features/prune response straight to a temp zip, extract it via ZipService.unArchive, then read APIMATIC-BUILD.json and report.json back off disk. Drops the in-memory Buffer/AdmZip handling and lets ZipService.readEntry go away entirely. The prune error parser now consumes the streamed body, matching the sibling strip flow. Co-Authored-By: Claude Opus 4.8 (1M context) * badrequest->serverError --------- Co-authored-by: Claude Opus 4.8 (1M context) --- src/actions/portal/copilot.ts | 2 +- src/actions/portal/quickstart.ts | 68 ++++++++--- src/actions/sdk/quickstart.ts | 21 +++- .../services/validation-service.ts | 108 +++++++++++++++++- src/prompts/portal/quickstart.ts | 85 +++++++++++--- src/prompts/sdk/quickstart.ts | 48 +++++--- src/types/api/account.ts | 1 + src/types/build-context.ts | 5 + src/types/build/build.ts | 6 +- src/types/sdk/generate.ts | 16 +++ 10 files changed, 309 insertions(+), 51 deletions(-) diff --git a/src/actions/portal/copilot.ts b/src/actions/portal/copilot.ts index b46d6406..36d2836e 100644 --- a/src/actions/portal/copilot.ts +++ b/src/actions/portal/copilot.ts @@ -21,7 +21,7 @@ export const DEFAULT_COPILOT_WELCOME_MESSAGE = "Ask me anything about this API or try one of these example prompts:\n" + "\n" + "- `What authentication methods does this API support?`\n" + - "- `What endpoints are available in this API?`\n" ; + "- `What is this API about?`\n" ; export class CopilotAction { private readonly apiService = new ApiService(); diff --git a/src/actions/portal/quickstart.ts b/src/actions/portal/quickstart.ts index 090366d1..dd519002 100644 --- a/src/actions/portal/quickstart.ts +++ b/src/actions/portal/quickstart.ts @@ -19,6 +19,7 @@ import { FeaturesToRemove, ValidationService } from '../../infrastructure/servic import { FileName } from '../../types/file/fileName.js'; import { ApiService } from '../../infrastructure/services/api-service.js'; import { DEFAULT_COPILOT_WELCOME_MESSAGE } from './copilot.js'; +import { mapLanguages } from '../../types/sdk/generate.js'; const defaultPort: number = 23513 as const; const defaultBaseUrl = new UrlPath(`http://localhost:${defaultPort}`); @@ -56,6 +57,22 @@ export class PortalQuickstartAction { } return await withDirPath(async (tempDirectory: DirectoryPath): Promise => { + // Fetch account info before anything else so the plan is known up front: it + // gates the on-prem generation exit below, feeds the language step the allowed + // SDK languages, and resolves the API Copilot key later. A lookup failure is fatal. + const accountInfo = await this.apiService.getAccountInfo(this.configDir, this.commandMetadata.shell, null); + if (accountInfo.isErr()) { + this.prompts.accountInfoFetchFailed(accountInfo.error); + return ActionResult.failed(); + } + // Quickstart generates the portal locally (on-prem); a plan that doesn't allow + // on-prem generation can't run it, so stop before importing or pruning a spec. + if (!accountInfo.value.isOnPremGenerationAllowed) { + this.prompts.onPremGenerationNotAllowedOnPlan(); + return ActionResult.cancelled(); + } + const allowedLanguages = mapLanguages(accountInfo.value.allowedLanguages); + // Step 1/4 this.prompts.importSpecStep(); @@ -131,10 +148,19 @@ export class PortalQuickstartAction { // Step 3/4 this.prompts.selectLanguagesStep(); - const languages = await this.prompts.selectLanguagesPrompt(); - if (!languages) { - this.prompts.noLanguagesSelected(); - return ActionResult.cancelled(); + let languages: string[]; + if (allowedLanguages.length === 0) { + // With no SDK languages on the plan there's nothing to select, so skip the + // menu and build the portal with HTTP documentation only. + this.prompts.httpOnlyPortalOnPlan(); + languages = ['http']; + } else { + const selectedLanguages = await this.prompts.selectLanguagesPrompt(allowedLanguages); + if (!selectedLanguages) { + this.prompts.noLanguagesSelected(); + return ActionResult.cancelled(); + } + languages = selectedLanguages; } // Step 4/4 @@ -161,15 +187,10 @@ export class PortalQuickstartAction { } // Resolve the API Copilot key to enable, if any, before setting up the source - // directory so the user decides on Copilot up front. The lookup failing is fatal; - // an account with no key continues silently (no Copilot); cancelling the multi-key - // selection aborts quickstart. - const accountInfo = await this.apiService.getAccountInfo(this.configDir, this.commandMetadata.shell, null); - if (accountInfo.isErr()) { - this.prompts.accountInfoFetchFailed(accountInfo.error); - return ActionResult.failed(); - } - + // directory. An account with no key continues silently (no Copilot); cancelling + // the multi-key selection aborts quickstart. (Account info was already fetched + // above for the language step.) Whether Copilot is actually on the plan is only + // known after the prune below, so the "enabled" caution is deferred until then. let copilotKey: string | undefined; const copilotKeys = accountInfo.value.ApiCopilotKeys ?? []; if (copilotKeys.length === 1) { @@ -181,9 +202,6 @@ export class PortalQuickstartAction { return ActionResult.cancelled(); } } - if (copilotKey) { - this.prompts.copilotEnabled(copilotKey); - } const masterBuildFile = await this.prompts.downloadBuildDirectory( this.fileDownloadService.downloadFile(this.buildFileUrl) @@ -214,6 +232,24 @@ export class PortalQuickstartAction { : baseConfig; await buildContext.updateBuildFileContents(buildConfig); + // Prune the build file to what the plan allows (SDK languages + AI features) + // before serving. Fail closed: a prune failure aborts rather than serving a + // build the plan can't generate. + const pruneResult = await this.validationService.pruneBuildFile(buildContext.buildConfigFilePath()); + if (pruneResult.isErr()) { + this.prompts.serviceError(pruneResult.error); + return ActionResult.failed(); + } + const { buildFile: prunedConfig, report } = pruneResult.value; + await buildContext.updateBuildFileContents(prunedConfig); + this.prompts.buildFilePruned(report); + + // Only surface the Copilot caution if Copilot survived the prune — i.e. it's + // actually on the plan. If it was stripped, buildFilePruned already reported it. + if (prunedConfig.hasApiCopilot() && copilotKey) { + this.prompts.copilotEnabled(copilotKey); + } + const specDirectory = sourceDirectory.join('spec'); const specContext = new SpecContext(specDirectory); await specContext.replaceDefaultSpec(specPath); diff --git a/src/actions/sdk/quickstart.ts b/src/actions/sdk/quickstart.ts index 037f7898..7628dd07 100644 --- a/src/actions/sdk/quickstart.ts +++ b/src/actions/sdk/quickstart.ts @@ -12,11 +12,12 @@ import { ValidateAction } from '../api/validate.js'; import { FileDownloadService } from '../../infrastructure/services/file-download-service.js'; import { FileService } from '../../infrastructure/file-service.js'; import { GenerateAction } from './generate.js'; -import { CodeGenerationVersion, Language, Stability } from '../../types/sdk/generate.js'; +import { CodeGenerationVersion, Language, mapLanguages, Stability } from '../../types/sdk/generate.js'; import { LauncherService } from '../../infrastructure/launcher-service.js'; import { ZipService } from '../../infrastructure/zip-service.js'; import { FileName } from '../../types/file/fileName.js'; import { FeaturesToRemove, ValidationService } from '../../infrastructure/services/validation-service.js'; +import { ApiService } from '../../infrastructure/services/api-service.js'; export class SdkQuickstartAction { private readonly prompts = new SdkQuickstartPrompts(); @@ -24,6 +25,7 @@ export class SdkQuickstartAction { private readonly fileService = new FileService(); private readonly launcherService = new LauncherService(); private readonly zipService = new ZipService(); + private readonly apiService = new ApiService(); private readonly validationService = new ValidationService(this.configDir); private readonly metadataFileUrl = new UrlPath( `https://raw.githubusercontent.com/apimatic/sample-docs-as-code-portal/refs/heads/master/src/spec/APIMATIC-META.json` @@ -44,6 +46,20 @@ export class SdkQuickstartAction { } return await withDirPath(async (tempDirectory: DirectoryPath): Promise => { + // Fetch account info before anything else so the plan is known up front: it + // gates the free-plan exit below and feeds the language step the allowed SDK + // languages. A lookup failure is fatal. + const accountInfo = await this.apiService.getAccountInfo(this.configDir, this.commandMetadata.shell, null); + if (accountInfo.isErr()) { + this.prompts.accountInfoFetchFailed(accountInfo.error); + return ActionResult.failed(); + } + // An SDK needs a language; with none on the plan (e.g. the free plan) there's + // nothing to generate, so stop before importing or pruning a spec. + if (mapLanguages(accountInfo.value.allowedLanguages).length === 0) { + this.prompts.noLanguagesAvailableOnPlan(); + return ActionResult.cancelled(); + } // Step 1/4 this.prompts.importSpecStep(); @@ -120,8 +136,7 @@ export class SdkQuickstartAction { // Step 3/4 this.prompts.selectLanguageStep(); - - const language = await this.prompts.selectLanguagePrompt(); + const language = await this.prompts.selectLanguagePrompt(mapLanguages(accountInfo.value.allowedLanguages)); if (!language) { this.prompts.noLanguageSelected(); return ActionResult.cancelled(); diff --git a/src/infrastructure/services/validation-service.ts b/src/infrastructure/services/validation-service.ts index 298b4f8f..8a20d753 100644 --- a/src/infrastructure/services/validation-service.ts +++ b/src/infrastructure/services/validation-service.ts @@ -14,12 +14,17 @@ import { AuthInfo, getAuthInfo } from "../../client-utils/auth-manager.js"; import { apiClientFactory } from "./api-client-factory.js"; import { err, ok, Result } from "neverthrow"; import { FilePath } from "../../types/file/filePath.js"; +import { FileName } from "../../types/file/fileName.js"; import { CommandMetadata } from "../../types/common/command-metadata.js"; import FormData from "form-data"; +import { ZipService } from "../zip-service.js"; +import { FileService } from "../file-service.js"; +import { withDirPath } from "../tmp-extensions.js"; import { handleServiceError, ServiceError } from "../service-error.js"; -import axios, { AxiosResponse } from "axios"; +import axios from "axios"; import { envInfo } from "../env-info.js"; import { Buffer } from "node:buffer"; +import { BuildConfig } from "../../types/build/build.js"; export enum RemovableFeature { Merging = 'Merging', @@ -53,8 +58,21 @@ export interface ValidateApiResponse { unallowedFeatures: UnallowedFeaturesResponse | null; } +export interface BuildFilePruneReport { + removedLanguages: string[]; + removedApiCopilot: boolean; + removedAiIntegration: boolean; +} + +export interface PruneBuildFileResponse { + buildFile: BuildConfig; + report: BuildFilePruneReport; +} + export class ValidationService { private readonly apiBaseUrl = "https://api.apimatic.io" as const; + private readonly zipService = new ZipService(); + private readonly fileService = new FileService(); constructor(private readonly configDir: DirectoryPath) {} @@ -132,6 +150,90 @@ export class ValidationService { } } + /** + * Prunes a build file down to what the user's subscription allows (SDK languages + + * AI features) via the platform, returning the pruned build file and a report of + * what was removed. The platform is the entitlement authority, so the build we + * submit for generation is never rejected for a build-file feature the plan lacks. + */ + public async pruneBuildFile( + buildConfigFilePath: FilePath, + authKey?: string | null + ): Promise> { + const authInfo: AuthInfo | null = await getAuthInfo(this.configDir.toString()); + const authorizationHeader = this.createAuthorizationHeader(authInfo, authKey ?? null); + + const formData = new FormData(); + formData.append("file", createReadStream(buildConfigFilePath.toString()), { + filename: "APIMATIC-BUILD.json", + contentType: "application/json" + }); + + const baseURL = envInfo.getBaseUrl() ?? this.apiBaseUrl; + + try { + const response = await axios({ + method: "POST", + url: `${baseURL}/build-features/prune`, + data: formData, + headers: { + ...formData.getHeaders(), + Authorization: authorizationHeader + }, + // The endpoint returns a zip (pruned APIMATIC-BUILD.json + report.json), + // streamed so it can be written straight to disk and unarchived. + responseType: "stream", + validateStatus: () => true + }); + + if (response.status >= 400) { + return err(await this.parsePruneErrorResponse(response)); + } + + // Persist the zip to a temp dir and extract it via ZipService, then read the + // two entries back off disk — no in-memory zip handling. + return await withDirPath>(async (tempDir) => { + const zipPath = new FilePath(tempDir, new FileName("prune-response.zip")); + await this.fileService.writeFile(zipPath, response.data); + + const extractDir = tempDir.join("prune"); + await this.zipService.unArchive(zipPath, extractDir); + + const prunedBuildConfigFile = new FilePath(extractDir, new FileName("APIMATIC-BUILD.json")); + const reportFile = new FilePath(extractDir, new FileName("report.json")); + if (!(await this.fileService.fileExists(prunedBuildConfigFile)) || !(await this.fileService.fileExists(reportFile))) { + return err(ServiceError.ServerError); + } + + const buildConfigFile = BuildConfig.parse(await this.fileService.getContents(prunedBuildConfigFile)); + const report = JSON.parse(await this.fileService.getContents(reportFile)) as BuildFilePruneReport; + return ok({ buildFile: buildConfigFile, report }); + }); + } catch (error: unknown) { + return err(handleServiceError(error)); + } + } + + /** Decodes a streamed error body into a ServiceError with a prune-specific fallback message. */ + private async parsePruneErrorResponse( + response: { status: number; data: AsyncIterable } + ): Promise { + const chunks: Buffer[] = []; + for await (const chunk of response.data) { + chunks.push(Buffer.from(chunk)); + } + const errorBody = Buffer.concat(chunks).toString("utf-8"); + + let message = `Error ${response.status}: Failed to prune the build file for your subscription.`; + try { + const body = JSON.parse(errorBody) as { errors?: { summary?: string[] }; message?: string; title?: string }; + message = body?.errors?.summary?.[0] ?? body?.message ?? body?.title ?? message; + } catch { + // Non-JSON / undecodable body — keep the default message. + } + return ServiceError.badRequest(message, {}); + } + private createAuthorizationHeader(authInfo: AuthInfo | null, overrideAuthKey: string | null): string { const key = overrideAuthKey || authInfo?.authKey; return `X-Auth-Key ${key ?? ""}`; @@ -158,7 +260,9 @@ export class ValidationService { return "Unexpected error occurred while validating API specification."; } - private async parseErrorResponse(response: AxiosResponse): Promise { + private async parseErrorResponse( + response: { status: number; data: AsyncIterable } + ): Promise { const chunks: Buffer[] = []; for await (const chunk of response.data) { chunks.push(Buffer.from(chunk)); diff --git a/src/prompts/portal/quickstart.ts b/src/prompts/portal/quickstart.ts index 7d46f41c..2f04cf63 100644 --- a/src/prompts/portal/quickstart.ts +++ b/src/prompts/portal/quickstart.ts @@ -1,5 +1,6 @@ import { Result } from "neverthrow"; import { isCancel, log, multiselect, select, text } from "@clack/prompts"; +import { Language, LANGUAGE_CHOICES } from "../../types/sdk/generate.js"; import { UrlPath } from "../../types/file/urlPath.js"; import { format as f, getTree } from "../format.js"; import { DirectoryPath } from "../../types/file/directoryPath.js"; @@ -9,11 +10,15 @@ import { Directory } from "../../types/file/directory.js"; import { createResourceInputFromInput, ResourceInput } from "../../types/file/resource-input.js"; import { FileDownloadResponse } from "../../infrastructure/services/file-download-service.js"; import { noteWrapped, withSpinner } from "../prompt.js"; -import { UnallowedFeaturesResponse } from "../../infrastructure/services/validation-service.js"; +import { + BuildFilePruneReport, + UnallowedFeaturesResponse +} from "../../infrastructure/services/validation-service.js"; const vscodeExtensionUrl = "https://marketplace.visualstudio.com/items?itemName=apimatic-developers.apimatic-for-vscode"; const referenceDocumentationUrl = "https://docs.apimatic.io/cli-getting-started/advanced-portal-setup"; +const pricingUrl = "https://www.apimatic.io/pricing"; export class PortalQuickstartPrompts { public importSpecStep() { @@ -88,7 +93,7 @@ export class PortalQuickstartPrompts { "To continue:", "- Remove these components from your API Specification and re-run this command.", "- Combine your split API Specification files into a single file. We can automatically remove unsupported components from single-file specs.", - "- Upgrade your subscription to unlock additional features: https://www.apimatic.io/pricing" + `- Upgrade your subscription to unlock additional features: ${f.link(pricingUrl)}` ].join("\n"); log.info(message); @@ -110,7 +115,7 @@ export class PortalQuickstartPrompts { endpointMessage, "", "You won't see these components in the generated SDKs or documentation.", - "Want to keep them? Upgrade your subscription to unlock additional features: https://www.apimatic.io/pricing" + `Want to keep them? Upgrade your subscription to unlock additional features: ${f.link(pricingUrl)}` ].join("\n"); log.info(message); @@ -120,22 +125,22 @@ export class PortalQuickstartPrompts { log.info(`Step 3 of 4: Select programming languages`); } - public async selectLanguagesPrompt(): Promise { + public async selectLanguagesPrompt(allowedLanguages: Language[]): Promise { + const allowed = new Set(allowedLanguages); + const available = LANGUAGE_CHOICES.filter(({ value }) => allowed.has(value)); + const excluded = LANGUAGE_CHOICES.filter(({ value }) => !allowed.has(value)); + + if (excluded.length > 0) { + log.info(this.languagesNotOnPlanNote(excluded.map(({ label }) => label))); + } + const languages = (await multiselect({ message: "Your API Portal will contain SDKs and SDK Documentation in the following Languages (HTTP is enabled by default). Press enter to continue with all languages, or use the arrow keys and space to customize your selection:", - options: [ - { label: "Typescript", value: "typescript" }, - { label: "Ruby", value: "ruby" }, - { label: "Python", value: "python" }, - { label: "Java", value: "java" }, - { label: "C#", value: "csharp" }, - { label: "PHP", value: "php" }, - { label: "Go", value: "go" } - ], - initialValues: ["typescript", "ruby", "python", "java", "csharp", "php", "go"], + options: available.map(({ label, value }) => ({ label, value })), + initialValues: available.map(({ value }) => value), required: false - })) as string[]; + })) as Language[]; if (isCancel(languages)) { return undefined; @@ -144,10 +149,60 @@ export class PortalQuickstartPrompts { return ["http", ...languages]; } + public httpOnlyPortalOnPlan(): void { + const message = [ + "Your current subscription plan doesn't include any SDK languages, so the portal will contain HTTP documentation only.", + `Upgrade your subscription to unlock SDK languages: ${f.link(pricingUrl)}` + ].join("\n"); + log.warn(message); + } + + public onPremGenerationNotAllowedOnPlan(): void { + const message = [ + "Your current subscription plan doesn't include on-prem generation.", + `Upgrade your subscription to get started: ${f.link(pricingUrl)}` + ].join("\n"); + log.warn(message); + } + + private languagesNotOnPlanNote(languages: string[]): string { + return [ + `The following languages aren't included in your current subscription plan`, + ...languages.map((language) => ` • ${language}`), + "", + `Upgrade your subscription to unlock them: ${f.link(pricingUrl)}` + ].join("\n"); + } + public noLanguagesSelected() { log.error("No programming languages were selected."); } + public buildFilePruned(report: BuildFilePruneReport): void { + const removed: string[] = []; + if (report.removedLanguages.length > 0) { + removed.push(`SDK languages: ${report.removedLanguages.join(", ")}`); + } + if (report.removedApiCopilot) { + removed.push("API Copilot"); + } + if (report.removedAiIntegration) { + removed.push("AI context plugins (Cursor / VS Code / Claude Code)"); + } + if (removed.length === 0) { + return; + } + + const message = [ + "Some portal features aren't on your current subscription plan and were removed before generation:", + "", + ...removed.map((item) => ` • ${item}`), + "", + `Upgrade your subscription to unlock these: ${f.link(pricingUrl)}` + ].join("\n"); + log.warn(message); + } + public selectInputDirectoryStep() { log.info(`Step 4 of 4: Generate source files for Docs as Code`); } diff --git a/src/prompts/sdk/quickstart.ts b/src/prompts/sdk/quickstart.ts index 8b682089..790d15fd 100644 --- a/src/prompts/sdk/quickstart.ts +++ b/src/prompts/sdk/quickstart.ts @@ -9,12 +9,13 @@ import { ServiceError } from "../../infrastructure/service-error.js"; import { DirectoryPath } from "../../types/file/directoryPath.js"; import { removeQuotes } from "../../utils/string-utils.js"; import { Directory } from "../../types/file/directory.js"; -import { Language } from "../../types/sdk/generate.js"; +import { Language, LANGUAGE_CHOICES } from "../../types/sdk/generate.js"; import { UnallowedFeaturesResponse } from "../../infrastructure/services/validation-service.js"; const vscodeExtensionUrl = "https://marketplace.visualstudio.com/items?itemName=apimatic-developers.apimatic-for-vscode"; const sdkCustomizationUrl = "https://docs.apimatic.io/generate-sdks/codegen-settings/codegen-settings-overview/"; +const pricingUrl = "https://www.apimatic.io/pricing"; export class SdkQuickstartPrompts { public importSpecStep() { @@ -58,7 +59,7 @@ export class SdkQuickstartPrompts { "To continue:", "- Remove these components from your API Specification and re-run this command.", "- Combine your split API Specification files into a single file. We can automatically remove unsupported components from single-file specs.", - "- Upgrade your subscription to unlock additional features: https://www.apimatic.io/pricing" + `- Upgrade your subscription to unlock additional features: ${f.link(pricingUrl)}` ].join("\n"); log.info(message); @@ -80,7 +81,7 @@ export class SdkQuickstartPrompts { endpointMessage, "", "You won't see these components in the generated SDKs or documentation.", - "Want to keep them? Upgrade your subscription to unlock additional features: https://www.apimatic.io/pricing" + `Want to keep them? Upgrade your subscription to unlock additional features: ${f.link(pricingUrl)}` ].join("\n"); log.info(message); @@ -143,18 +144,18 @@ export class SdkQuickstartPrompts { log.info(`Step 3 of 4: Select programming language`); } - public async selectLanguagePrompt(): Promise { + public async selectLanguagePrompt(allowedLanguages: Language[]): Promise { + const allowed = new Set(allowedLanguages); + const available = LANGUAGE_CHOICES.filter(({ value }) => allowed.has(value)); + const excluded = LANGUAGE_CHOICES.filter(({ value }) => !allowed.has(value)); + + if (excluded.length > 0) { + log.info(this.languagesNotOnPlanNote(excluded.map(({ label }) => label))); + } + const language = await select({ message: "Choose the programming language for your SDK:", - options: [ - { label: "Typescript", value: Language.TYPESCRIPT }, - { label: "Ruby", value: Language.RUBY }, - { label: "Python", value: Language.PYTHON }, - { label: "Java", value: Language.JAVA }, - { label: "C#", value: Language.CSHARP }, - { label: "PHP", value: Language.PHP }, - { label: "Go", value: Language.GO } - ] + options: available.map(({ label, value }) => ({ label, value })) }); if (isCancel(language)) { @@ -164,10 +165,31 @@ export class SdkQuickstartPrompts { return language; } + public noLanguagesAvailableOnPlan(): void { + const message = [ + "You're on the Free plan.", + `Upgrade your subscription to get started: ${f.link(pricingUrl)}` + ].join("\n"); + log.warn(message); + } + + private languagesNotOnPlanNote(languages: string[]): string { + return [ + `The following languages aren't included in your current subscription plan:`, + ...languages.map((language) => ` • ${language}`), + "", + `Upgrade your subscription to unlock them: ${f.link(pricingUrl)}` + ].join("\n"); + } + public noLanguageSelected() { log.error("No programming language was selected."); } + public accountInfoFetchFailed(serviceError: ServiceError) { + log.error(`Failed to fetch your account information. ${serviceError.errorMessage}`); + } + public selectInputDirectoryStep() { log.info(`Step 4 of 4: Setup directory for SDK Generation`); } diff --git a/src/types/api/account.ts b/src/types/api/account.ts index 1c33c5ab..96fa6cbc 100644 --- a/src/types/api/account.ts +++ b/src/types/api/account.ts @@ -6,5 +6,6 @@ export interface SubscriptionInfo { tenantId: string; allowedLanguages: number; // might be a comma-separated string or code — clarify if needed isPackagePublishingAllowed: boolean; + isOnPremGenerationAllowed: boolean; ApiCopilotKeys: string[]; } diff --git a/src/types/build-context.ts b/src/types/build-context.ts index cd9a4c78..a46f1bcc 100644 --- a/src/types/build-context.ts +++ b/src/types/build-context.ts @@ -19,6 +19,11 @@ export class BuildContext { return new FilePath(this.buildDirectory, new FileName("APIMATIC-BUILD.json")); } + /** The APIMATIC-BUILD.json path within this build directory, for callers that hand it to a service. */ + public buildConfigFilePath(): FilePath { + return this.buildFile; + } + public async validate(): Promise { // TODO: add more checks here if (!(await this.fileService.directoryExists(this.buildDirectory))) return false; diff --git a/src/types/build/build.ts b/src/types/build/build.ts index dd7ddd27..dac4f12c 100644 --- a/src/types/build/build.ts +++ b/src/types/build/build.ts @@ -166,7 +166,11 @@ export class BuildConfig { private constructor(private readonly data: BuildConfigData) {} public static parse(json: string): BuildConfig { - return new BuildConfig(JSON.parse(json) as BuildConfigData); + return BuildConfig.from(JSON.parse(json) as BuildConfigData); + } + + public static from(data: BuildConfigData): BuildConfig { + return new BuildConfig(data); } // Called implicitly by JSON.stringify when the config is written back to disk. diff --git a/src/types/sdk/generate.ts b/src/types/sdk/generate.ts index 67c3d4d8..106932c2 100644 --- a/src/types/sdk/generate.ts +++ b/src/types/sdk/generate.ts @@ -33,3 +33,19 @@ export function mapLanguages(languageFlag: number): Language[] { .filter(([flag]) => (languageFlag & parseInt(flag)) !== 0) .map(([, language]) => language); } + +/** + * The languages offered in the quickstart prompts, in display order. + * Shared by the portal (multi-select) and SDK (single-select) flows so both + * present the same list; the subscription's allowed languages decide which + * are selectable. + */ +export const LANGUAGE_CHOICES: ReadonlyArray<{ label: string; value: Language }> = [ + { label: "Typescript", value: Language.TYPESCRIPT }, + { label: "Ruby", value: Language.RUBY }, + { label: "Python", value: Language.PYTHON }, + { label: "Java", value: Language.JAVA }, + { label: "C#", value: Language.CSHARP }, + { label: "PHP", value: Language.PHP }, + { label: "Go", value: Language.GO } +];