From 70434496e874d5f56b35dc68dcc799de3f17a2be Mon Sep 17 00:00:00 2001 From: Sohail Date: Tue, 7 Jul 2026 16:24:55 +0500 Subject: [PATCH 01/18] 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) --- src/actions/portal/quickstart.ts | 26 ++++++++ .../services/validation-service.ts | 64 ++++++++++++++++++- src/prompts/portal/quickstart.ts | 42 +++++++++++- 3 files changed, 130 insertions(+), 2 deletions(-) diff --git a/src/actions/portal/quickstart.ts b/src/actions/portal/quickstart.ts index 090366d1..570410fd 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 { Language, mapLanguages } from '../../types/sdk/generate.js'; const defaultPort: number = 23513 as const; const defaultBaseUrl = new UrlPath(`http://localhost:${defaultPort}`); @@ -170,6 +171,16 @@ export class PortalQuickstartAction { return ActionResult.failed(); } + // Fail fast if a selected SDK language isn't on the plan (http docs are always allowed). + const allowedLanguages = mapLanguages(accountInfo.value.allowedLanguages); + const disallowedLanguages = languages.filter( + (language) => language !== 'http' && !allowedLanguages.includes(language as Language) + ); + if (disallowedLanguages.length > 0) { + this.prompts.languagesNotAllowed(disallowedLanguages); + return ActionResult.failed(); + } + let copilotKey: string | undefined; const copilotKeys = accountInfo.value.ApiCopilotKeys ?? []; if (copilotKeys.length === 1) { @@ -214,6 +225,21 @@ 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 buildFilePath = new FilePath(sourceDirectory, new FileName('APIMATIC-BUILD.json')); + const pruneResult = await this.validationService.pruneBuildFile(buildFilePath); + if (pruneResult.isErr()) { + this.prompts.serviceError(pruneResult.error); + return ActionResult.failed(); + } + await this.fileService.writeContents( + buildFilePath, + JSON.stringify(pruneResult.value.buildFile, null, 2) + ); + this.prompts.buildFilePruned(pruneResult.value.report); + const specDirectory = sourceDirectory.join('spec'); const specContext = new SpecContext(specDirectory); await specContext.replaceDefaultSpec(specPath); diff --git a/src/infrastructure/services/validation-service.ts b/src/infrastructure/services/validation-service.ts index a0ea11fe..f0cafec5 100644 --- a/src/infrastructure/services/validation-service.ts +++ b/src/infrastructure/services/validation-service.ts @@ -53,6 +53,17 @@ export interface ValidateApiResponse { unallowedFeatures: UnallowedFeaturesResponse | null; } +export interface BuildFilePruneReport { + removedLanguages: string[]; + removedApiCopilot: boolean; + removedAiIntegration: boolean; +} + +export interface PruneBuildFileResponse { + buildFile: unknown; + report: BuildFilePruneReport; +} + export class ValidationService { private readonly apiBaseUrl = "https://api.apimatic.io" as const; @@ -132,6 +143,55 @@ 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( + buildFilePath: 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(buildFilePath.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 + }, + validateStatus: () => true + }); + + if (response.status >= 400) { + const data = response.data as { errors?: { summary?: string[] }; message?: string; title?: string }; + const message = + data?.errors?.summary?.[0] ?? + data?.message ?? + data?.title ?? + `Error ${response.status}: Failed to prune the build file for your subscription.`; + return err(ServiceError.badRequest(message, {})); + } + + return ok(response.data as PruneBuildFileResponse); + } catch (error: unknown) { + return err(handleServiceError(error)); + } + } + private createAuthorizationHeader(authInfo: AuthInfo | null, overrideAuthKey: string | null): string { const key = overrideAuthKey || authInfo?.authKey; return `X-Auth-Key ${key ?? ""}`; @@ -158,7 +218,9 @@ export class ValidationService { return "Unexpected error occurred while validating API specification."; } - private async parseErrorResponse(response: any): 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..362ca2f0 100644 --- a/src/prompts/portal/quickstart.ts +++ b/src/prompts/portal/quickstart.ts @@ -9,7 +9,10 @@ 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"; @@ -148,6 +151,43 @@ export class PortalQuickstartPrompts { log.error("No programming languages were selected."); } + public languagesNotAllowed(languages: string[]): void { + const languagesList = languages.map((language) => ` • ${language}`).join("\n"); + const message = [ + "The following SDK languages are not available on your current subscription plan:", + "", + languagesList, + "", + "Select only the languages your plan includes, or upgrade your subscription to unlock more: https://www.apimatic.io/pricing" + ].join("\n"); + log.error(message); + } + + 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 build features aren't on your current subscription plan and were removed before generation:", + "", + ...removed.map((item) => ` • ${item}`), + "", + "Upgrade your subscription to unlock these: https://www.apimatic.io/pricing" + ].join("\n"); + log.warn(message); + } + public selectInputDirectoryStep() { log.info(`Step 4 of 4: Generate source files for Docs as Code`); } From eaed4deff47092e5fca37cf411c4bc587e3b036b Mon Sep 17 00:00:00 2001 From: Sohail Date: Wed, 8 Jul 2026 16:19:48 +0500 Subject: [PATCH 02/18] feat(quickstart): only offer SDK languages included in the plan MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- src/actions/portal/quickstart.ts | 46 +++++++++++++++----------------- src/actions/sdk/quickstart.ts | 15 +++++++++-- src/prompts/portal/quickstart.ts | 46 +++++++++++++++----------------- src/prompts/sdk/quickstart.ts | 35 ++++++++++++++++-------- src/types/sdk/generate.ts | 16 +++++++++++ 5 files changed, 97 insertions(+), 61 deletions(-) diff --git a/src/actions/portal/quickstart.ts b/src/actions/portal/quickstart.ts index 570410fd..448a4d10 100644 --- a/src/actions/portal/quickstart.ts +++ b/src/actions/portal/quickstart.ts @@ -19,7 +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 { Language, mapLanguages } from '../../types/sdk/generate.js'; +import { mapLanguages } from '../../types/sdk/generate.js'; const defaultPort: number = 23513 as const; const defaultBaseUrl = new UrlPath(`http://localhost:${defaultPort}`); @@ -130,9 +130,19 @@ export class PortalQuickstartAction { } } + // Fetch account info up front so the language step can offer only the SDK + // languages the plan allows, and so the API Copilot key (below) is resolved + // from the same lookup. 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(); + } + const allowedLanguages = mapLanguages(accountInfo.value.allowedLanguages); + // Step 3/4 this.prompts.selectLanguagesStep(); - const languages = await this.prompts.selectLanguagesPrompt(); + const languages = await this.prompts.selectLanguagesPrompt(allowedLanguages); if (!languages) { this.prompts.noLanguagesSelected(); return ActionResult.cancelled(); @@ -162,25 +172,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(); - } - - // Fail fast if a selected SDK language isn't on the plan (http docs are always allowed). - const allowedLanguages = mapLanguages(accountInfo.value.allowedLanguages); - const disallowedLanguages = languages.filter( - (language) => language !== 'http' && !allowedLanguages.includes(language as Language) - ); - if (disallowedLanguages.length > 0) { - this.prompts.languagesNotAllowed(disallowedLanguages); - 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) { @@ -192,9 +187,6 @@ export class PortalQuickstartAction { return ActionResult.cancelled(); } } - if (copilotKey) { - this.prompts.copilotEnabled(copilotKey); - } const masterBuildFile = await this.prompts.downloadBuildDirectory( this.fileDownloadService.downloadFile(this.buildFileUrl) @@ -240,6 +232,12 @@ export class PortalQuickstartAction { ); this.prompts.buildFilePruned(pruneResult.value.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 (copilotKey && !pruneResult.value.report.removedApiCopilot) { + 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..fcc2eca6 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` @@ -118,10 +120,19 @@ export class SdkQuickstartAction { } } + // Fetch account info so the language step only offers SDK languages the plan + // allows. 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(); + } + const allowedLanguages = mapLanguages(accountInfo.value.allowedLanguages); + // Step 3/4 this.prompts.selectLanguageStep(); - const language = await this.prompts.selectLanguagePrompt(); + const language = await this.prompts.selectLanguagePrompt(allowedLanguages); if (!language) { this.prompts.noLanguageSelected(); return ActionResult.cancelled(); diff --git a/src/prompts/portal/quickstart.ts b/src/prompts/portal/quickstart.ts index 362ca2f0..eb548720 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"; @@ -123,22 +124,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; @@ -147,20 +148,17 @@ export class PortalQuickstartPrompts { return ["http", ...languages]; } - public noLanguagesSelected() { - log.error("No programming languages were selected."); - } - - public languagesNotAllowed(languages: string[]): void { - const languagesList = languages.map((language) => ` • ${language}`).join("\n"); - const message = [ - "The following SDK languages are not available on your current subscription plan:", + private languagesNotOnPlanNote(languages: string[]): string { + return [ + `The following languages aren't included in your current subscription plan, so they aren't available to select:`, + ...languages.map((language) => ` • ${language}`), "", - languagesList, - "", - "Select only the languages your plan includes, or upgrade your subscription to unlock more: https://www.apimatic.io/pricing" + "Upgrade your subscription to unlock them: https://www.apimatic.io/pricing" ].join("\n"); - log.error(message); + } + + public noLanguagesSelected() { + log.error("No programming languages were selected."); } public buildFilePruned(report: BuildFilePruneReport): void { diff --git a/src/prompts/sdk/quickstart.ts b/src/prompts/sdk/quickstart.ts index 8b682089..708900fc 100644 --- a/src/prompts/sdk/quickstart.ts +++ b/src/prompts/sdk/quickstart.ts @@ -9,7 +9,7 @@ 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 = @@ -143,18 +143,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 +164,23 @@ export class SdkQuickstartPrompts { return language; } + private languagesNotOnPlanNote(languages: string[]): string { + return [ + `The following languages aren't included in your current subscription plan, so they aren't available to select:`, + ...languages.map((language) => ` • ${language}`), + "", + "Upgrade your subscription to unlock them: https://www.apimatic.io/pricing" + ].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/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 } +]; From 7acb1360d57c6b21b532c250da51853c0a969fed Mon Sep 17 00:00:00 2001 From: Sohail Date: Wed, 8 Jul 2026 16:36:56 +0500 Subject: [PATCH 03/18] 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) --- src/actions/portal/copilot.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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(); From d1ac57f479bafd5c8704e97a7d8d3ddef0a6ce0b Mon Sep 17 00:00:00 2001 From: Sohail Date: Wed, 8 Jul 2026 17:21:29 +0500 Subject: [PATCH 04/18] 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) --- src/actions/portal/quickstart.ts | 6 ++++++ src/actions/sdk/quickstart.ts | 6 ++++++ src/prompts/portal/quickstart.ts | 10 +++++++++- src/prompts/sdk/quickstart.ts | 10 +++++++++- 4 files changed, 30 insertions(+), 2 deletions(-) diff --git a/src/actions/portal/quickstart.ts b/src/actions/portal/quickstart.ts index 448a4d10..3d5dae54 100644 --- a/src/actions/portal/quickstart.ts +++ b/src/actions/portal/quickstart.ts @@ -139,6 +139,12 @@ export class PortalQuickstartAction { return ActionResult.failed(); } const allowedLanguages = mapLanguages(accountInfo.value.allowedLanguages); + // Quickstart builds a portal around SDKs; with no SDK languages on the plan + // (e.g. the free plan) there's nothing to generate, so stop with guidance. + if (allowedLanguages.length === 0) { + this.prompts.noLanguagesAvailableOnPlan(); + return ActionResult.cancelled(); + } // Step 3/4 this.prompts.selectLanguagesStep(); diff --git a/src/actions/sdk/quickstart.ts b/src/actions/sdk/quickstart.ts index fcc2eca6..7446f8b8 100644 --- a/src/actions/sdk/quickstart.ts +++ b/src/actions/sdk/quickstart.ts @@ -128,6 +128,12 @@ export class SdkQuickstartAction { return ActionResult.failed(); } const allowedLanguages = mapLanguages(accountInfo.value.allowedLanguages); + // An SDK needs a language; with none on the plan (e.g. the free plan) there's + // nothing to generate, so stop with guidance instead of an empty prompt. + if (allowedLanguages.length === 0) { + this.prompts.noLanguagesAvailableOnPlan(); + return ActionResult.cancelled(); + } // Step 3/4 this.prompts.selectLanguageStep(); diff --git a/src/prompts/portal/quickstart.ts b/src/prompts/portal/quickstart.ts index eb548720..a0b8ccbc 100644 --- a/src/prompts/portal/quickstart.ts +++ b/src/prompts/portal/quickstart.ts @@ -148,9 +148,17 @@ export class PortalQuickstartPrompts { return ["http", ...languages]; } + public noLanguagesAvailableOnPlan(): void { + const message = [ + "You're on the Free plan", + "Upgrade your subscription to get started: https://www.apimatic.io/pricing" + ].join("\n"); + log.warn(message); + } + private languagesNotOnPlanNote(languages: string[]): string { return [ - `The following languages aren't included in your current subscription plan, so they aren't available to select:`, + `The following languages aren't included in your current subscription plan`, ...languages.map((language) => ` • ${language}`), "", "Upgrade your subscription to unlock them: https://www.apimatic.io/pricing" diff --git a/src/prompts/sdk/quickstart.ts b/src/prompts/sdk/quickstart.ts index 708900fc..fb5c1c69 100644 --- a/src/prompts/sdk/quickstart.ts +++ b/src/prompts/sdk/quickstart.ts @@ -164,9 +164,17 @@ export class SdkQuickstartPrompts { return language; } + public noLanguagesAvailableOnPlan(): void { + const message = [ + "You're on the Free plan.", + "Upgrade your subscription to get started: https://www.apimatic.io/pricing" + ].join("\n"); + log.warn(message); + } + private languagesNotOnPlanNote(languages: string[]): string { return [ - `The following languages aren't included in your current subscription plan, so they aren't available to select:`, + `The following languages aren't included in your current subscription plan:`, ...languages.map((language) => ` • ${language}`), "", "Upgrade your subscription to unlock them: https://www.apimatic.io/pricing" From eeb7d4d31889d20ce5b90f05c2a647c2a5645380 Mon Sep 17 00:00:00 2001 From: Sohail Date: Wed, 8 Jul 2026 17:24:52 +0500 Subject: [PATCH 05/18] 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) --- src/actions/portal/quickstart.ts | 33 ++++++++++++++++---------------- src/actions/sdk/quickstart.ts | 31 +++++++++++++++--------------- 2 files changed, 33 insertions(+), 31 deletions(-) diff --git a/src/actions/portal/quickstart.ts b/src/actions/portal/quickstart.ts index 3d5dae54..bef02808 100644 --- a/src/actions/portal/quickstart.ts +++ b/src/actions/portal/quickstart.ts @@ -57,6 +57,23 @@ 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 free-plan 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(); + } + const allowedLanguages = mapLanguages(accountInfo.value.allowedLanguages); + // Quickstart builds a portal around SDKs; with no SDK languages on the plan + // (e.g. the free plan) there's nothing to generate, so stop before importing + // or pruning a spec. + if (allowedLanguages.length === 0) { + this.prompts.noLanguagesAvailableOnPlan(); + return ActionResult.cancelled(); + } + // Step 1/4 this.prompts.importSpecStep(); @@ -130,22 +147,6 @@ export class PortalQuickstartAction { } } - // Fetch account info up front so the language step can offer only the SDK - // languages the plan allows, and so the API Copilot key (below) is resolved - // from the same lookup. 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(); - } - const allowedLanguages = mapLanguages(accountInfo.value.allowedLanguages); - // Quickstart builds a portal around SDKs; with no SDK languages on the plan - // (e.g. the free plan) there's nothing to generate, so stop with guidance. - if (allowedLanguages.length === 0) { - this.prompts.noLanguagesAvailableOnPlan(); - return ActionResult.cancelled(); - } - // Step 3/4 this.prompts.selectLanguagesStep(); const languages = await this.prompts.selectLanguagesPrompt(allowedLanguages); diff --git a/src/actions/sdk/quickstart.ts b/src/actions/sdk/quickstart.ts index 7446f8b8..8298d570 100644 --- a/src/actions/sdk/quickstart.ts +++ b/src/actions/sdk/quickstart.ts @@ -46,6 +46,22 @@ 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(); + } + const allowedLanguages = mapLanguages(accountInfo.value.allowedLanguages); + // 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 (allowedLanguages.length === 0) { + this.prompts.noLanguagesAvailableOnPlan(); + return ActionResult.cancelled(); + } + // Step 1/4 this.prompts.importSpecStep(); @@ -120,21 +136,6 @@ export class SdkQuickstartAction { } } - // Fetch account info so the language step only offers SDK languages the plan - // allows. 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(); - } - const allowedLanguages = mapLanguages(accountInfo.value.allowedLanguages); - // An SDK needs a language; with none on the plan (e.g. the free plan) there's - // nothing to generate, so stop with guidance instead of an empty prompt. - if (allowedLanguages.length === 0) { - this.prompts.noLanguagesAvailableOnPlan(); - return ActionResult.cancelled(); - } - // Step 3/4 this.prompts.selectLanguageStep(); From 2d383c5820ef2d608575024315855e122fa5b96b Mon Sep 17 00:00:00 2001 From: Sohail Date: Thu, 9 Jul 2026 09:46:50 +0500 Subject: [PATCH 06/18] 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) --- src/actions/portal/quickstart.ts | 26 ++++++------------ src/actions/quickstart-plan.ts | 47 ++++++++++++++++++++++++++++++++ src/actions/sdk/quickstart.ts | 24 ++++++---------- 3 files changed, 65 insertions(+), 32 deletions(-) create mode 100644 src/actions/quickstart-plan.ts diff --git a/src/actions/portal/quickstart.ts b/src/actions/portal/quickstart.ts index bef02808..705bc144 100644 --- a/src/actions/portal/quickstart.ts +++ b/src/actions/portal/quickstart.ts @@ -19,7 +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'; +import { resolveQuickstartPlan } from '../quickstart-plan.js'; const defaultPort: number = 23513 as const; const defaultBaseUrl = new UrlPath(`http://localhost:${defaultPort}`); @@ -57,22 +57,14 @@ 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 free-plan 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(); - } - const allowedLanguages = mapLanguages(accountInfo.value.allowedLanguages); - // Quickstart builds a portal around SDKs; with no SDK languages on the plan - // (e.g. the free plan) there's nothing to generate, so stop before importing - // or pruning a spec. - if (allowedLanguages.length === 0) { - this.prompts.noLanguagesAvailableOnPlan(); - return ActionResult.cancelled(); + // Resolve the plan before anything else: it gates the free-plan exit, feeds + // the language step the allowed SDK languages, and its account info resolves + // the API Copilot key later. Stops before importing or pruning a spec. + const plan = await resolveQuickstartPlan(this.apiService, this.configDir, this.commandMetadata, this.prompts); + if (plan.isErr()) { + return plan.error; } + const { accountInfo, allowedLanguages } = plan.value; // Step 1/4 this.prompts.importSpecStep(); @@ -184,7 +176,7 @@ export class PortalQuickstartAction { // 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 ?? []; + const copilotKeys = accountInfo.ApiCopilotKeys ?? []; if (copilotKeys.length === 1) { copilotKey = copilotKeys[0]; } else if (copilotKeys.length > 1) { diff --git a/src/actions/quickstart-plan.ts b/src/actions/quickstart-plan.ts new file mode 100644 index 00000000..6b8c8240 --- /dev/null +++ b/src/actions/quickstart-plan.ts @@ -0,0 +1,47 @@ +import { err, ok, Result } from "neverthrow"; +import { ActionResult } from "./action-result.js"; +import { ApiService } from "../infrastructure/services/api-service.js"; +import { ServiceError } from "../infrastructure/service-error.js"; +import { DirectoryPath } from "../types/file/directoryPath.js"; +import { CommandMetadata } from "../types/common/command-metadata.js"; +import { SubscriptionInfo } from "../types/api/account.js"; +import { Language, mapLanguages } from "../types/sdk/generate.js"; + +/** Prompts the plan resolution needs to report failures — satisfied by both quickstart prompt classes. */ +export interface QuickstartPlanPrompts { + accountInfoFetchFailed(error: ServiceError): void; + noLanguagesAvailableOnPlan(): void; +} + +export interface QuickstartPlan { + accountInfo: SubscriptionInfo; + allowedLanguages: Language[]; +} + +/** + * Fetches account info and resolves the SDK languages the plan allows, shared by + * the portal and SDK quickstart flows. On the error branch it has already shown + * the relevant prompt and carries the {@link ActionResult} the caller should + * return: a failure if the lookup fails, or a cancellation when the plan includes + * no SDK languages (e.g. the free plan) so quickstart stops before touching a spec. + */ +export async function resolveQuickstartPlan( + apiService: ApiService, + configDir: DirectoryPath, + commandMetadata: CommandMetadata, + prompts: QuickstartPlanPrompts +): Promise> { + const accountInfo = await apiService.getAccountInfo(configDir, commandMetadata.shell, null); + if (accountInfo.isErr()) { + prompts.accountInfoFetchFailed(accountInfo.error); + return err(ActionResult.failed()); + } + + const allowedLanguages = mapLanguages(accountInfo.value.allowedLanguages); + if (allowedLanguages.length === 0) { + prompts.noLanguagesAvailableOnPlan(); + return err(ActionResult.cancelled()); + } + + return ok({ accountInfo: accountInfo.value, allowedLanguages }); +} diff --git a/src/actions/sdk/quickstart.ts b/src/actions/sdk/quickstart.ts index 8298d570..8f6ca7ed 100644 --- a/src/actions/sdk/quickstart.ts +++ b/src/actions/sdk/quickstart.ts @@ -12,7 +12,8 @@ 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, mapLanguages, Stability } from '../../types/sdk/generate.js'; +import { CodeGenerationVersion, Language, Stability } from '../../types/sdk/generate.js'; +import { resolveQuickstartPlan } from '../quickstart-plan.js'; import { LauncherService } from '../../infrastructure/launcher-service.js'; import { ZipService } from '../../infrastructure/zip-service.js'; import { FileName } from '../../types/file/fileName.js'; @@ -46,21 +47,14 @@ 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(); - } - const allowedLanguages = mapLanguages(accountInfo.value.allowedLanguages); - // 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 (allowedLanguages.length === 0) { - this.prompts.noLanguagesAvailableOnPlan(); - return ActionResult.cancelled(); + // Resolve the plan before anything else: it gates the free-plan exit and + // feeds the language step the allowed SDK languages. Stops before importing + // or pruning a spec. + const plan = await resolveQuickstartPlan(this.apiService, this.configDir, this.commandMetadata, this.prompts); + if (plan.isErr()) { + return plan.error; } + const { allowedLanguages } = plan.value; // Step 1/4 this.prompts.importSpecStep(); From c95a1657777d4d929e1c446a9878ca6b0270b506 Mon Sep 17 00:00:00 2001 From: Sohail Date: Thu, 9 Jul 2026 14:48:24 +0500 Subject: [PATCH 07/18] Revert "refactor(quickstart): extract shared plan resolution to remove duplication" This reverts commit 2d383c5820ef2d608575024315855e122fa5b96b. --- src/actions/portal/quickstart.ts | 26 ++++++++++++------ src/actions/quickstart-plan.ts | 47 -------------------------------- src/actions/sdk/quickstart.ts | 24 ++++++++++------ 3 files changed, 32 insertions(+), 65 deletions(-) delete mode 100644 src/actions/quickstart-plan.ts diff --git a/src/actions/portal/quickstart.ts b/src/actions/portal/quickstart.ts index 705bc144..bef02808 100644 --- a/src/actions/portal/quickstart.ts +++ b/src/actions/portal/quickstart.ts @@ -19,7 +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 { resolveQuickstartPlan } from '../quickstart-plan.js'; +import { mapLanguages } from '../../types/sdk/generate.js'; const defaultPort: number = 23513 as const; const defaultBaseUrl = new UrlPath(`http://localhost:${defaultPort}`); @@ -57,14 +57,22 @@ export class PortalQuickstartAction { } return await withDirPath(async (tempDirectory: DirectoryPath): Promise => { - // Resolve the plan before anything else: it gates the free-plan exit, feeds - // the language step the allowed SDK languages, and its account info resolves - // the API Copilot key later. Stops before importing or pruning a spec. - const plan = await resolveQuickstartPlan(this.apiService, this.configDir, this.commandMetadata, this.prompts); - if (plan.isErr()) { - return plan.error; + // Fetch account info before anything else so the plan is known up front: it + // gates the free-plan 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(); + } + const allowedLanguages = mapLanguages(accountInfo.value.allowedLanguages); + // Quickstart builds a portal around SDKs; with no SDK languages on the plan + // (e.g. the free plan) there's nothing to generate, so stop before importing + // or pruning a spec. + if (allowedLanguages.length === 0) { + this.prompts.noLanguagesAvailableOnPlan(); + return ActionResult.cancelled(); } - const { accountInfo, allowedLanguages } = plan.value; // Step 1/4 this.prompts.importSpecStep(); @@ -176,7 +184,7 @@ export class PortalQuickstartAction { // 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.ApiCopilotKeys ?? []; + const copilotKeys = accountInfo.value.ApiCopilotKeys ?? []; if (copilotKeys.length === 1) { copilotKey = copilotKeys[0]; } else if (copilotKeys.length > 1) { diff --git a/src/actions/quickstart-plan.ts b/src/actions/quickstart-plan.ts deleted file mode 100644 index 6b8c8240..00000000 --- a/src/actions/quickstart-plan.ts +++ /dev/null @@ -1,47 +0,0 @@ -import { err, ok, Result } from "neverthrow"; -import { ActionResult } from "./action-result.js"; -import { ApiService } from "../infrastructure/services/api-service.js"; -import { ServiceError } from "../infrastructure/service-error.js"; -import { DirectoryPath } from "../types/file/directoryPath.js"; -import { CommandMetadata } from "../types/common/command-metadata.js"; -import { SubscriptionInfo } from "../types/api/account.js"; -import { Language, mapLanguages } from "../types/sdk/generate.js"; - -/** Prompts the plan resolution needs to report failures — satisfied by both quickstart prompt classes. */ -export interface QuickstartPlanPrompts { - accountInfoFetchFailed(error: ServiceError): void; - noLanguagesAvailableOnPlan(): void; -} - -export interface QuickstartPlan { - accountInfo: SubscriptionInfo; - allowedLanguages: Language[]; -} - -/** - * Fetches account info and resolves the SDK languages the plan allows, shared by - * the portal and SDK quickstart flows. On the error branch it has already shown - * the relevant prompt and carries the {@link ActionResult} the caller should - * return: a failure if the lookup fails, or a cancellation when the plan includes - * no SDK languages (e.g. the free plan) so quickstart stops before touching a spec. - */ -export async function resolveQuickstartPlan( - apiService: ApiService, - configDir: DirectoryPath, - commandMetadata: CommandMetadata, - prompts: QuickstartPlanPrompts -): Promise> { - const accountInfo = await apiService.getAccountInfo(configDir, commandMetadata.shell, null); - if (accountInfo.isErr()) { - prompts.accountInfoFetchFailed(accountInfo.error); - return err(ActionResult.failed()); - } - - const allowedLanguages = mapLanguages(accountInfo.value.allowedLanguages); - if (allowedLanguages.length === 0) { - prompts.noLanguagesAvailableOnPlan(); - return err(ActionResult.cancelled()); - } - - return ok({ accountInfo: accountInfo.value, allowedLanguages }); -} diff --git a/src/actions/sdk/quickstart.ts b/src/actions/sdk/quickstart.ts index 8f6ca7ed..8298d570 100644 --- a/src/actions/sdk/quickstart.ts +++ b/src/actions/sdk/quickstart.ts @@ -12,8 +12,7 @@ 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 { resolveQuickstartPlan } from '../quickstart-plan.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'; @@ -47,14 +46,21 @@ export class SdkQuickstartAction { } return await withDirPath(async (tempDirectory: DirectoryPath): Promise => { - // Resolve the plan before anything else: it gates the free-plan exit and - // feeds the language step the allowed SDK languages. Stops before importing - // or pruning a spec. - const plan = await resolveQuickstartPlan(this.apiService, this.configDir, this.commandMetadata, this.prompts); - if (plan.isErr()) { - return plan.error; + // 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(); + } + const allowedLanguages = mapLanguages(accountInfo.value.allowedLanguages); + // 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 (allowedLanguages.length === 0) { + this.prompts.noLanguagesAvailableOnPlan(); + return ActionResult.cancelled(); } - const { allowedLanguages } = plan.value; // Step 1/4 this.prompts.importSpecStep(); From 287dd8f7a8eb8762498c17b43f7661b5c80ee7a5 Mon Sep 17 00:00:00 2001 From: Sohail Date: Fri, 10 Jul 2026 09:50:38 +0500 Subject: [PATCH 08/18] wrap link in to f.link --- src/prompts/portal/quickstart.ts | 11 ++++++----- src/prompts/sdk/quickstart.ts | 9 +++++---- 2 files changed, 11 insertions(+), 9 deletions(-) diff --git a/src/prompts/portal/quickstart.ts b/src/prompts/portal/quickstart.ts index a0b8ccbc..20c68b31 100644 --- a/src/prompts/portal/quickstart.ts +++ b/src/prompts/portal/quickstart.ts @@ -18,6 +18,7 @@ import { 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() { @@ -92,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); @@ -114,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); @@ -151,7 +152,7 @@ export class PortalQuickstartPrompts { public noLanguagesAvailableOnPlan(): void { const message = [ "You're on the Free plan", - "Upgrade your subscription to get started: https://www.apimatic.io/pricing" + `Upgrade your subscription to get started: ${f.link(pricingUrl)}` ].join("\n"); log.warn(message); } @@ -161,7 +162,7 @@ export class PortalQuickstartPrompts { `The following languages aren't included in your current subscription plan`, ...languages.map((language) => ` • ${language}`), "", - "Upgrade your subscription to unlock them: https://www.apimatic.io/pricing" + `Upgrade your subscription to unlock them: ${f.link(pricingUrl)}` ].join("\n"); } @@ -189,7 +190,7 @@ export class PortalQuickstartPrompts { "", ...removed.map((item) => ` • ${item}`), "", - "Upgrade your subscription to unlock these: https://www.apimatic.io/pricing" + `Upgrade your subscription to unlock these: ${f.link(pricingUrl)}` ].join("\n"); log.warn(message); } diff --git a/src/prompts/sdk/quickstart.ts b/src/prompts/sdk/quickstart.ts index fb5c1c69..790d15fd 100644 --- a/src/prompts/sdk/quickstart.ts +++ b/src/prompts/sdk/quickstart.ts @@ -15,6 +15,7 @@ import { UnallowedFeaturesResponse } from "../../infrastructure/services/validat 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); @@ -167,7 +168,7 @@ export class SdkQuickstartPrompts { public noLanguagesAvailableOnPlan(): void { const message = [ "You're on the Free plan.", - "Upgrade your subscription to get started: https://www.apimatic.io/pricing" + `Upgrade your subscription to get started: ${f.link(pricingUrl)}` ].join("\n"); log.warn(message); } @@ -177,7 +178,7 @@ export class SdkQuickstartPrompts { `The following languages aren't included in your current subscription plan:`, ...languages.map((language) => ` • ${language}`), "", - "Upgrade your subscription to unlock them: https://www.apimatic.io/pricing" + `Upgrade your subscription to unlock them: ${f.link(pricingUrl)}` ].join("\n"); } From 022884cb746756a6a7bc1fdcb09cadde0b26e53f Mon Sep 17 00:00:00 2001 From: Sohail Date: Fri, 10 Jul 2026 12:17:11 +0500 Subject: [PATCH 09/18] 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 --- src/actions/portal/quickstart.ts | 10 ++++------ src/infrastructure/services/validation-service.ts | 13 +++++++++++-- src/types/build/build.ts | 6 +++++- 3 files changed, 20 insertions(+), 9 deletions(-) diff --git a/src/actions/portal/quickstart.ts b/src/actions/portal/quickstart.ts index bef02808..de911376 100644 --- a/src/actions/portal/quickstart.ts +++ b/src/actions/portal/quickstart.ts @@ -233,15 +233,13 @@ export class PortalQuickstartAction { this.prompts.serviceError(pruneResult.error); return ActionResult.failed(); } - await this.fileService.writeContents( - buildFilePath, - JSON.stringify(pruneResult.value.buildFile, null, 2) - ); - this.prompts.buildFilePruned(pruneResult.value.report); + 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 (copilotKey && !pruneResult.value.report.removedApiCopilot) { + if (prunedConfig.hasApiCopilot() && copilotKey) { this.prompts.copilotEnabled(copilotKey); } diff --git a/src/infrastructure/services/validation-service.ts b/src/infrastructure/services/validation-service.ts index f0cafec5..134ede98 100644 --- a/src/infrastructure/services/validation-service.ts +++ b/src/infrastructure/services/validation-service.ts @@ -20,6 +20,7 @@ import { handleServiceError, ServiceError } from "../service-error.js"; import axios from "axios"; import { envInfo } from "../env-info.js"; import { Buffer } from "node:buffer"; +import { BuildConfig, BuildConfigData } from "../../types/build/build.js"; export enum RemovableFeature { Merging = 'Merging', @@ -60,7 +61,14 @@ export interface BuildFilePruneReport { } export interface PruneBuildFileResponse { - buildFile: unknown; + buildFile: BuildConfig; + report: BuildFilePruneReport; +} + +// Wire shape of the prune endpoint's response; the service converts `buildFile` +// into a rich BuildConfig at this boundary. +interface PruneBuildFileWireResponse { + buildFile: BuildConfigData; report: BuildFilePruneReport; } @@ -186,7 +194,8 @@ export class ValidationService { return err(ServiceError.badRequest(message, {})); } - return ok(response.data as PruneBuildFileResponse); + const data = response.data as PruneBuildFileWireResponse; + return ok({ buildFile: BuildConfig.from(data.buildFile), report: data.report }); } catch (error: unknown) { return err(handleServiceError(error)); } 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. From 8a51c9523272a62fd416ed68fbc383bb2df913f2 Mon Sep 17 00:00:00 2001 From: Sohail Date: Fri, 10 Jul 2026 14:11:17 +0500 Subject: [PATCH 10/18] 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) --- .../services/validation-service.ts | 48 ++++++++++++------- 1 file changed, 31 insertions(+), 17 deletions(-) diff --git a/src/infrastructure/services/validation-service.ts b/src/infrastructure/services/validation-service.ts index 134ede98..b515e2ea 100644 --- a/src/infrastructure/services/validation-service.ts +++ b/src/infrastructure/services/validation-service.ts @@ -16,11 +16,12 @@ import { err, ok, Result } from "neverthrow"; import { FilePath } from "../../types/file/filePath.js"; import { CommandMetadata } from "../../types/common/command-metadata.js"; import FormData from "form-data"; +import AdmZip from "adm-zip"; import { handleServiceError, ServiceError } from "../service-error.js"; import axios from "axios"; import { envInfo } from "../env-info.js"; import { Buffer } from "node:buffer"; -import { BuildConfig, BuildConfigData } from "../../types/build/build.js"; +import { BuildConfig } from "../../types/build/build.js"; export enum RemovableFeature { Merging = 'Merging', @@ -65,13 +66,6 @@ export interface PruneBuildFileResponse { report: BuildFilePruneReport; } -// Wire shape of the prune endpoint's response; the service converts `buildFile` -// into a rich BuildConfig at this boundary. -interface PruneBuildFileWireResponse { - buildFile: BuildConfigData; - report: BuildFilePruneReport; -} - export class ValidationService { private readonly apiBaseUrl = "https://api.apimatic.io" as const; @@ -181,26 +175,46 @@ export class ValidationService { ...formData.getHeaders(), Authorization: authorizationHeader }, + // The endpoint returns a zip (pruned APIMATIC-BUILD.json + report.json), + // so read the raw bytes rather than letting axios parse JSON. + responseType: "arraybuffer", validateStatus: () => true }); if (response.status >= 400) { - const data = response.data as { errors?: { summary?: string[] }; message?: string; title?: string }; - const message = - data?.errors?.summary?.[0] ?? - data?.message ?? - data?.title ?? - `Error ${response.status}: Failed to prune the build file for your subscription.`; - return err(ServiceError.badRequest(message, {})); + return err(this.parsePruneErrorResponse(response.status, response.data)); + } + + const zip = new AdmZip(globalThis.Buffer.from(response.data as ArrayBuffer)); + const buildEntry = zip.getEntry("APIMATIC-BUILD.json"); + const reportEntry = zip.getEntry("report.json"); + if (!buildEntry || !reportEntry) { + return err( + ServiceError.badRequest("The build-file prune returned an unexpected response.", {}) + ); } - const data = response.data as PruneBuildFileWireResponse; - return ok({ buildFile: BuildConfig.from(data.buildFile), report: data.report }); + const buildFile = BuildConfig.parse(buildEntry.getData().toString("utf-8")); + const report = JSON.parse(reportEntry.getData().toString("utf-8")) as BuildFilePruneReport; + return ok({ buildFile, report }); } catch (error: unknown) { return err(handleServiceError(error)); } } + /** Decodes an error body that arrived as raw zip-request bytes into a ServiceError. */ + private parsePruneErrorResponse(status: number, data: unknown): ServiceError { + let message = `Error ${status}: Failed to prune the build file for your subscription.`; + try { + const text = globalThis.Buffer.from(data as ArrayBuffer).toString("utf-8"); + const body = JSON.parse(text) 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 ?? ""}`; From 36cf78ad29256eab759548111a6a1a49d87d28ed Mon Sep 17 00:00:00 2001 From: Sohail Date: Fri, 10 Jul 2026 14:24:27 +0500 Subject: [PATCH 11/18] 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 --- src/infrastructure/services/validation-service.ts | 13 +++++++------ src/infrastructure/zip-service.ts | 6 ++++++ 2 files changed, 13 insertions(+), 6 deletions(-) diff --git a/src/infrastructure/services/validation-service.ts b/src/infrastructure/services/validation-service.ts index b515e2ea..c1c8a8d8 100644 --- a/src/infrastructure/services/validation-service.ts +++ b/src/infrastructure/services/validation-service.ts @@ -16,7 +16,7 @@ import { err, ok, Result } from "neverthrow"; import { FilePath } from "../../types/file/filePath.js"; import { CommandMetadata } from "../../types/common/command-metadata.js"; import FormData from "form-data"; -import AdmZip from "adm-zip"; +import { ZipService } from "../zip-service.js"; import { handleServiceError, ServiceError } from "../service-error.js"; import axios from "axios"; import { envInfo } from "../env-info.js"; @@ -68,6 +68,7 @@ export interface PruneBuildFileResponse { export class ValidationService { private readonly apiBaseUrl = "https://api.apimatic.io" as const; + private readonly zipService = new ZipService(); constructor(private readonly configDir: DirectoryPath) {} @@ -185,17 +186,17 @@ export class ValidationService { return err(this.parsePruneErrorResponse(response.status, response.data)); } - const zip = new AdmZip(globalThis.Buffer.from(response.data as ArrayBuffer)); - const buildEntry = zip.getEntry("APIMATIC-BUILD.json"); - const reportEntry = zip.getEntry("report.json"); + const zipData = globalThis.Buffer.from(response.data as ArrayBuffer); + const buildEntry = this.zipService.readEntry(zipData, "APIMATIC-BUILD.json"); + const reportEntry = this.zipService.readEntry(zipData, "report.json"); if (!buildEntry || !reportEntry) { return err( ServiceError.badRequest("The build-file prune returned an unexpected response.", {}) ); } - const buildFile = BuildConfig.parse(buildEntry.getData().toString("utf-8")); - const report = JSON.parse(reportEntry.getData().toString("utf-8")) as BuildFilePruneReport; + const buildFile = BuildConfig.parse(buildEntry.toString("utf-8")); + const report = JSON.parse(reportEntry.toString("utf-8")) as BuildFilePruneReport; return ok({ buildFile, report }); } catch (error: unknown) { return err(handleServiceError(error)); diff --git a/src/infrastructure/zip-service.ts b/src/infrastructure/zip-service.ts index dfcb98e2..6562c064 100644 --- a/src/infrastructure/zip-service.ts +++ b/src/infrastructure/zip-service.ts @@ -1,4 +1,5 @@ import fs from 'fs'; +import { Buffer } from 'node:buffer'; import yazl from 'yazl'; import AdmZip from 'adm-zip'; import { DirectoryPath } from '../types/file/directoryPath.js'; @@ -36,6 +37,11 @@ export class ZipService { }); } + /** Reads a single entry's contents from an in-memory zip; undefined if absent. */ + public readEntry(zipData: Buffer, entryName: string): Buffer | undefined { + return new AdmZip(zipData).getEntry(entryName)?.getData(); + } + public async unArchive(sourceFile: FilePath, destinationDirectory: DirectoryPath): Promise { const MAX_FILES = 100_000; const MAX_SIZE = 1_000_000_000; // 1 GB From 5c78b33bb8dae95c1079754a61d93f4cc07a7c2d Mon Sep 17 00:00:00 2001 From: Sohail Date: Fri, 10 Jul 2026 14:48:02 +0500 Subject: [PATCH 12/18] update msg --- src/prompts/portal/quickstart.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/prompts/portal/quickstart.ts b/src/prompts/portal/quickstart.ts index 20c68b31..f665c63f 100644 --- a/src/prompts/portal/quickstart.ts +++ b/src/prompts/portal/quickstart.ts @@ -186,7 +186,7 @@ export class PortalQuickstartPrompts { } const message = [ - "Some build features aren't on your current subscription plan and were removed before generation:", + "Some portal features aren't on your current subscription plan and were removed before generation:", "", ...removed.map((item) => ` • ${item}`), "", From 907d2198b3f0a30cb2fdcc3b0c3bf580a80bff60 Mon Sep 17 00:00:00 2001 From: Sohail Date: Mon, 13 Jul 2026 12:21:08 +0500 Subject: [PATCH 13/18] 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) --- scratchpad/plan-aware-prompt-mockup.html | 206 +++++++++++++++++++++++ src/actions/portal/quickstart.ts | 3 +- src/types/build-context.ts | 5 + 3 files changed, 212 insertions(+), 2 deletions(-) create mode 100644 scratchpad/plan-aware-prompt-mockup.html diff --git a/scratchpad/plan-aware-prompt-mockup.html b/scratchpad/plan-aware-prompt-mockup.html new file mode 100644 index 00000000..11ccbafe --- /dev/null +++ b/scratchpad/plan-aware-prompt-mockup.html @@ -0,0 +1,206 @@ + + +
+
+

Quickstart · language step

+

Plan-aware language selection

+

Languages your subscription doesn't include are shown greyed out, struck through, with an on-plan hint. The cursor skips them, and in the multi-select they can't be toggled — so an unavailable language can never be chosen. Below is the real clack rendering for both apimatic quickstart flows. Plan shown allows TypeScript, Python, Java & Go.

+
+ +
+ + +
+ SDK quickstart · single-select +
+
+ + apimatic quickstart — sdk +
+
  Quickstart
+
+  Step 3 of 4: Select programming language
+
+  Choose the programming language for your SDK:
+   Typescript
+  ○ Python
+  ○ Java
+  ○ Go
+  Ruby      (not available on your plan)
+  C#        (not available on your plan)
+  PHP       (not available on your plan)
+
+
+
Pressing moves Typescript → Python → Java → Go → then wraps back to Typescript, never landing on Ruby, C# or PHP. The cursor physically cannot rest on a greyed row, so Enter can only confirm an allowed language.
+
+ + +
+ Portal quickstart · multi-select +
+
+ + apimatic quickstart — portal +
+
  Quickstart
+
+  Step 3 of 4: Select programming languages
+
+  Your API Portal will contain SDKs in these languages (HTTP is enabled by default).
+  Use arrow keys + space to customize, then Enter to continue:
+   Typescript
+   Python
+   Java
+   Go
+  Ruby      (not available on your plan)
+  C#        (not available on your plan)
+  PHP       (not available on your plan)
+
+
+
Allowed languages start pre-checked (). The cursor skips the greyed rows and space does nothing on them, so they stay unchecked and out of the result. HTTP is always included and isn't shown as a toggle.
+
+ +
+ +
+

How to read the screen

+
Green radio — the highlighted / chosen single option
+
Green box — a checked (selected) language
+
Grey — allowed, currently not the cursor
+
Dim + struck + hint — not on your plan; skipped & non-selectable
+
Cyan diamond — the active prompt step
+
Cyan highlight — where the cursor currently rests
+
+
diff --git a/src/actions/portal/quickstart.ts b/src/actions/portal/quickstart.ts index de911376..81f72cc7 100644 --- a/src/actions/portal/quickstart.ts +++ b/src/actions/portal/quickstart.ts @@ -227,8 +227,7 @@ export class PortalQuickstartAction { // 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 buildFilePath = new FilePath(sourceDirectory, new FileName('APIMATIC-BUILD.json')); - const pruneResult = await this.validationService.pruneBuildFile(buildFilePath); + const pruneResult = await this.validationService.pruneBuildFile(buildContext.buildFilePath()); if (pruneResult.isErr()) { this.prompts.serviceError(pruneResult.error); return ActionResult.failed(); diff --git a/src/types/build-context.ts b/src/types/build-context.ts index cd9a4c78..7b7e1679 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 buildFilePath(): FilePath { + return this.buildFile; + } + public async validate(): Promise { // TODO: add more checks here if (!(await this.fileService.directoryExists(this.buildDirectory))) return false; From 3249e5309e1a33ef24b8f480805f9f6911462120 Mon Sep 17 00:00:00 2001 From: Sohail Date: Mon, 13 Jul 2026 12:21:59 +0500 Subject: [PATCH 14/18] chore: stop tracking scratchpad directory Co-Authored-By: Claude Opus 4.8 (1M context) --- scratchpad/plan-aware-prompt-mockup.html | 206 ----------------------- 1 file changed, 206 deletions(-) delete mode 100644 scratchpad/plan-aware-prompt-mockup.html diff --git a/scratchpad/plan-aware-prompt-mockup.html b/scratchpad/plan-aware-prompt-mockup.html deleted file mode 100644 index 11ccbafe..00000000 --- a/scratchpad/plan-aware-prompt-mockup.html +++ /dev/null @@ -1,206 +0,0 @@ - - -
-
-

Quickstart · language step

-

Plan-aware language selection

-

Languages your subscription doesn't include are shown greyed out, struck through, with an on-plan hint. The cursor skips them, and in the multi-select they can't be toggled — so an unavailable language can never be chosen. Below is the real clack rendering for both apimatic quickstart flows. Plan shown allows TypeScript, Python, Java & Go.

-
- -
- - -
- SDK quickstart · single-select -
-
- - apimatic quickstart — sdk -
-
  Quickstart
-
-  Step 3 of 4: Select programming language
-
-  Choose the programming language for your SDK:
-   Typescript
-  ○ Python
-  ○ Java
-  ○ Go
-  Ruby      (not available on your plan)
-  C#        (not available on your plan)
-  PHP       (not available on your plan)
-
-
-
Pressing moves Typescript → Python → Java → Go → then wraps back to Typescript, never landing on Ruby, C# or PHP. The cursor physically cannot rest on a greyed row, so Enter can only confirm an allowed language.
-
- - -
- Portal quickstart · multi-select -
-
- - apimatic quickstart — portal -
-
  Quickstart
-
-  Step 3 of 4: Select programming languages
-
-  Your API Portal will contain SDKs in these languages (HTTP is enabled by default).
-  Use arrow keys + space to customize, then Enter to continue:
-   Typescript
-   Python
-   Java
-   Go
-  Ruby      (not available on your plan)
-  C#        (not available on your plan)
-  PHP       (not available on your plan)
-
-
-
Allowed languages start pre-checked (). The cursor skips the greyed rows and space does nothing on them, so they stay unchecked and out of the result. HTTP is always included and isn't shown as a toggle.
-
- -
- -
-

How to read the screen

-
Green radio — the highlighted / chosen single option
-
Green box — a checked (selected) language
-
Grey — allowed, currently not the cursor
-
Dim + struck + hint — not on your plan; skipped & non-selectable
-
Cyan diamond — the active prompt step
-
Cyan highlight — where the cursor currently rests
-
-
From feccda49bbbe1857c9145d69292dc973cc246033 Mon Sep 17 00:00:00 2001 From: Sohail Date: Mon, 13 Jul 2026 16:15:17 +0500 Subject: [PATCH 15/18] 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 --- src/actions/portal/quickstart.ts | 32 ++++++++++++++++++++------------ src/actions/sdk/quickstart.ts | 7 ++----- src/prompts/portal/quickstart.ts | 12 ++++++++++-- src/types/api/account.ts | 1 + 4 files changed, 33 insertions(+), 19 deletions(-) diff --git a/src/actions/portal/quickstart.ts b/src/actions/portal/quickstart.ts index 81f72cc7..4a04223d 100644 --- a/src/actions/portal/quickstart.ts +++ b/src/actions/portal/quickstart.ts @@ -58,21 +58,20 @@ 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 free-plan exit below, feeds the language step the allowed SDK - // languages, and resolves the API Copilot key later. A lookup failure is fatal. + // 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(); } - const allowedLanguages = mapLanguages(accountInfo.value.allowedLanguages); - // Quickstart builds a portal around SDKs; with no SDK languages on the plan - // (e.g. the free plan) there's nothing to generate, so stop before importing - // or pruning a spec. - if (allowedLanguages.length === 0) { - this.prompts.noLanguagesAvailableOnPlan(); + // 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(); @@ -149,10 +148,19 @@ export class PortalQuickstartAction { // Step 3/4 this.prompts.selectLanguagesStep(); - const languages = await this.prompts.selectLanguagesPrompt(allowedLanguages); - 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 diff --git a/src/actions/sdk/quickstart.ts b/src/actions/sdk/quickstart.ts index 8298d570..7628dd07 100644 --- a/src/actions/sdk/quickstart.ts +++ b/src/actions/sdk/quickstart.ts @@ -54,14 +54,12 @@ export class SdkQuickstartAction { this.prompts.accountInfoFetchFailed(accountInfo.error); return ActionResult.failed(); } - const allowedLanguages = mapLanguages(accountInfo.value.allowedLanguages); // 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 (allowedLanguages.length === 0) { + if (mapLanguages(accountInfo.value.allowedLanguages).length === 0) { this.prompts.noLanguagesAvailableOnPlan(); return ActionResult.cancelled(); } - // Step 1/4 this.prompts.importSpecStep(); @@ -138,8 +136,7 @@ export class SdkQuickstartAction { // Step 3/4 this.prompts.selectLanguageStep(); - - const language = await this.prompts.selectLanguagePrompt(allowedLanguages); + const language = await this.prompts.selectLanguagePrompt(mapLanguages(accountInfo.value.allowedLanguages)); if (!language) { this.prompts.noLanguageSelected(); return ActionResult.cancelled(); diff --git a/src/prompts/portal/quickstart.ts b/src/prompts/portal/quickstart.ts index f665c63f..2f04cf63 100644 --- a/src/prompts/portal/quickstart.ts +++ b/src/prompts/portal/quickstart.ts @@ -149,9 +149,17 @@ export class PortalQuickstartPrompts { return ["http", ...languages]; } - public noLanguagesAvailableOnPlan(): void { + public httpOnlyPortalOnPlan(): void { const message = [ - "You're on the Free plan", + "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); 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[]; } From 462f4591ec2499632c9627ccbdd3bbb3957b91f1 Mon Sep 17 00:00:00 2001 From: Sohail Date: Mon, 13 Jul 2026 17:16:46 +0500 Subject: [PATCH 16/18] rename --- src/actions/portal/quickstart.ts | 2 +- src/infrastructure/services/validation-service.ts | 4 ++-- src/types/build-context.ts | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/actions/portal/quickstart.ts b/src/actions/portal/quickstart.ts index 4a04223d..dd519002 100644 --- a/src/actions/portal/quickstart.ts +++ b/src/actions/portal/quickstart.ts @@ -235,7 +235,7 @@ export class PortalQuickstartAction { // 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.buildFilePath()); + const pruneResult = await this.validationService.pruneBuildFile(buildContext.buildConfigFilePath()); if (pruneResult.isErr()) { this.prompts.serviceError(pruneResult.error); return ActionResult.failed(); diff --git a/src/infrastructure/services/validation-service.ts b/src/infrastructure/services/validation-service.ts index c1c8a8d8..f9a3fb10 100644 --- a/src/infrastructure/services/validation-service.ts +++ b/src/infrastructure/services/validation-service.ts @@ -153,14 +153,14 @@ export class ValidationService { * submit for generation is never rejected for a build-file feature the plan lacks. */ public async pruneBuildFile( - buildFilePath: FilePath, + 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(buildFilePath.toString()), { + formData.append("file", createReadStream(buildConfigFilePath.toString()), { filename: "APIMATIC-BUILD.json", contentType: "application/json" }); diff --git a/src/types/build-context.ts b/src/types/build-context.ts index 7b7e1679..a46f1bcc 100644 --- a/src/types/build-context.ts +++ b/src/types/build-context.ts @@ -20,7 +20,7 @@ export class BuildContext { } /** The APIMATIC-BUILD.json path within this build directory, for callers that hand it to a service. */ - public buildFilePath(): FilePath { + public buildConfigFilePath(): FilePath { return this.buildFile; } From 2f9d87af38b11aa879d798c5612def0533976720 Mon Sep 17 00:00:00 2001 From: Sohail Date: Mon, 13 Jul 2026 17:25:46 +0500 Subject: [PATCH 17/18] 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) --- .../services/validation-service.ts | 56 ++++++++++++------- src/infrastructure/zip-service.ts | 6 -- 2 files changed, 37 insertions(+), 25 deletions(-) diff --git a/src/infrastructure/services/validation-service.ts b/src/infrastructure/services/validation-service.ts index f9a3fb10..fffb4f44 100644 --- a/src/infrastructure/services/validation-service.ts +++ b/src/infrastructure/services/validation-service.ts @@ -14,9 +14,12 @@ 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 from "axios"; import { envInfo } from "../env-info.js"; @@ -69,6 +72,7 @@ export interface PruneBuildFileResponse { 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) {} @@ -177,38 +181,52 @@ export class ValidationService { Authorization: authorizationHeader }, // The endpoint returns a zip (pruned APIMATIC-BUILD.json + report.json), - // so read the raw bytes rather than letting axios parse JSON. - responseType: "arraybuffer", + // streamed so it can be written straight to disk and unarchived. + responseType: "stream", validateStatus: () => true }); if (response.status >= 400) { - return err(this.parsePruneErrorResponse(response.status, response.data)); + return err(await this.parsePruneErrorResponse(response)); } - const zipData = globalThis.Buffer.from(response.data as ArrayBuffer); - const buildEntry = this.zipService.readEntry(zipData, "APIMATIC-BUILD.json"); - const reportEntry = this.zipService.readEntry(zipData, "report.json"); - if (!buildEntry || !reportEntry) { - return err( - ServiceError.badRequest("The build-file prune returned an unexpected 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 buildFile = BuildConfig.parse(buildEntry.toString("utf-8")); - const report = JSON.parse(reportEntry.toString("utf-8")) as BuildFilePruneReport; - return ok({ buildFile, report }); + 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.badRequest("The build-file prune returned an unexpected response.", {})); + } + + 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 an error body that arrived as raw zip-request bytes into a ServiceError. */ - private parsePruneErrorResponse(status: number, data: unknown): ServiceError { - let message = `Error ${status}: Failed to prune the build file for your subscription.`; + /** 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 text = globalThis.Buffer.from(data as ArrayBuffer).toString("utf-8"); - const body = JSON.parse(text) as { errors?: { summary?: string[] }; message?: string; title?: string }; + 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. diff --git a/src/infrastructure/zip-service.ts b/src/infrastructure/zip-service.ts index 6562c064..dfcb98e2 100644 --- a/src/infrastructure/zip-service.ts +++ b/src/infrastructure/zip-service.ts @@ -1,5 +1,4 @@ import fs from 'fs'; -import { Buffer } from 'node:buffer'; import yazl from 'yazl'; import AdmZip from 'adm-zip'; import { DirectoryPath } from '../types/file/directoryPath.js'; @@ -37,11 +36,6 @@ export class ZipService { }); } - /** Reads a single entry's contents from an in-memory zip; undefined if absent. */ - public readEntry(zipData: Buffer, entryName: string): Buffer | undefined { - return new AdmZip(zipData).getEntry(entryName)?.getData(); - } - public async unArchive(sourceFile: FilePath, destinationDirectory: DirectoryPath): Promise { const MAX_FILES = 100_000; const MAX_SIZE = 1_000_000_000; // 1 GB From 1c6192bb0912e96a9ea899530c1549fe75f6aa67 Mon Sep 17 00:00:00 2001 From: Sohail Date: Tue, 14 Jul 2026 10:36:11 +0500 Subject: [PATCH 18/18] badrequest->serverError --- src/infrastructure/services/validation-service.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/infrastructure/services/validation-service.ts b/src/infrastructure/services/validation-service.ts index fffb4f44..8a20d753 100644 --- a/src/infrastructure/services/validation-service.ts +++ b/src/infrastructure/services/validation-service.ts @@ -202,7 +202,7 @@ export class ValidationService { 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.badRequest("The build-file prune returned an unexpected response.", {})); + return err(ServiceError.ServerError); } const buildConfigFile = BuildConfig.parse(await this.fileService.getContents(prunedBuildConfigFile));