From 1f8418027349dba4f982d5482173cda223cf4461 Mon Sep 17 00:00:00 2001 From: Sohail Date: Mon, 22 Jun 2026 17:06:27 +0500 Subject: [PATCH 01/44] feat(portal): prefer port 23513 and auto-configure API Copilot in quickstart - Default the portal serve port to 23513 (falls back to 3000, 3001, 3002, then a random port), and align the quickstart serve port to match. - Warn on `portal serve` when the effective portal base URL points at localhost on a port different from the actual serve port; non-localhost base URLs are ignored. - On `apimatic quickstart`, when the account has an API Copilot key, set generatePortal.baseUrl to http://localhost:23513, add apiCopilotConfig, and enable AI integrations (Cursor, Claude Code, VS Code) for every configured language. Skips silently when no key is available. Co-Authored-By: Claude Opus 4.8 --- src/actions/portal/quickstart.ts | 66 +++++++++++++++++++++++++++++++- src/actions/portal/serve.ts | 30 +++++++++++++++ src/commands/portal/serve.ts | 6 +-- src/prompts/portal/quickstart.ts | 18 +++++++++ src/prompts/portal/serve.ts | 11 ++++++ src/types/build/build.ts | 30 ++++++++++++++- src/types/file/urlPath.ts | 15 ++++++++ 7 files changed, 171 insertions(+), 5 deletions(-) diff --git a/src/actions/portal/quickstart.ts b/src/actions/portal/quickstart.ts index 8834460a..4d34c028 100644 --- a/src/actions/portal/quickstart.ts +++ b/src/actions/portal/quickstart.ts @@ -18,8 +18,18 @@ import { FilePath } from '../../types/file/filePath.js'; import { SpecContext } from '../../types/spec-context.js'; import { FeaturesToRemove, ValidationService } from '../../infrastructure/services/validation-service.js'; import { FileName } from '../../types/file/fileName.js'; +import { ApiService } from '../../infrastructure/services/api-service.js'; +import { BuildConfig } from '../../types/build/build.js'; -const defaultPort: number = 3000 as const; +const defaultPort: number = 23513 as const; +const copilotBaseUrl: string = `http://localhost:${defaultPort}` as const; +const defaultCopilotWelcomeMessage: string = + "Hi there! I'm your API Integration Assistant, here to help you learn and integrate with this API.\n" + + "\n" + + "Ask me anything about this API or try one of these example prompts:\n" + + "\n" + + "- `What authentication methods does this API support?`\n" + + "- `[Enter another prompt here]`"; export class PortalQuickstartAction { private readonly prompts: PortalQuickstartPrompts = new PortalQuickstartPrompts(); @@ -28,6 +38,7 @@ export class PortalQuickstartAction { private readonly configDir: DirectoryPath; private readonly commandMetadata: CommandMetadata; private readonly fileDownloadService = new FileDownloadService(); + private readonly apiService = new ApiService(); private readonly buildFileUrl = new UrlPath( `https://github.com/apimatic/sample-docs-as-code-portal/archive/refs/heads/master.zip` ); @@ -174,6 +185,7 @@ export class PortalQuickstartAction { const buildFile = await tempBuildContext.getBuildFileContents(); buildFile.generatePortal!.languageConfig = getLanguagesConfig(languages); + await this.configureApiCopilot(buildFile); await tempBuildContext.updateBuildFileContents(buildFile); const sourceDirectory = inputDirectory.join('src'); @@ -199,4 +211,56 @@ export class PortalQuickstartAction { return ActionResult.success(); }); }; + + // When the account has an API Copilot key, wires Copilot into the build config: + // points the portal base URL at the local serve port, adds the apiCopilotConfig + // block, and enables AI editor integrations for every configured language. + // Copilot is optional, so any failure here is logged and skipped, never fatal. + private async configureApiCopilot(buildFile: BuildConfig): Promise { + // Copilot is opt-in based on account access. When the user has no Copilot key + // (or the check fails), skip silently without surfacing any output. + const accountInfo = await this.apiService.getAccountInfo(this.configDir, this.commandMetadata.shell, null); + if (accountInfo.isErr()) { + return; + } + + const copilotKeys = accountInfo.value.ApiCopilotKeys ?? []; + if (copilotKeys.length === 0) { + return; + } + + const copilotKey = copilotKeys.length === 1 ? copilotKeys[0] : await this.prompts.selectCopilotKey(copilotKeys); + if (!copilotKey) { + return; + } + + buildFile.generatePortal!.baseUrl = copilotBaseUrl; + buildFile.apiCopilotConfig = { + isEnabled: true, + key: copilotKey, + welcomeMessage: defaultCopilotWelcomeMessage + }; + this.enableAiIntegrations(buildFile); + + this.prompts.copilotEnabled(copilotKey); + } + + // Enables Cursor, Claude Code and VS Code integrations for every language in + // the portal's languageConfig, preserving any existing per-language settings. + private enableAiIntegrations(buildFile: BuildConfig): void { + const portalSettings = (buildFile.generatePortal!.portalSettings ??= {}); + const languageSettings = (portalSettings.languageSettings ??= {}); + + for (const language of Object.keys(buildFile.generatePortal!.languageConfig)) { + const existing = languageSettings[language] ?? {}; + languageSettings[language] = { + ...existing, + aiIntegration: { + cursor: { isEnabled: true }, + claudeCode: { isEnabled: true }, + vscode: { isEnabled: true } + } + }; + } + } } diff --git a/src/actions/portal/serve.ts b/src/actions/portal/serve.ts index d6ebdf54..26d9c0d0 100644 --- a/src/actions/portal/serve.ts +++ b/src/actions/portal/serve.ts @@ -11,6 +11,7 @@ import { NetworkService } from "../../infrastructure/network-service.js"; import { UrlPath } from "../../types/file/urlPath.js"; import { LauncherService } from "../../infrastructure/launcher-service.js"; import { DebounceService } from "../../infrastructure/debounce-service.js"; +import { BuildContext } from "../../types/build-context.js"; export class PortalServeAction { private readonly prompts: PortalServePrompts = new PortalServePrompts(); @@ -46,6 +47,8 @@ export class PortalServeAction { this.prompts.usingFallbackPort(port, servePort); } + await this.warnOnBaseUrlPortMismatch(buildDirectory, servePort); + const liveReloadPort = await this.networkService.getServerPort([35729, 35730, 35731, 35732]); const liveReloadServer = createLiveReloadServer({ port: liveReloadPort }); const server = this.application @@ -122,6 +125,33 @@ export class PortalServeAction { return ActionResult.success(); } + // Warns when the portal's configured base URL points at localhost on a port + // that differs from the port the portal is actually being served on. + private async warnOnBaseUrlPortMismatch(buildDirectory: DirectoryPath, servePort: number): Promise { + const buildContext = new BuildContext(buildDirectory); + if (!(await buildContext.validate())) { + return; + } + + const buildConfig = await buildContext.getBuildFileContents(); + // `portalSettings.baseUrl` is preferred for portal artifacts; otherwise fall + // back to `generatePortal.baseUrl`. Mirrors how codegen resolves the base URL. + const baseUrl = buildConfig.generatePortal?.portalSettings?.baseUrl ?? buildConfig.generatePortal?.baseUrl; + if (!baseUrl) { + return; + } + + const parsedUrl = UrlPath.create(baseUrl); + if (!parsedUrl || !parsedUrl.isLocalhost()) { + return; + } + + const baseUrlPort = parsedUrl.port(); + if (baseUrlPort !== servePort) { + this.prompts.baseUrlPortMismatch(baseUrl, baseUrlPort, servePort); + } + } + // This clears the standard input to allow interrupts like CTRL+C to work properly. private clearStandardInput() { if (process.platform !== "darwin" && process.stdin.isTTY) { diff --git a/src/commands/portal/serve.ts b/src/commands/portal/serve.ts index 80a7899e..67a9e71f 100644 --- a/src/commands/portal/serve.ts +++ b/src/commands/portal/serve.ts @@ -15,8 +15,8 @@ export default class PortalServe extends Command { port: Flags.integer({ char: "p", description: "port to serve the portal.", - default: 3000, - helpValue: "3000" + default: 23513, + helpValue: "23513" }), ...FlagsProvider.input, ...FlagsProvider.destination("portal", "portal"), @@ -38,7 +38,7 @@ export default class PortalServe extends Command { `${this.cmdTxt} ` + `${format.flag("input", './')} ` + `${format.flag("destination", './portal')} ` + - `${format.flag("port", "3000")} ` + + `${format.flag("port", "23513")} ` + `${format.flag("open")} ` + `${format.flag("no-reload")}` ]; diff --git a/src/prompts/portal/quickstart.ts b/src/prompts/portal/quickstart.ts index aa7e6280..99fcbb12 100644 --- a/src/prompts/portal/quickstart.ts +++ b/src/prompts/portal/quickstart.ts @@ -212,6 +212,24 @@ ${f.link(referenceDocumentationUrl)}`; log.error(serviceError.errorMessage); } + public async selectCopilotKey(keys: string[]): Promise { + const selectedKey = await select({ + message: "Select the API Copilot key you would like to enable for this Portal:", + maxItems: 10, + options: keys.map((key) => ({ value: key, label: key })) + }); + + if (isCancel(selectedKey)) { + return null; + } + + return selectedKey; + } + + public copilotEnabled(key: string) { + log.info(`API Copilot enabled with key ${f.var(key)} and AI integrations turned on for all selected languages.`); + } + public printDirectoryStructure(inputDirectory: DirectoryPath, directory: Directory) { const heading = `${f.var("src")} directory containing source files created at ${f.path(inputDirectory)}\n`; const message = getTree(directory.toTreeNode()); diff --git a/src/prompts/portal/serve.ts b/src/prompts/portal/serve.ts index 5a75d66e..a90da257 100644 --- a/src/prompts/portal/serve.ts +++ b/src/prompts/portal/serve.ts @@ -13,6 +13,17 @@ export class PortalServePrompts { log.step(message); } + public baseUrlPortMismatch(baseUrl: string, baseUrlPort: number, servePort: number) { + const message = `The configured base URL ${f.var(baseUrl)} uses port ${f.var( + baseUrlPort.toString() + )}, but the portal is being served on port ${f.var( + servePort.toString() + )}. This mismatch may cause unexpected behavior. Update the base URL in your configuration or serve on port ${f.var( + baseUrlPort.toString() + )} to keep them aligned.`; + log.warn(message); + } + public portalServed(urlPath: UrlPath) { const message = `The portal is running at ${f.link(urlPath.toString())}`; log.message(message); diff --git a/src/types/build/build.ts b/src/types/build/build.ts index 1424b757..b853497d 100644 --- a/src/types/build/build.ts +++ b/src/types/build/build.ts @@ -11,8 +11,36 @@ export interface BuildConfig { export interface PortalConfig { contentFolder?: string; languageConfig: { [key: string]: object }; - [key: string]: unknown; + /** URL where the portal will be hosted. Mirrors `generatePortal.baseUrl` in codegen. */ + baseUrl?: string; + /** Portal UI settings. Mirrors `generatePortal.portalSettings` in codegen. */ + portalSettings?: PortalSettings; apiSpecPath?: DirectoryPath; + [key: string]: unknown; +} + +export interface PortalSettings { + /** Base URL for the API calls made by the portal. Preferred over `generatePortal.baseUrl` for portal artifacts. */ + baseUrl?: string; + /** Per-language portal settings, keyed by language/template id. */ + languageSettings?: { [language: string]: LanguageSetting }; + [key: string]: unknown; +} + +export interface LanguageSetting { + aiIntegration?: AiIntegration; + [key: string]: unknown; +} + +export interface AiIntegration { + cursor?: AiIntegrationSetting; + claudeCode?: AiIntegrationSetting; + vscode?: AiIntegrationSetting; +} + +export interface AiIntegrationSetting { + isEnabled: boolean; + stabilityLevelTag?: string; } export interface CopilotConfig { diff --git a/src/types/file/urlPath.ts b/src/types/file/urlPath.ts index 242143e7..241c7e3f 100644 --- a/src/types/file/urlPath.ts +++ b/src/types/file/urlPath.ts @@ -22,4 +22,19 @@ export class UrlPath { public toString(): string { return this.url; } + + /** True when the URL points at the local machine (`localhost` or `127.0.0.1`). */ + public isLocalhost(): boolean { + const hostname = new URL(this.url).hostname; + return hostname === "localhost" || hostname === "127.0.0.1"; + } + + /** The URL's port. Falls back to the protocol default (443 for https, 80 otherwise) when none is specified. */ + public port(): number { + const parsed = new URL(this.url); + if (parsed.port) { + return Number(parsed.port); + } + return parsed.protocol === "https:" ? 443 : 80; + } } \ No newline at end of file From 7ffa43e78974ecc24b70fbe6dd8ca23a142153c6 Mon Sep 17 00:00:00 2001 From: Sohail Date: Tue, 23 Jun 2026 09:36:13 +0500 Subject: [PATCH 02/44] refactor(portal): address code review on copilot/serve changes - Suppress the base-URL port-mismatch warning during quickstart (onAfterServe), consistent with the suppressed fallback-port message. - Exclude the synthetic "http" entry when enabling AI integrations; only real SDK languages get aiIntegration. - Share the default Copilot welcome message between the copilot command and quickstart instead of duplicating it. - Drop the redundant build-file validate() in the mismatch check and guard the read against a malformed file. - Type the apiCopilotConfig literal as CopilotConfig and fix the stale comment. Co-Authored-By: Claude Opus 4.8 --- src/actions/portal/copilot.ts | 17 ++++++++-------- src/actions/portal/quickstart.ts | 35 ++++++++++++++++---------------- src/actions/portal/serve.ts | 15 ++++++++++---- 3 files changed, 37 insertions(+), 30 deletions(-) diff --git a/src/actions/portal/copilot.ts b/src/actions/portal/copilot.ts index fdf46248..5fc2d148 100644 --- a/src/actions/portal/copilot.ts +++ b/src/actions/portal/copilot.ts @@ -15,6 +15,14 @@ import { err, ok, Result } from "neverthrow"; type SelectKeyFailure = "failed" | "cancelled"; type SelectKeyResult = Result; +export const DEFAULT_COPILOT_WELCOME_MESSAGE = + "Hi there! I'm your API Integration Assistant, here to help you learn and integrate with this API.\n" + + "\n" + + "Ask me anything about this API or try one of these example prompts:\n" + + "\n" + + "- `What authentication methods does this API support?`\n" + + "- `[Enter another prompt here]`"; + export class CopilotAction { private readonly apiService = new ApiService(); private readonly fileService = new FileService(); @@ -104,14 +112,7 @@ export class CopilotAction { private async prepareWelcomeMessage(): Promise { return await withDirPath(async (tempDir) => { const tempFile = new FilePath(tempDir, new FileName("welcome-message.md")); - const defaultContent = - "Hi there! I'm your API Integration Assistant, here to help you learn and integrate with this API.\n" + - "\n" + - "Ask me anything about this API or try one of these example prompts:\n" + - "\n" + - "- `What authentication methods does this API support?`\n" + - "- `[Enter another prompt here]`"; - await this.fileService.writeContents(tempFile, defaultContent); + await this.fileService.writeContents(tempFile, DEFAULT_COPILOT_WELCOME_MESSAGE); this.prompts.openWelcomeMessageEditor(); await this.launcherService.openInEditor(tempFile); const welcomeMessage = await this.fileService.getContents(tempFile); diff --git a/src/actions/portal/quickstart.ts b/src/actions/portal/quickstart.ts index 4d34c028..d6627afa 100644 --- a/src/actions/portal/quickstart.ts +++ b/src/actions/portal/quickstart.ts @@ -19,17 +19,14 @@ import { SpecContext } from '../../types/spec-context.js'; import { FeaturesToRemove, ValidationService } from '../../infrastructure/services/validation-service.js'; import { FileName } from '../../types/file/fileName.js'; import { ApiService } from '../../infrastructure/services/api-service.js'; -import { BuildConfig } from '../../types/build/build.js'; +import { BuildConfig, CopilotConfig } from '../../types/build/build.js'; +import { DEFAULT_COPILOT_WELCOME_MESSAGE } from './copilot.js'; const defaultPort: number = 23513 as const; const copilotBaseUrl: string = `http://localhost:${defaultPort}` as const; -const defaultCopilotWelcomeMessage: string = - "Hi there! I'm your API Integration Assistant, here to help you learn and integrate with this API.\n" + - "\n" + - "Ask me anything about this API or try one of these example prompts:\n" + - "\n" + - "- `What authentication methods does this API support?`\n" + - "- `[Enter another prompt here]`"; +// `languageConfig` always contains the synthetic "http" entry, which has no SDK +// and therefore no editor integration to enable. +const nonSdkLanguages: ReadonlySet = new Set(['http']); export class PortalQuickstartAction { private readonly prompts: PortalQuickstartPrompts = new PortalQuickstartPrompts(); @@ -214,11 +211,10 @@ export class PortalQuickstartAction { // When the account has an API Copilot key, wires Copilot into the build config: // points the portal base URL at the local serve port, adds the apiCopilotConfig - // block, and enables AI editor integrations for every configured language. - // Copilot is optional, so any failure here is logged and skipped, never fatal. + // block, and enables AI editor integrations for every configured SDK language. + // Copilot is opt-in based on account access: when the user has no Copilot key + // (or the lookup fails) it is skipped silently, never fatal. private async configureApiCopilot(buildFile: BuildConfig): Promise { - // Copilot is opt-in based on account access. When the user has no Copilot key - // (or the check fails), skip silently without surfacing any output. const accountInfo = await this.apiService.getAccountInfo(this.configDir, this.commandMetadata.shell, null); if (accountInfo.isErr()) { return; @@ -234,27 +230,30 @@ export class PortalQuickstartAction { return; } - buildFile.generatePortal!.baseUrl = copilotBaseUrl; - buildFile.apiCopilotConfig = { + const apiCopilotConfig: CopilotConfig = { isEnabled: true, key: copilotKey, - welcomeMessage: defaultCopilotWelcomeMessage + welcomeMessage: DEFAULT_COPILOT_WELCOME_MESSAGE }; + buildFile.generatePortal!.baseUrl = copilotBaseUrl; + buildFile.apiCopilotConfig = apiCopilotConfig; this.enableAiIntegrations(buildFile); this.prompts.copilotEnabled(copilotKey); } - // Enables Cursor, Claude Code and VS Code integrations for every language in + // Enables Cursor, Claude Code and VS Code integrations for every SDK language in // the portal's languageConfig, preserving any existing per-language settings. private enableAiIntegrations(buildFile: BuildConfig): void { const portalSettings = (buildFile.generatePortal!.portalSettings ??= {}); const languageSettings = (portalSettings.languageSettings ??= {}); for (const language of Object.keys(buildFile.generatePortal!.languageConfig)) { - const existing = languageSettings[language] ?? {}; + if (nonSdkLanguages.has(language)) { + continue; + } languageSettings[language] = { - ...existing, + ...languageSettings[language], aiIntegration: { cursor: { isEnabled: true }, claudeCode: { isEnabled: true }, diff --git a/src/actions/portal/serve.ts b/src/actions/portal/serve.ts index 26d9c0d0..52e84e9d 100644 --- a/src/actions/portal/serve.ts +++ b/src/actions/portal/serve.ts @@ -47,7 +47,11 @@ export class PortalServeAction { this.prompts.usingFallbackPort(port, servePort); } - await this.warnOnBaseUrlPortMismatch(buildDirectory, servePort); + // The serve port is chosen by the CLI during quickstart (onAfterServe set), so + // a mismatch warning there would be noise about a port the user did not pick. + if (!onAfterServe) { + await this.warnOnBaseUrlPortMismatch(buildDirectory, servePort); + } const liveReloadPort = await this.networkService.getServerPort([35729, 35730, 35731, 35732]); const liveReloadServer = createLiveReloadServer({ port: liveReloadPort }); @@ -128,12 +132,15 @@ export class PortalServeAction { // Warns when the portal's configured base URL points at localhost on a port // that differs from the port the portal is actually being served on. private async warnOnBaseUrlPortMismatch(buildDirectory: DirectoryPath, servePort: number): Promise { - const buildContext = new BuildContext(buildDirectory); - if (!(await buildContext.validate())) { + // The build file was already validated by the GenerateAction above, so read it + // directly; guard only against an unreadable/malformed file. + let buildConfig; + try { + buildConfig = await new BuildContext(buildDirectory).getBuildFileContents(); + } catch { return; } - const buildConfig = await buildContext.getBuildFileContents(); // `portalSettings.baseUrl` is preferred for portal artifacts; otherwise fall // back to `generatePortal.baseUrl`. Mirrors how codegen resolves the base URL. const baseUrl = buildConfig.generatePortal?.portalSettings?.baseUrl ?? buildConfig.generatePortal?.baseUrl; From 57776bc57f57571bd10640f15ec1e9299e02a374 Mon Sep 17 00:00:00 2001 From: Sohail Date: Tue, 23 Jun 2026 09:38:59 +0500 Subject: [PATCH 03/44] style(portal): align with .ai/skills prompt & value-object conventions - selectCopilotKey returns undefined on cancel (prompt.md: select/text/ multiselect return undefined), matching the other prompts in the class. - Add trailing newline to urlPath.ts. Co-Authored-By: Claude Opus 4.8 --- src/prompts/portal/quickstart.ts | 4 ++-- src/types/file/urlPath.ts | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/prompts/portal/quickstart.ts b/src/prompts/portal/quickstart.ts index 99fcbb12..80fc04d8 100644 --- a/src/prompts/portal/quickstart.ts +++ b/src/prompts/portal/quickstart.ts @@ -212,7 +212,7 @@ ${f.link(referenceDocumentationUrl)}`; log.error(serviceError.errorMessage); } - public async selectCopilotKey(keys: string[]): Promise { + public async selectCopilotKey(keys: string[]): Promise { const selectedKey = await select({ message: "Select the API Copilot key you would like to enable for this Portal:", maxItems: 10, @@ -220,7 +220,7 @@ ${f.link(referenceDocumentationUrl)}`; }); if (isCancel(selectedKey)) { - return null; + return undefined; } return selectedKey; diff --git a/src/types/file/urlPath.ts b/src/types/file/urlPath.ts index 241c7e3f..e747efea 100644 --- a/src/types/file/urlPath.ts +++ b/src/types/file/urlPath.ts @@ -37,4 +37,4 @@ export class UrlPath { } return parsed.protocol === "https:" ? 443 : 80; } -} \ No newline at end of file +} From 2f8701315da673cfa52a435c90aad21ac476b514 Mon Sep 17 00:00:00 2001 From: Sohail Date: Tue, 23 Jun 2026 10:40:50 +0500 Subject: [PATCH 04/44] fix(portal): key copilot languageSettings by codegen template id MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Portal generation failed with `System.InvalidOperationException: Sequence contains no matching element` from SdkLanguage.FromSupportedTemplate. codegen resolves `portalSettings.languageSettings` keys via `SdkLanguage.FromSupportedTemplate`, which matches the SupportedTemplates id (e.g. `ts_generic_lib`, `php_generic_lib_v2`) — not the friendly `languageConfig` key (`typescript`, `php`). enableAiIntegrations was keying by the friendly name, so the lookup threw and the whole generation failed (surfaced as the generic "unexpected error"). Map each friendly language to its codegen template id and key languageSettings by that. Languages without a known template id (e.g. the synthetic `http`) are skipped rather than emitted with an unresolvable key. Co-Authored-By: Claude Opus 4.8 --- src/actions/portal/quickstart.ts | 27 +++++++++++++++++++++------ 1 file changed, 21 insertions(+), 6 deletions(-) diff --git a/src/actions/portal/quickstart.ts b/src/actions/portal/quickstart.ts index d6627afa..18585592 100644 --- a/src/actions/portal/quickstart.ts +++ b/src/actions/portal/quickstart.ts @@ -24,9 +24,21 @@ import { DEFAULT_COPILOT_WELCOME_MESSAGE } from './copilot.js'; const defaultPort: number = 23513 as const; const copilotBaseUrl: string = `http://localhost:${defaultPort}` as const; -// `languageConfig` always contains the synthetic "http" entry, which has no SDK -// and therefore no editor integration to enable. -const nonSdkLanguages: ReadonlySet = new Set(['http']); +// `portalSettings.languageSettings` must be keyed by codegen's SupportedTemplates id +// (codegen resolves the key via `SdkLanguage.FromSupportedTemplate`), NOT by the +// friendly `languageConfig` key. These ids are version-specific in codegen +// (e.g. `php_generic_lib_v2`) — keep in sync with `APIMatic.CodeGen.Common.SdkLanguage`. +// Languages without an entry here (e.g. the synthetic `http`) are skipped: they have +// no SDK template, so codegen cannot resolve them and would throw. +const codegenTemplateIdByLanguage: Readonly> = { + typescript: 'ts_generic_lib', + csharp: 'cs_net_standard_lib', + java: 'java_eclipse_jre_lib', + php: 'php_generic_lib_v2', + python: 'python_generic_lib', + ruby: 'ruby_generic_lib', + go: 'go_generic_lib' +}; export class PortalQuickstartAction { private readonly prompts: PortalQuickstartPrompts = new PortalQuickstartPrompts(); @@ -244,16 +256,19 @@ export class PortalQuickstartAction { // Enables Cursor, Claude Code and VS Code integrations for every SDK language in // the portal's languageConfig, preserving any existing per-language settings. + // languageSettings is keyed by codegen's SupportedTemplates id (see + // codegenTemplateIdByLanguage); languages without a known template id are skipped. private enableAiIntegrations(buildFile: BuildConfig): void { const portalSettings = (buildFile.generatePortal!.portalSettings ??= {}); const languageSettings = (portalSettings.languageSettings ??= {}); for (const language of Object.keys(buildFile.generatePortal!.languageConfig)) { - if (nonSdkLanguages.has(language)) { + const templateId = codegenTemplateIdByLanguage[language]; + if (!templateId) { continue; } - languageSettings[language] = { - ...languageSettings[language], + languageSettings[templateId] = { + ...languageSettings[templateId], aiIntegration: { cursor: { isEnabled: true }, claudeCode: { isEnabled: true }, From 9085ebcbe69cca6e257f58e0037b334f0ea1a653 Mon Sep 17 00:00:00 2001 From: Sohail Date: Tue, 23 Jun 2026 10:45:29 +0500 Subject: [PATCH 05/44] chore(portal): drop the "API Copilot enabled" message in quickstart Copilot setup now applies silently; removes the log line that also echoed the Copilot key to the terminal. Co-Authored-By: Claude Opus 4.8 --- src/actions/portal/quickstart.ts | 2 -- src/prompts/portal/quickstart.ts | 4 ---- 2 files changed, 6 deletions(-) diff --git a/src/actions/portal/quickstart.ts b/src/actions/portal/quickstart.ts index 18585592..4247b305 100644 --- a/src/actions/portal/quickstart.ts +++ b/src/actions/portal/quickstart.ts @@ -250,8 +250,6 @@ export class PortalQuickstartAction { buildFile.generatePortal!.baseUrl = copilotBaseUrl; buildFile.apiCopilotConfig = apiCopilotConfig; this.enableAiIntegrations(buildFile); - - this.prompts.copilotEnabled(copilotKey); } // Enables Cursor, Claude Code and VS Code integrations for every SDK language in diff --git a/src/prompts/portal/quickstart.ts b/src/prompts/portal/quickstart.ts index 80fc04d8..75cf0dff 100644 --- a/src/prompts/portal/quickstart.ts +++ b/src/prompts/portal/quickstart.ts @@ -226,10 +226,6 @@ ${f.link(referenceDocumentationUrl)}`; return selectedKey; } - public copilotEnabled(key: string) { - log.info(`API Copilot enabled with key ${f.var(key)} and AI integrations turned on for all selected languages.`); - } - public printDirectoryStructure(inputDirectory: DirectoryPath, directory: Directory) { const heading = `${f.var("src")} directory containing source files created at ${f.path(inputDirectory)}\n`; const message = getTree(directory.toTreeNode()); From e9215604db25f18861c6266b480b95d22be49ccc Mon Sep 17 00:00:00 2001 From: Sohail Date: Tue, 23 Jun 2026 11:02:26 +0500 Subject: [PATCH 06/44] fix(portal): stop forcing an absolute baseUrl in quickstart copilot setup Setting generatePortal.baseUrl to http://localhost:23513 propagated into portalSettings.baseUrl, flipping the served portal from the default relative "./" base to an absolute origin. The locally-served portal loads its content relative to where it is served, so the absolute base left the portal rendering only its shell (header) with no docs/content. The baseUrl provided nothing to API Copilot (the MCP/chatbot URL is resolved server-side), so drop the assignment. apiCopilotConfig + aiIntegration are kept. Co-Authored-By: Claude Opus 4.8 --- src/actions/portal/quickstart.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/actions/portal/quickstart.ts b/src/actions/portal/quickstart.ts index 4247b305..af64d050 100644 --- a/src/actions/portal/quickstart.ts +++ b/src/actions/portal/quickstart.ts @@ -23,7 +23,6 @@ import { BuildConfig, CopilotConfig } from '../../types/build/build.js'; import { DEFAULT_COPILOT_WELCOME_MESSAGE } from './copilot.js'; const defaultPort: number = 23513 as const; -const copilotBaseUrl: string = `http://localhost:${defaultPort}` as const; // `portalSettings.languageSettings` must be keyed by codegen's SupportedTemplates id // (codegen resolves the key via `SdkLanguage.FromSupportedTemplate`), NOT by the // friendly `languageConfig` key. These ids are version-specific in codegen @@ -247,7 +246,6 @@ export class PortalQuickstartAction { key: copilotKey, welcomeMessage: DEFAULT_COPILOT_WELCOME_MESSAGE }; - buildFile.generatePortal!.baseUrl = copilotBaseUrl; buildFile.apiCopilotConfig = apiCopilotConfig; this.enableAiIntegrations(buildFile); } From 1f4e09677c844685a46972c0c002734aa2dace01 Mon Sep 17 00:00:00 2001 From: Sohail Date: Tue, 23 Jun 2026 11:10:04 +0500 Subject: [PATCH 07/44] revert(portal): restore default baseUrl in quickstart, rename to defaultBaseUrl MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Restores generatePortal.baseUrl (default http://localhost:23513) in quickstart copilot setup — it is required. Renames the constant copilotBaseUrl -> defaultBaseUrl. Reverts the removal in e921560 (baseUrl was not the cause of the portal-not-loading issue). Co-Authored-By: Claude Opus 4.8 --- src/actions/portal/quickstart.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/actions/portal/quickstart.ts b/src/actions/portal/quickstart.ts index af64d050..c8b11501 100644 --- a/src/actions/portal/quickstart.ts +++ b/src/actions/portal/quickstart.ts @@ -23,6 +23,7 @@ import { BuildConfig, CopilotConfig } from '../../types/build/build.js'; import { DEFAULT_COPILOT_WELCOME_MESSAGE } from './copilot.js'; const defaultPort: number = 23513 as const; +const defaultBaseUrl: string = `http://localhost:${defaultPort}` as const; // `portalSettings.languageSettings` must be keyed by codegen's SupportedTemplates id // (codegen resolves the key via `SdkLanguage.FromSupportedTemplate`), NOT by the // friendly `languageConfig` key. These ids are version-specific in codegen @@ -246,6 +247,7 @@ export class PortalQuickstartAction { key: copilotKey, welcomeMessage: DEFAULT_COPILOT_WELCOME_MESSAGE }; + buildFile.generatePortal!.baseUrl = defaultBaseUrl; buildFile.apiCopilotConfig = apiCopilotConfig; this.enableAiIntegrations(buildFile); } From f0025c324f326fbb367714522b0bce73d074867f Mon Sep 17 00:00:00 2001 From: Sohail Date: Tue, 23 Jun 2026 11:23:32 +0500 Subject: [PATCH 08/44] fix(portal): write languageSettings for every language so the portal renders MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The quickstart wrote portalSettings.languageSettings only for SDK languages getting AI integration. Supplying a partial languageSettings suppresses codegen's auto-population (which normally creates an entry per language), but codegen still sets initialPlatform from languageConfig — typically `http_curl_v1`. With no languageSettings entry for that platform, the portal widget threw during init: only the static header rendered and docs were never fetched (all network 200). Write an entry for every languageConfig language (mapping http -> http_curl_v1), with AI integration on real SDK languages and an empty entry for http. Mirrors codegen's own auto-population and the known-good build format. Co-Authored-By: Claude Opus 4.8 --- src/actions/portal/quickstart.ts | 38 ++++++++++++++++++++------------ 1 file changed, 24 insertions(+), 14 deletions(-) diff --git a/src/actions/portal/quickstart.ts b/src/actions/portal/quickstart.ts index c8b11501..eea25598 100644 --- a/src/actions/portal/quickstart.ts +++ b/src/actions/portal/quickstart.ts @@ -28,9 +28,8 @@ const defaultBaseUrl: string = `http://localhost:${defaultPort}` as const; // (codegen resolves the key via `SdkLanguage.FromSupportedTemplate`), NOT by the // friendly `languageConfig` key. These ids are version-specific in codegen // (e.g. `php_generic_lib_v2`) — keep in sync with `APIMatic.CodeGen.Common.SdkLanguage`. -// Languages without an entry here (e.g. the synthetic `http`) are skipped: they have -// no SDK template, so codegen cannot resolve them and would throw. const codegenTemplateIdByLanguage: Readonly> = { + http: 'http_curl_v1', typescript: 'ts_generic_lib', csharp: 'cs_net_standard_lib', java: 'java_eclipse_jre_lib', @@ -39,6 +38,9 @@ const codegenTemplateIdByLanguage: Readonly> = { ruby: 'ruby_generic_lib', go: 'go_generic_lib' }; +// `http` has no SDK, so it gets a languageSettings entry (so the portal's +// initialPlatform always resolves) but no AI editor integration. +const nonSdkLanguage = 'http' as const; export class PortalQuickstartAction { private readonly prompts: PortalQuickstartPrompts = new PortalQuickstartPrompts(); @@ -252,10 +254,14 @@ export class PortalQuickstartAction { this.enableAiIntegrations(buildFile); } - // Enables Cursor, Claude Code and VS Code integrations for every SDK language in - // the portal's languageConfig, preserving any existing per-language settings. - // languageSettings is keyed by codegen's SupportedTemplates id (see - // codegenTemplateIdByLanguage); languages without a known template id are skipped. + // Writes a portalSettings.languageSettings entry for every language in + // languageConfig, keyed by its codegen SupportedTemplates id. Real SDK languages + // get the Cursor/Claude Code/VS Code integrations; `http` gets an empty entry. + // Every language must be present: codegen derives initialPlatform from + // languageConfig, and the portal widget fails to render (only the header shows, + // docs are never fetched) if that platform has no languageSettings entry. By + // supplying languageSettings we also suppress codegen's own auto-population, so we + // must mirror it for all languages, not just the ones getting AI integration. private enableAiIntegrations(buildFile: BuildConfig): void { const portalSettings = (buildFile.generatePortal!.portalSettings ??= {}); const languageSettings = (portalSettings.languageSettings ??= {}); @@ -265,14 +271,18 @@ export class PortalQuickstartAction { if (!templateId) { continue; } - languageSettings[templateId] = { - ...languageSettings[templateId], - aiIntegration: { - cursor: { isEnabled: true }, - claudeCode: { isEnabled: true }, - vscode: { isEnabled: true } - } - }; + const existing = languageSettings[templateId] ?? {}; + languageSettings[templateId] = + language === nonSdkLanguage + ? existing + : { + ...existing, + aiIntegration: { + cursor: { isEnabled: true }, + claudeCode: { isEnabled: true }, + vscode: { isEnabled: true } + } + }; } } } From 62f4b29c773b55db4d4b4332a34ff7fe45b3d7df Mon Sep 17 00:00:00 2001 From: Sohail Date: Tue, 23 Jun 2026 11:28:41 +0500 Subject: [PATCH 09/44] refactor(portal): limit aiIntegration to selected SDK languages, add http entry explicitly AI integration is written only for the selected SDK languages (the codegen template-id map no longer includes http). The http_curl_v1 languageSettings entry the portal needs to render is added as a single explicit line rather than via the language loop. Co-Authored-By: Claude Opus 4.8 --- src/actions/portal/quickstart.ts | 41 +++++++++++++------------------- 1 file changed, 17 insertions(+), 24 deletions(-) diff --git a/src/actions/portal/quickstart.ts b/src/actions/portal/quickstart.ts index eea25598..e228e0cb 100644 --- a/src/actions/portal/quickstart.ts +++ b/src/actions/portal/quickstart.ts @@ -29,7 +29,6 @@ const defaultBaseUrl: string = `http://localhost:${defaultPort}` as const; // friendly `languageConfig` key. These ids are version-specific in codegen // (e.g. `php_generic_lib_v2`) — keep in sync with `APIMatic.CodeGen.Common.SdkLanguage`. const codegenTemplateIdByLanguage: Readonly> = { - http: 'http_curl_v1', typescript: 'ts_generic_lib', csharp: 'cs_net_standard_lib', java: 'java_eclipse_jre_lib', @@ -38,9 +37,9 @@ const codegenTemplateIdByLanguage: Readonly> = { ruby: 'ruby_generic_lib', go: 'go_generic_lib' }; -// `http` has no SDK, so it gets a languageSettings entry (so the portal's -// initialPlatform always resolves) but no AI editor integration. -const nonSdkLanguage = 'http' as const; +// codegen derives initialPlatform from languageConfig (http is always first), so a +// languageSettings entry for http must exist or the portal widget fails to render. +const httpTemplateId = 'http_curl_v1' as const; export class PortalQuickstartAction { private readonly prompts: PortalQuickstartPrompts = new PortalQuickstartPrompts(); @@ -254,35 +253,29 @@ export class PortalQuickstartAction { this.enableAiIntegrations(buildFile); } - // Writes a portalSettings.languageSettings entry for every language in - // languageConfig, keyed by its codegen SupportedTemplates id. Real SDK languages - // get the Cursor/Claude Code/VS Code integrations; `http` gets an empty entry. - // Every language must be present: codegen derives initialPlatform from - // languageConfig, and the portal widget fails to render (only the header shows, - // docs are never fetched) if that platform has no languageSettings entry. By - // supplying languageSettings we also suppress codegen's own auto-population, so we - // must mirror it for all languages, not just the ones getting AI integration. + // Enables Cursor/Claude Code/VS Code integrations for the selected SDK languages. + // Supplying languageSettings suppresses codegen's own per-language auto-population, + // so we also add the http entry (no AI integration) the portal needs to render — + // initialPlatform defaults to http, and a missing entry leaves the widget unrendered. private enableAiIntegrations(buildFile: BuildConfig): void { const portalSettings = (buildFile.generatePortal!.portalSettings ??= {}); const languageSettings = (portalSettings.languageSettings ??= {}); + languageSettings[httpTemplateId] ??= {}; + for (const language of Object.keys(buildFile.generatePortal!.languageConfig)) { const templateId = codegenTemplateIdByLanguage[language]; if (!templateId) { continue; } - const existing = languageSettings[templateId] ?? {}; - languageSettings[templateId] = - language === nonSdkLanguage - ? existing - : { - ...existing, - aiIntegration: { - cursor: { isEnabled: true }, - claudeCode: { isEnabled: true }, - vscode: { isEnabled: true } - } - }; + languageSettings[templateId] = { + ...languageSettings[templateId], + aiIntegration: { + cursor: { isEnabled: true }, + claudeCode: { isEnabled: true }, + vscode: { isEnabled: true } + } + }; } } } From c214970c3231e2c1748474cc72ed28ca1ffe8ae9 Mon Sep 17 00:00:00 2001 From: Sohail Date: Tue, 23 Jun 2026 11:30:30 +0500 Subject: [PATCH 10/44] feat(portal): open quickstart portal on the first SDK language Set portalSettings.initialPlatform to the first SDK language (the entry after http in languageSettings) so the portal opens on a real SDK instead of http. Co-Authored-By: Claude Opus 4.8 --- src/actions/portal/quickstart.ts | 7 +++++++ src/types/build/build.ts | 2 ++ 2 files changed, 9 insertions(+) diff --git a/src/actions/portal/quickstart.ts b/src/actions/portal/quickstart.ts index e228e0cb..5e4723ce 100644 --- a/src/actions/portal/quickstart.ts +++ b/src/actions/portal/quickstart.ts @@ -263,11 +263,13 @@ export class PortalQuickstartAction { languageSettings[httpTemplateId] ??= {}; + let firstSdkTemplateId: string | undefined; for (const language of Object.keys(buildFile.generatePortal!.languageConfig)) { const templateId = codegenTemplateIdByLanguage[language]; if (!templateId) { continue; } + firstSdkTemplateId ??= templateId; languageSettings[templateId] = { ...languageSettings[templateId], aiIntegration: { @@ -277,5 +279,10 @@ export class PortalQuickstartAction { } }; } + + // Open the portal on the first SDK language (the entry after http) rather than http. + if (firstSdkTemplateId) { + portalSettings.initialPlatform = firstSdkTemplateId; + } } } diff --git a/src/types/build/build.ts b/src/types/build/build.ts index b853497d..86f18065 100644 --- a/src/types/build/build.ts +++ b/src/types/build/build.ts @@ -22,6 +22,8 @@ export interface PortalConfig { export interface PortalSettings { /** Base URL for the API calls made by the portal. Preferred over `generatePortal.baseUrl` for portal artifacts. */ baseUrl?: string; + /** Language/template id the portal opens on first load. */ + initialPlatform?: string; /** Per-language portal settings, keyed by language/template id. */ languageSettings?: { [language: string]: LanguageSetting }; [key: string]: unknown; From 5353e1728ac1c92792fd50c2b5f02805609a3980 Mon Sep 17 00:00:00 2001 From: Sohail Date: Tue, 23 Jun 2026 11:38:13 +0500 Subject: [PATCH 11/44] fix(portal): cancel quickstart when copilot key selection is cancelled When the account has multiple Copilot keys and the user cancels the key-selection prompt, abort the quickstart (ActionResult.cancelled) instead of silently finishing without Copilot/AI integration. No-key and single-key paths are unchanged (skip silently / auto-use). Co-Authored-By: Claude Opus 4.8 --- src/actions/portal/quickstart.ts | 27 ++++++++++++++++++++------- src/prompts/portal/quickstart.ts | 4 ++++ 2 files changed, 24 insertions(+), 7 deletions(-) diff --git a/src/actions/portal/quickstart.ts b/src/actions/portal/quickstart.ts index 5e4723ce..cbfa9bfd 100644 --- a/src/actions/portal/quickstart.ts +++ b/src/actions/portal/quickstart.ts @@ -195,7 +195,10 @@ export class PortalQuickstartAction { const buildFile = await tempBuildContext.getBuildFileContents(); buildFile.generatePortal!.languageConfig = getLanguagesConfig(languages); - await this.configureApiCopilot(buildFile); + if (await this.configureApiCopilot(buildFile)) { + this.prompts.noCopilotKeySelected(); + return ActionResult.cancelled(); + } await tempBuildContext.updateBuildFileContents(buildFile); const sourceDirectory = inputDirectory.join('src'); @@ -227,20 +230,29 @@ export class PortalQuickstartAction { // block, and enables AI editor integrations for every configured SDK language. // Copilot is opt-in based on account access: when the user has no Copilot key // (or the lookup fails) it is skipped silently, never fatal. - private async configureApiCopilot(buildFile: BuildConfig): Promise { + // Returns true if the user cancelled Copilot key selection — quickstart should + // abort. When the account has no Copilot key (or the lookup fails), Copilot is + // skipped silently and this returns false so quickstart continues without it. + private async configureApiCopilot(buildFile: BuildConfig): Promise { const accountInfo = await this.apiService.getAccountInfo(this.configDir, this.commandMetadata.shell, null); if (accountInfo.isErr()) { - return; + return false; } const copilotKeys = accountInfo.value.ApiCopilotKeys ?? []; if (copilotKeys.length === 0) { - return; + return false; } - const copilotKey = copilotKeys.length === 1 ? copilotKeys[0] : await this.prompts.selectCopilotKey(copilotKeys); - if (!copilotKey) { - return; + let copilotKey: string; + if (copilotKeys.length === 1) { + copilotKey = copilotKeys[0]; + } else { + const selectedKey = await this.prompts.selectCopilotKey(copilotKeys); + if (!selectedKey) { + return true; + } + copilotKey = selectedKey; } const apiCopilotConfig: CopilotConfig = { @@ -251,6 +263,7 @@ export class PortalQuickstartAction { buildFile.generatePortal!.baseUrl = defaultBaseUrl; buildFile.apiCopilotConfig = apiCopilotConfig; this.enableAiIntegrations(buildFile); + return false; } // Enables Cursor/Claude Code/VS Code integrations for the selected SDK languages. diff --git a/src/prompts/portal/quickstart.ts b/src/prompts/portal/quickstart.ts index 75cf0dff..40778b0a 100644 --- a/src/prompts/portal/quickstart.ts +++ b/src/prompts/portal/quickstart.ts @@ -226,6 +226,10 @@ ${f.link(referenceDocumentationUrl)}`; return selectedKey; } + public noCopilotKeySelected() { + log.error("No API Copilot key was selected."); + } + public printDirectoryStructure(inputDirectory: DirectoryPath, directory: Directory) { const heading = `${f.var("src")} directory containing source files created at ${f.path(inputDirectory)}\n`; const message = getTree(directory.toTreeNode()); From 54c5ec364f5efcd091195fd876183ce18ab366cd Mon Sep 17 00:00:00 2001 From: Sohail Date: Tue, 23 Jun 2026 11:39:11 +0500 Subject: [PATCH 12/44] style(portal): resolve SonarCloud warnings - Merge the duplicate `types/build/build.js` import in quickstart. - Use an optional chain (`!parsedUrl?.isLocalhost()`) in the serve base-URL check. Co-Authored-By: Claude Opus 4.8 --- src/actions/portal/quickstart.ts | 3 +-- src/actions/portal/serve.ts | 2 +- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/src/actions/portal/quickstart.ts b/src/actions/portal/quickstart.ts index cbfa9bfd..bec3cf34 100644 --- a/src/actions/portal/quickstart.ts +++ b/src/actions/portal/quickstart.ts @@ -13,13 +13,12 @@ import { ValidateAction } from '../api/validate.js'; import { BuildContext } from '../../types/build-context.js'; import { TempContext } from '../../types/temp-context.js'; import { FileDownloadService } from '../../infrastructure/services/file-download-service.js'; -import { getLanguagesConfig } from '../../types/build/build.js'; +import { getLanguagesConfig, BuildConfig, CopilotConfig } from '../../types/build/build.js'; import { FilePath } from '../../types/file/filePath.js'; import { SpecContext } from '../../types/spec-context.js'; import { FeaturesToRemove, ValidationService } from '../../infrastructure/services/validation-service.js'; import { FileName } from '../../types/file/fileName.js'; import { ApiService } from '../../infrastructure/services/api-service.js'; -import { BuildConfig, CopilotConfig } from '../../types/build/build.js'; import { DEFAULT_COPILOT_WELCOME_MESSAGE } from './copilot.js'; const defaultPort: number = 23513 as const; diff --git a/src/actions/portal/serve.ts b/src/actions/portal/serve.ts index 52e84e9d..90f1e580 100644 --- a/src/actions/portal/serve.ts +++ b/src/actions/portal/serve.ts @@ -149,7 +149,7 @@ export class PortalServeAction { } const parsedUrl = UrlPath.create(baseUrl); - if (!parsedUrl || !parsedUrl.isLocalhost()) { + if (!parsedUrl?.isLocalhost()) { return; } From 1fe7393f5bb40c8288d3861b840fa308b10f5a51 Mon Sep 17 00:00:00 2001 From: Sohail Date: Tue, 23 Jun 2026 12:46:45 +0500 Subject: [PATCH 13/44] fix(portal): reconcile localhost baseUrl port with serve port before generation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previously the serve port was resolved AFTER portal generation, which bakes the baseUrl into the artifacts. When the preferred port (23513) was taken, serve fell back to 3000 while the baked baseUrl still said 23513, so the portal loaded its content from the wrong port and rendered only the header — and in quickstart the mismatch warning was suppressed, so it failed silently. Resolve the serve port first, then, for localhost base URLs whose port differs from the actual serve port, rewrite the baseUrl port and persist it to the build file BEFORE generation. Covers both `portal serve` and quickstart (which delegates to PortalServeAction.execute). The informational message is shown for standalone serve and suppressed during quickstart. - Replace warnOnBaseUrlPortMismatch with reconcileBaseUrlPort (read -> rewrite -> persist) and run it before GenerateAction. - Add UrlPath.withPort(port). - Replace the baseUrlPortMismatch warning prompt with baseUrlPortUpdated (info). Co-Authored-By: Claude Opus 4.8 --- src/actions/portal/serve.ts | 55 +++++++++++++++++++++++-------------- src/prompts/portal/serve.ts | 14 ++++------ src/types/file/urlPath.ts | 9 ++++++ 3 files changed, 48 insertions(+), 30 deletions(-) diff --git a/src/actions/portal/serve.ts b/src/actions/portal/serve.ts index 90f1e580..fa191031 100644 --- a/src/actions/portal/serve.ts +++ b/src/actions/portal/serve.ts @@ -36,21 +36,21 @@ export class PortalServeAction { hotReload: boolean, onAfterServe?: () => void ): Promise { - const generatePortalAction = new GenerateAction(this.configDir, this.commandMetadata, this.authKey); - const result = await generatePortalAction.execute(buildDirectory, portalDirectory, true, false); - if (result.isFailed()) { - return ActionResult.failed(); - } - const servePort = await this.networkService.getServerPort([port, 3000, 3001, 3002]); if (servePort != port && !onAfterServe) { this.prompts.usingFallbackPort(port, servePort); } - // The serve port is chosen by the CLI during quickstart (onAfterServe set), so - // a mismatch warning there would be noise about a port the user did not pick. - if (!onAfterServe) { - await this.warnOnBaseUrlPortMismatch(buildDirectory, servePort); + // Align the configured localhost base URL with the actual serve port BEFORE + // generation bakes it into the portal artifacts; otherwise the portal would load + // its content from the wrong port and fail to render. Reconciliation runs in both + // flows; the message is suppressed during quickstart (the CLI chose the port). + await this.reconcileBaseUrlPort(buildDirectory, servePort, !onAfterServe); + + const generatePortalAction = new GenerateAction(this.configDir, this.commandMetadata, this.authKey); + const result = await generatePortalAction.execute(buildDirectory, portalDirectory, true, false); + if (result.isFailed()) { + return ActionResult.failed(); } const liveReloadPort = await this.networkService.getServerPort([35729, 35730, 35731, 35732]); @@ -129,33 +129,46 @@ export class PortalServeAction { return ActionResult.success(); } - // Warns when the portal's configured base URL points at localhost on a port - // that differs from the port the portal is actually being served on. - private async warnOnBaseUrlPortMismatch(buildDirectory: DirectoryPath, servePort: number): Promise { - // The build file was already validated by the GenerateAction above, so read it - // directly; guard only against an unreadable/malformed file. + // Rewrites the portal's configured localhost base URL so its port matches the port + // the portal is actually served on, then persists it to the build file. Only + // localhost base URLs are touched; non-localhost URLs and matching ports are left + // as-is. `informUser` is false during quickstart, where the CLI chose the port. + private async reconcileBaseUrlPort( + buildDirectory: DirectoryPath, + servePort: number, + informUser: boolean + ): Promise { + const buildContext = new BuildContext(buildDirectory); let buildConfig; try { - buildConfig = await new BuildContext(buildDirectory).getBuildFileContents(); + buildConfig = await buildContext.getBuildFileContents(); } catch { return; } // `portalSettings.baseUrl` is preferred for portal artifacts; otherwise fall // back to `generatePortal.baseUrl`. Mirrors how codegen resolves the base URL. - const baseUrl = buildConfig.generatePortal?.portalSettings?.baseUrl ?? buildConfig.generatePortal?.baseUrl; + const portalSettings = buildConfig.generatePortal?.portalSettings; + const baseUrl = portalSettings?.baseUrl ?? buildConfig.generatePortal?.baseUrl; if (!baseUrl) { return; } const parsedUrl = UrlPath.create(baseUrl); - if (!parsedUrl?.isLocalhost()) { + if (!parsedUrl?.isLocalhost() || parsedUrl.port() === servePort) { return; } - const baseUrlPort = parsedUrl.port(); - if (baseUrlPort !== servePort) { - this.prompts.baseUrlPortMismatch(baseUrl, baseUrlPort, servePort); + const updatedUrl = parsedUrl.withPort(servePort).toString(); + if (portalSettings?.baseUrl) { + portalSettings.baseUrl = updatedUrl; + } else { + buildConfig.generatePortal!.baseUrl = updatedUrl; + } + await buildContext.updateBuildFileContents(buildConfig); + + if (informUser) { + this.prompts.baseUrlPortUpdated(baseUrl, updatedUrl); } } diff --git a/src/prompts/portal/serve.ts b/src/prompts/portal/serve.ts index a90da257..8f85c8c2 100644 --- a/src/prompts/portal/serve.ts +++ b/src/prompts/portal/serve.ts @@ -13,15 +13,11 @@ export class PortalServePrompts { log.step(message); } - public baseUrlPortMismatch(baseUrl: string, baseUrlPort: number, servePort: number) { - const message = `The configured base URL ${f.var(baseUrl)} uses port ${f.var( - baseUrlPort.toString() - )}, but the portal is being served on port ${f.var( - servePort.toString() - )}. This mismatch may cause unexpected behavior. Update the base URL in your configuration or serve on port ${f.var( - baseUrlPort.toString() - )} to keep them aligned.`; - log.warn(message); + public baseUrlPortUpdated(previousUrl: string, updatedUrl: string) { + const message = + `The configured base URL ${f.var(previousUrl)} did not match the serve port. ` + + `Updated it to ${f.var(updatedUrl)} so the portal loads correctly.`; + log.info(message); } public portalServed(urlPath: UrlPath) { diff --git a/src/types/file/urlPath.ts b/src/types/file/urlPath.ts index e747efea..61f3492f 100644 --- a/src/types/file/urlPath.ts +++ b/src/types/file/urlPath.ts @@ -37,4 +37,13 @@ export class UrlPath { } return parsed.protocol === "https:" ? 443 : 80; } + + /** Returns a copy of this URL with its port replaced, preserving the rest of the URL. */ + public withPort(port: number): UrlPath { + const parsed = new URL(this.url); + parsed.port = String(port); + const updated = parsed.toString(); + // URL.toString() appends a trailing slash to origin-only URLs; keep the original's form. + return new UrlPath(this.url.endsWith("/") ? updated : updated.replace(/\/+$/, "")); + } } From 60b29ed18b54717bcb6b1ae0f2a52e68b0dec227 Mon Sep 17 00:00:00 2001 From: Sohail Date: Tue, 23 Jun 2026 12:55:11 +0500 Subject: [PATCH 14/44] fix(portal): surface fallback-port notice during quickstart The "port X is in use, using Y" message was suppressed in the quickstart flow, so when the default 23513 (and 3000) were taken the portal silently came up on a different port with no explanation. Show the fallback notice in both flows; it only fires when a fallback actually happens. The separate baseUrl-reconcile message stays suppressed during quickstart to avoid redundancy. Co-Authored-By: Claude Opus 4.8 --- src/actions/portal/serve.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/actions/portal/serve.ts b/src/actions/portal/serve.ts index fa191031..85c48d55 100644 --- a/src/actions/portal/serve.ts +++ b/src/actions/portal/serve.ts @@ -37,7 +37,7 @@ export class PortalServeAction { onAfterServe?: () => void ): Promise { const servePort = await this.networkService.getServerPort([port, 3000, 3001, 3002]); - if (servePort != port && !onAfterServe) { + if (servePort != port) { this.prompts.usingFallbackPort(port, servePort); } From aec28206a404a4cd93cf66b6999ef5fe8c54b0de Mon Sep 17 00:00:00 2001 From: Sohail Date: Tue, 23 Jun 2026 13:00:11 +0500 Subject: [PATCH 15/44] feat(portal): warn about training-data overwrite when enabling copilot in quickstart After Copilot is wired into the build config, show which key it was enabled with and warn that any existing training data on that key will be overwritten on the next portal generation. Co-Authored-By: Claude Opus 4.8 --- src/actions/portal/quickstart.ts | 1 + src/prompts/portal/quickstart.ts | 7 +++++++ 2 files changed, 8 insertions(+) diff --git a/src/actions/portal/quickstart.ts b/src/actions/portal/quickstart.ts index bec3cf34..46018dcc 100644 --- a/src/actions/portal/quickstart.ts +++ b/src/actions/portal/quickstart.ts @@ -262,6 +262,7 @@ export class PortalQuickstartAction { buildFile.generatePortal!.baseUrl = defaultBaseUrl; buildFile.apiCopilotConfig = apiCopilotConfig; this.enableAiIntegrations(buildFile); + this.prompts.copilotEnabled(copilotKey); return false; } diff --git a/src/prompts/portal/quickstart.ts b/src/prompts/portal/quickstart.ts index 40778b0a..497d23a5 100644 --- a/src/prompts/portal/quickstart.ts +++ b/src/prompts/portal/quickstart.ts @@ -230,6 +230,13 @@ ${f.link(referenceDocumentationUrl)}`; log.error("No API Copilot key was selected."); } + public copilotEnabled(key: string) { + const message = + `API Copilot is enabled with key ${f.var(key)}. ` + + `Any existing training data associated with this key will be overwritten when the portal is generated.`; + log.warn(message); + } + public printDirectoryStructure(inputDirectory: DirectoryPath, directory: Directory) { const heading = `${f.var("src")} directory containing source files created at ${f.path(inputDirectory)}\n`; const message = getTree(directory.toTreeNode()); From 22051922bb26cf64e89b24776f5acea1964992b9 Mon Sep 17 00:00:00 2001 From: Sohail Date: Tue, 23 Jun 2026 13:01:27 +0500 Subject: [PATCH 16/44] style(portal): avoid regex backtracking in UrlPath.withPort Replace the trailing-slash regex with endsWith/slice to resolve the SonarCloud super-linear-backtracking warning. Co-Authored-By: Claude Opus 4.8 --- src/types/file/urlPath.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/types/file/urlPath.ts b/src/types/file/urlPath.ts index 61f3492f..eea96c5f 100644 --- a/src/types/file/urlPath.ts +++ b/src/types/file/urlPath.ts @@ -43,7 +43,8 @@ export class UrlPath { const parsed = new URL(this.url); parsed.port = String(port); const updated = parsed.toString(); - // URL.toString() appends a trailing slash to origin-only URLs; keep the original's form. - return new UrlPath(this.url.endsWith("/") ? updated : updated.replace(/\/+$/, "")); + // URL.toString() appends a single trailing slash to origin-only URLs; keep the original's form. + const trimmed = updated.endsWith("/") ? updated.slice(0, -1) : updated; + return new UrlPath(this.url.endsWith("/") ? updated : trimmed); } } From a3222fb25925271517d953b578b48fee7d3c1536 Mon Sep 17 00:00:00 2001 From: Sohail Date: Tue, 23 Jun 2026 13:05:55 +0500 Subject: [PATCH 17/44] feat(portal): inform user of baseUrl port reconciliation in quickstart too Show the "base URL updated to match serve port" message in both flows. Drop the now-always-true informUser parameter from reconcileBaseUrlPort. Co-Authored-By: Claude Opus 4.8 --- src/actions/portal/serve.ts | 21 +++++++-------------- 1 file changed, 7 insertions(+), 14 deletions(-) diff --git a/src/actions/portal/serve.ts b/src/actions/portal/serve.ts index 85c48d55..8efae60d 100644 --- a/src/actions/portal/serve.ts +++ b/src/actions/portal/serve.ts @@ -43,9 +43,8 @@ export class PortalServeAction { // Align the configured localhost base URL with the actual serve port BEFORE // generation bakes it into the portal artifacts; otherwise the portal would load - // its content from the wrong port and fail to render. Reconciliation runs in both - // flows; the message is suppressed during quickstart (the CLI chose the port). - await this.reconcileBaseUrlPort(buildDirectory, servePort, !onAfterServe); + // its content from the wrong port and fail to render. + await this.reconcileBaseUrlPort(buildDirectory, servePort); const generatePortalAction = new GenerateAction(this.configDir, this.commandMetadata, this.authKey); const result = await generatePortalAction.execute(buildDirectory, portalDirectory, true, false); @@ -130,14 +129,10 @@ export class PortalServeAction { } // Rewrites the portal's configured localhost base URL so its port matches the port - // the portal is actually served on, then persists it to the build file. Only - // localhost base URLs are touched; non-localhost URLs and matching ports are left - // as-is. `informUser` is false during quickstart, where the CLI chose the port. - private async reconcileBaseUrlPort( - buildDirectory: DirectoryPath, - servePort: number, - informUser: boolean - ): Promise { + // the portal is actually served on, then persists it to the build file and informs + // the user. Only localhost base URLs are touched; non-localhost URLs and matching + // ports are left as-is. + private async reconcileBaseUrlPort(buildDirectory: DirectoryPath, servePort: number): Promise { const buildContext = new BuildContext(buildDirectory); let buildConfig; try { @@ -167,9 +162,7 @@ export class PortalServeAction { } await buildContext.updateBuildFileContents(buildConfig); - if (informUser) { - this.prompts.baseUrlPortUpdated(baseUrl, updatedUrl); - } + this.prompts.baseUrlPortUpdated(baseUrl, updatedUrl); } // This clears the standard input to allow interrupts like CTRL+C to work properly. From 71756933bf0e8f891b29cc5d56eb867ff87ee5b5 Mon Sep 17 00:00:00 2001 From: Sohail Date: Tue, 23 Jun 2026 13:11:51 +0500 Subject: [PATCH 18/44] refactor(portal): resolve copilot key before source-directory setup Split configureApiCopilot into resolveCopilotKey (account lookup + key selection + enabled/overwrite warning) and applyCopilotConfig (mutates the build file). Resolve the key before downloading/setting up the source directory so the Copilot prompt and warning appear ahead of the "source directory set up" / "src directory created" messages, then apply the config to the build file after it's fetched. Co-Authored-By: Claude Opus 4.8 --- src/actions/portal/quickstart.ts | 42 +++++++++++++++++++------------- 1 file changed, 25 insertions(+), 17 deletions(-) diff --git a/src/actions/portal/quickstart.ts b/src/actions/portal/quickstart.ts index 46018dcc..ff9f9d1e 100644 --- a/src/actions/portal/quickstart.ts +++ b/src/actions/portal/quickstart.ts @@ -177,6 +177,12 @@ export class PortalQuickstartAction { break; } + const copilotKeyResult = await this.resolveCopilotKey(); + if (copilotKeyResult.cancelled) { + this.prompts.noCopilotKeySelected(); + return ActionResult.cancelled(); + } + const masterBuildFile = await this.prompts.downloadBuildDirectory( this.fileDownloadService.downloadFile(this.buildFileUrl) ); @@ -194,9 +200,8 @@ export class PortalQuickstartAction { const buildFile = await tempBuildContext.getBuildFileContents(); buildFile.generatePortal!.languageConfig = getLanguagesConfig(languages); - if (await this.configureApiCopilot(buildFile)) { - this.prompts.noCopilotKeySelected(); - return ActionResult.cancelled(); + if (copilotKeyResult.key) { + this.applyCopilotConfig(buildFile, copilotKeyResult.key); } await tempBuildContext.updateBuildFileContents(buildFile); @@ -224,23 +229,20 @@ export class PortalQuickstartAction { }); }; - // When the account has an API Copilot key, wires Copilot into the build config: - // points the portal base URL at the local serve port, adds the apiCopilotConfig - // block, and enables AI editor integrations for every configured SDK language. - // Copilot is opt-in based on account access: when the user has no Copilot key - // (or the lookup fails) it is skipped silently, never fatal. - // Returns true if the user cancelled Copilot key selection — quickstart should - // abort. When the account has no Copilot key (or the lookup fails), Copilot is - // skipped silently and this returns false so quickstart continues without it. - private async configureApiCopilot(buildFile: BuildConfig): Promise { + // Resolves the API Copilot key to enable, if any, BEFORE the source directory is set + // up so the user decides on Copilot up front. Copilot is opt-in based on account + // access: when the user has no Copilot key (or the lookup fails) it is skipped + // silently ({ cancelled: false } with no key). When multiple keys exist and the user + // cancels selection, returns { cancelled: true } so quickstart aborts. + private async resolveCopilotKey(): Promise<{ cancelled: boolean; key?: string }> { const accountInfo = await this.apiService.getAccountInfo(this.configDir, this.commandMetadata.shell, null); if (accountInfo.isErr()) { - return false; + return { cancelled: false }; } const copilotKeys = accountInfo.value.ApiCopilotKeys ?? []; if (copilotKeys.length === 0) { - return false; + return { cancelled: false }; } let copilotKey: string; @@ -249,11 +251,19 @@ export class PortalQuickstartAction { } else { const selectedKey = await this.prompts.selectCopilotKey(copilotKeys); if (!selectedKey) { - return true; + return { cancelled: true }; } copilotKey = selectedKey; } + this.prompts.copilotEnabled(copilotKey); + return { cancelled: false, key: copilotKey }; + } + + // Wires the resolved Copilot key into the build config: points the portal base URL + // at the local serve port, adds the apiCopilotConfig block, and enables AI editor + // integrations for every configured SDK language. + private applyCopilotConfig(buildFile: BuildConfig, copilotKey: string): void { const apiCopilotConfig: CopilotConfig = { isEnabled: true, key: copilotKey, @@ -262,8 +272,6 @@ export class PortalQuickstartAction { buildFile.generatePortal!.baseUrl = defaultBaseUrl; buildFile.apiCopilotConfig = apiCopilotConfig; this.enableAiIntegrations(buildFile); - this.prompts.copilotEnabled(copilotKey); - return false; } // Enables Cursor/Claude Code/VS Code integrations for the selected SDK languages. From ffca7e2c271aadf8d39304e4b6b0cc0b366eb8d5 Mon Sep 17 00:00:00 2001 From: Sohail Date: Tue, 23 Jun 2026 13:31:33 +0500 Subject: [PATCH 19/44] fix: refactor quickstart code to use build Context --- src/actions/portal/quickstart.ts | 41 +++++++++++++++++++------------- src/prompts/portal/serve.ts | 2 +- 2 files changed, 25 insertions(+), 18 deletions(-) diff --git a/src/actions/portal/quickstart.ts b/src/actions/portal/quickstart.ts index ff9f9d1e..5befe0ef 100644 --- a/src/actions/portal/quickstart.ts +++ b/src/actions/portal/quickstart.ts @@ -177,7 +177,7 @@ export class PortalQuickstartAction { break; } - const copilotKeyResult = await this.resolveCopilotKey(); + const copilotKeyResult = await this.getCopilotKey(); if (copilotKeyResult.cancelled) { this.prompts.noCopilotKeySelected(); return ActionResult.cancelled(); @@ -195,19 +195,26 @@ export class PortalQuickstartAction { await this.zipService.unArchive(masterBuildFilePath, tempDirectory); const extractedFolder = tempDirectory.join(this.repositoryFolderName); + // Clean up the workflow dir from the template before copying const tempBuildContext = new BuildContext(extractedFolder); await tempBuildContext.deleteWorkflowDir(); - const buildFile = await tempBuildContext.getBuildFileContents(); - buildFile.generatePortal!.languageConfig = getLanguagesConfig(languages); - if (copilotKeyResult.key) { - this.applyCopilotConfig(buildFile, copilotKeyResult.key); - } - await tempBuildContext.updateBuildFileContents(buildFile); - + // Copy the template into the final destination const sourceDirectory = inputDirectory.join('src'); await this.fileService.copyDirectoryContents(extractedFolder, sourceDirectory); + // Update the build file in its final location via BuildContext, + // mirroring exactly how CopilotAction reads and writes the build file + const buildContext = new BuildContext(sourceDirectory); + const buildConfig = await buildContext.getBuildFileContents(); + buildConfig.generatePortal!.languageConfig = getLanguagesConfig(languages); + if (copilotKeyResult.key) { + this.applyCopilotConfig(buildConfig, copilotKeyResult.key); + } else { + this.enableAiIntegrations(buildConfig); + } + await buildContext.updateBuildFileContents(buildConfig); + const specDirectory = sourceDirectory.join('spec'); const specContext = new SpecContext(specDirectory); await specContext.replaceDefaultSpec(specPath); @@ -234,7 +241,7 @@ export class PortalQuickstartAction { // access: when the user has no Copilot key (or the lookup fails) it is skipped // silently ({ cancelled: false } with no key). When multiple keys exist and the user // cancels selection, returns { cancelled: true } so quickstart aborts. - private async resolveCopilotKey(): Promise<{ cancelled: boolean; key?: string }> { + private async getCopilotKey(): Promise<{ cancelled: boolean; key?: string }> { const accountInfo = await this.apiService.getAccountInfo(this.configDir, this.commandMetadata.shell, null); if (accountInfo.isErr()) { return { cancelled: false }; @@ -263,29 +270,29 @@ export class PortalQuickstartAction { // Wires the resolved Copilot key into the build config: points the portal base URL // at the local serve port, adds the apiCopilotConfig block, and enables AI editor // integrations for every configured SDK language. - private applyCopilotConfig(buildFile: BuildConfig, copilotKey: string): void { + private applyCopilotConfig(buildConfig: BuildConfig, copilotKey: string): void { const apiCopilotConfig: CopilotConfig = { isEnabled: true, key: copilotKey, welcomeMessage: DEFAULT_COPILOT_WELCOME_MESSAGE }; - buildFile.generatePortal!.baseUrl = defaultBaseUrl; - buildFile.apiCopilotConfig = apiCopilotConfig; - this.enableAiIntegrations(buildFile); + buildConfig.generatePortal!.baseUrl = defaultBaseUrl; + buildConfig.apiCopilotConfig = apiCopilotConfig; + this.enableAiIntegrations(buildConfig); } // Enables Cursor/Claude Code/VS Code integrations for the selected SDK languages. // Supplying languageSettings suppresses codegen's own per-language auto-population, // so we also add the http entry (no AI integration) the portal needs to render — // initialPlatform defaults to http, and a missing entry leaves the widget unrendered. - private enableAiIntegrations(buildFile: BuildConfig): void { - const portalSettings = (buildFile.generatePortal!.portalSettings ??= {}); + private enableAiIntegrations(buildConfig: BuildConfig): void { + const portalSettings = (buildConfig.generatePortal!.portalSettings ??= {}); const languageSettings = (portalSettings.languageSettings ??= {}); languageSettings[httpTemplateId] ??= {}; let firstSdkTemplateId: string | undefined; - for (const language of Object.keys(buildFile.generatePortal!.languageConfig)) { + for (const language of Object.keys(buildConfig.generatePortal!.languageConfig)) { const templateId = codegenTemplateIdByLanguage[language]; if (!templateId) { continue; @@ -306,4 +313,4 @@ export class PortalQuickstartAction { portalSettings.initialPlatform = firstSdkTemplateId; } } -} +} \ No newline at end of file diff --git a/src/prompts/portal/serve.ts b/src/prompts/portal/serve.ts index 8f85c8c2..c9d4a6a2 100644 --- a/src/prompts/portal/serve.ts +++ b/src/prompts/portal/serve.ts @@ -16,7 +16,7 @@ export class PortalServePrompts { public baseUrlPortUpdated(previousUrl: string, updatedUrl: string) { const message = `The configured base URL ${f.var(previousUrl)} did not match the serve port. ` + - `Updated it to ${f.var(updatedUrl)} so the portal loads correctly.`; + `Updated it to ${f.var(updatedUrl)}.`; log.info(message); } From 54ca5db4b6afa38ce9443059bd39f928fec184d2 Mon Sep 17 00:00:00 2001 From: Sohail Date: Tue, 23 Jun 2026 14:42:05 +0500 Subject: [PATCH 20/44] refactor(build): encapsulate build-config manipulation in a BuildConfig class MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Turn BuildConfig into a rich class wrapping the parsed APIMATIC-BUILD.json data (interface renamed to BuildConfigData). All build-config reads and mutations now go through intent-revealing methods instead of raw `buildConfig.generatePortal!.…` access scattered across actions: - reads: contentFolder(), isVersioned(), versionsPath(), hasApiCopilot() - mutations: setPortalLanguages(), setApiCopilotConfig(), enableApiCopilotForPortal(), enableAiIntegrations(), reconcileLocalhostBaseUrlPort(), addRecipeWorkflow() The codegen template-id map, AI-integration/initialPlatform logic, and the localhost baseUrl reconciliation move into the class. BuildContext becomes the I/O boundary only (parse on read, JSON.stringify via toJSON on write). Callers (quickstart, copilot, serve, toc, recipe-generator) and BuildContext's versioned-portal helpers updated to use the class API. No raw generatePortal access remains outside build.ts. Also drops two pre-existing `any` casts in recipe-generator. Co-Authored-By: Claude Opus 4.8 --- src/actions/portal/copilot.ts | 6 +- src/actions/portal/quickstart.ts | 70 +------- src/actions/portal/serve.ts | 27 +-- src/actions/portal/toc/new-toc.ts | 2 +- .../portal/recipe/recipe-generator.ts | 30 +--- src/types/build-context.ts | 24 ++- src/types/build/build.ts | 165 +++++++++++++++++- 7 files changed, 190 insertions(+), 134 deletions(-) diff --git a/src/actions/portal/copilot.ts b/src/actions/portal/copilot.ts index 5fc2d148..93ea69d5 100644 --- a/src/actions/portal/copilot.ts +++ b/src/actions/portal/copilot.ts @@ -52,7 +52,7 @@ export class CopilotAction { const buildJson = await buildContext.getBuildFileContents(); - if (!force && buildJson.apiCopilotConfig != null && !(await this.prompts.confirmOverwrite())) { + if (!force && buildJson.hasApiCopilot() && !(await this.prompts.confirmOverwrite())) { this.prompts.cancelled(); return ActionResult.cancelled(); } @@ -74,11 +74,11 @@ export class CopilotAction { const welcomeMessage = await this.prepareWelcomeMessage(); - buildJson.apiCopilotConfig = { + buildJson.setApiCopilotConfig({ isEnabled: enable, key: apiCopilotKeyResult.value, welcomeMessage: welcomeMessage - }; + }); await buildContext.updateBuildFileContents(buildJson); diff --git a/src/actions/portal/quickstart.ts b/src/actions/portal/quickstart.ts index 5befe0ef..a51d1769 100644 --- a/src/actions/portal/quickstart.ts +++ b/src/actions/portal/quickstart.ts @@ -13,7 +13,6 @@ import { ValidateAction } from '../api/validate.js'; import { BuildContext } from '../../types/build-context.js'; import { TempContext } from '../../types/temp-context.js'; import { FileDownloadService } from '../../infrastructure/services/file-download-service.js'; -import { getLanguagesConfig, BuildConfig, CopilotConfig } from '../../types/build/build.js'; import { FilePath } from '../../types/file/filePath.js'; import { SpecContext } from '../../types/spec-context.js'; import { FeaturesToRemove, ValidationService } from '../../infrastructure/services/validation-service.js'; @@ -23,22 +22,6 @@ import { DEFAULT_COPILOT_WELCOME_MESSAGE } from './copilot.js'; const defaultPort: number = 23513 as const; const defaultBaseUrl: string = `http://localhost:${defaultPort}` as const; -// `portalSettings.languageSettings` must be keyed by codegen's SupportedTemplates id -// (codegen resolves the key via `SdkLanguage.FromSupportedTemplate`), NOT by the -// friendly `languageConfig` key. These ids are version-specific in codegen -// (e.g. `php_generic_lib_v2`) — keep in sync with `APIMatic.CodeGen.Common.SdkLanguage`. -const codegenTemplateIdByLanguage: Readonly> = { - typescript: 'ts_generic_lib', - csharp: 'cs_net_standard_lib', - java: 'java_eclipse_jre_lib', - php: 'php_generic_lib_v2', - python: 'python_generic_lib', - ruby: 'ruby_generic_lib', - go: 'go_generic_lib' -}; -// codegen derives initialPlatform from languageConfig (http is always first), so a -// languageSettings entry for http must exist or the portal widget fails to render. -const httpTemplateId = 'http_curl_v1' as const; export class PortalQuickstartAction { private readonly prompts: PortalQuickstartPrompts = new PortalQuickstartPrompts(); @@ -207,11 +190,11 @@ export class PortalQuickstartAction { // mirroring exactly how CopilotAction reads and writes the build file const buildContext = new BuildContext(sourceDirectory); const buildConfig = await buildContext.getBuildFileContents(); - buildConfig.generatePortal!.languageConfig = getLanguagesConfig(languages); + buildConfig.setPortalLanguages(languages); if (copilotKeyResult.key) { - this.applyCopilotConfig(buildConfig, copilotKeyResult.key); + buildConfig.enableApiCopilotForPortal(copilotKeyResult.key, DEFAULT_COPILOT_WELCOME_MESSAGE, defaultBaseUrl); } else { - this.enableAiIntegrations(buildConfig); + buildConfig.enableAiIntegrations(); } await buildContext.updateBuildFileContents(buildConfig); @@ -266,51 +249,4 @@ export class PortalQuickstartAction { this.prompts.copilotEnabled(copilotKey); return { cancelled: false, key: copilotKey }; } - - // Wires the resolved Copilot key into the build config: points the portal base URL - // at the local serve port, adds the apiCopilotConfig block, and enables AI editor - // integrations for every configured SDK language. - private applyCopilotConfig(buildConfig: BuildConfig, copilotKey: string): void { - const apiCopilotConfig: CopilotConfig = { - isEnabled: true, - key: copilotKey, - welcomeMessage: DEFAULT_COPILOT_WELCOME_MESSAGE - }; - buildConfig.generatePortal!.baseUrl = defaultBaseUrl; - buildConfig.apiCopilotConfig = apiCopilotConfig; - this.enableAiIntegrations(buildConfig); - } - - // Enables Cursor/Claude Code/VS Code integrations for the selected SDK languages. - // Supplying languageSettings suppresses codegen's own per-language auto-population, - // so we also add the http entry (no AI integration) the portal needs to render — - // initialPlatform defaults to http, and a missing entry leaves the widget unrendered. - private enableAiIntegrations(buildConfig: BuildConfig): void { - const portalSettings = (buildConfig.generatePortal!.portalSettings ??= {}); - const languageSettings = (portalSettings.languageSettings ??= {}); - - languageSettings[httpTemplateId] ??= {}; - - let firstSdkTemplateId: string | undefined; - for (const language of Object.keys(buildConfig.generatePortal!.languageConfig)) { - const templateId = codegenTemplateIdByLanguage[language]; - if (!templateId) { - continue; - } - firstSdkTemplateId ??= templateId; - languageSettings[templateId] = { - ...languageSettings[templateId], - aiIntegration: { - cursor: { isEnabled: true }, - claudeCode: { isEnabled: true }, - vscode: { isEnabled: true } - } - }; - } - - // Open the portal on the first SDK language (the entry after http) rather than http. - if (firstSdkTemplateId) { - portalSettings.initialPlatform = firstSdkTemplateId; - } - } } \ No newline at end of file diff --git a/src/actions/portal/serve.ts b/src/actions/portal/serve.ts index 8efae60d..79cfaf17 100644 --- a/src/actions/portal/serve.ts +++ b/src/actions/portal/serve.ts @@ -128,10 +128,8 @@ export class PortalServeAction { return ActionResult.success(); } - // Rewrites the portal's configured localhost base URL so its port matches the port - // the portal is actually served on, then persists it to the build file and informs - // the user. Only localhost base URLs are touched; non-localhost URLs and matching - // ports are left as-is. + // Aligns the portal's configured localhost base URL with the actual serve port and + // persists the change, informing the user. Delegates the config logic to BuildConfig. private async reconcileBaseUrlPort(buildDirectory: DirectoryPath, servePort: number): Promise { const buildContext = new BuildContext(buildDirectory); let buildConfig; @@ -141,28 +139,13 @@ export class PortalServeAction { return; } - // `portalSettings.baseUrl` is preferred for portal artifacts; otherwise fall - // back to `generatePortal.baseUrl`. Mirrors how codegen resolves the base URL. - const portalSettings = buildConfig.generatePortal?.portalSettings; - const baseUrl = portalSettings?.baseUrl ?? buildConfig.generatePortal?.baseUrl; - if (!baseUrl) { + const change = buildConfig.reconcileLocalhostBaseUrlPort(servePort); + if (!change) { return; } - const parsedUrl = UrlPath.create(baseUrl); - if (!parsedUrl?.isLocalhost() || parsedUrl.port() === servePort) { - return; - } - - const updatedUrl = parsedUrl.withPort(servePort).toString(); - if (portalSettings?.baseUrl) { - portalSettings.baseUrl = updatedUrl; - } else { - buildConfig.generatePortal!.baseUrl = updatedUrl; - } await buildContext.updateBuildFileContents(buildConfig); - - this.prompts.baseUrlPortUpdated(baseUrl, updatedUrl); + this.prompts.baseUrlPortUpdated(change.previous, change.updated); } // This clears the standard input to allow interrupts like CTRL+C to work properly. diff --git a/src/actions/portal/toc/new-toc.ts b/src/actions/portal/toc/new-toc.ts index d7532a75..5fe5b1db 100644 --- a/src/actions/portal/toc/new-toc.ts +++ b/src/actions/portal/toc/new-toc.ts @@ -53,7 +53,7 @@ export class PortalNewTocAction { return ActionResult.failed(); } const buildConfig = await buildContext.getBuildFileContents(); - const contentDirectory = buildDirectory.join(buildConfig.generatePortal?.contentFolder ?? 'content'); + const contentDirectory = buildDirectory.join(buildConfig.contentFolder()); const tocDir = tocDirectory ?? contentDirectory; const tocContext = new TocContext(tocDir); diff --git a/src/application/portal/recipe/recipe-generator.ts b/src/application/portal/recipe/recipe-generator.ts index 48626a77..0aa74b70 100644 --- a/src/application/portal/recipe/recipe-generator.ts +++ b/src/application/portal/recipe/recipe-generator.ts @@ -77,33 +77,11 @@ export class PortalRecipeGenerator { recipeScriptFileName: FileName ): Promise { const buildConfig = await buildContext.getBuildFileContents(); - if (!buildConfig.recipes) { - buildConfig.recipes = {}; - } - const recipesConfig = buildConfig.recipes as any; - - if (!recipesConfig.workflows) { - recipesConfig.workflows = []; - } - const existingIndex = recipesConfig.workflows.findIndex( - (workflow: any) => workflow.permalink === `page:recipes/${this.toPascalCase(recipeName)}` + buildConfig.addRecipeWorkflow( + recipeName, + this.toPascalCase(recipeName), + `./static/scripts/recipes/${recipeScriptFileName}` ); - - const newWorkflow = { - name: recipeName, - permalink: `page:recipes/${this.toPascalCase(recipeName)}`, - functionName: this.toPascalCase(recipeName), - scriptPath: `./static/scripts/recipes/${recipeScriptFileName}` - }; - - if (existingIndex !== -1) { - // Replace the existing workflow - recipesConfig.workflows[existingIndex] = newWorkflow; - } else { - // Add as new workflow - recipesConfig.workflows.push(newWorkflow); - } - await buildContext.updateBuildFileContents(buildConfig); } diff --git a/src/types/build-context.ts b/src/types/build-context.ts index 8dfcfd8b..cd9a4c78 100644 --- a/src/types/build-context.ts +++ b/src/types/build-context.ts @@ -36,11 +36,11 @@ export class BuildContext { public async getBuildFileContents(): Promise { const buildFileContent = await this.fileService.getContents(this.buildFile); - return JSON.parse(buildFileContent) as BuildConfig; + return BuildConfig.parse(buildFileContent); } - public async updateBuildFileContents(buildJson: BuildConfig) { - await this.fileService.writeContents(this.buildFile, JSON.stringify(buildJson, null, 2)); + public async updateBuildFileContents(buildConfig: BuildConfig) { + await this.fileService.writeContents(this.buildFile, JSON.stringify(buildConfig, null, 2)); } public async deleteWorkflowDir() { @@ -77,19 +77,15 @@ export class BuildContext { if (!await this.validate()) { return false; } - const buildConfig = await this.getBuildFileContents(); - if (buildConfig.generateVersionedPortal) { - return true; - } - return false; + return (await this.getBuildFileContents()).isVersioned(); } public async getVersionedBuildDirectory(): Promise { const buildConfig = await this.getBuildFileContents(); - if (!buildConfig.generateVersionedPortal) { + if (!buildConfig.isVersioned()) { return undefined; } - const versionsDirectory = this.buildDirectory.join(buildConfig.versionsPath ?? 'versioned_docs'); + const versionsDirectory = this.buildDirectory.join(buildConfig.versionsPath()); if (!await this.fileService.directoryExists(versionsDirectory)) { return undefined; } @@ -99,10 +95,10 @@ export class BuildContext { public async getSingleVersionedBuildDirectory(): Promise { const buildConfig = await this.getBuildFileContents(); - if (!buildConfig.generateVersionedPortal) { + if (!buildConfig.isVersioned()) { return undefined; } - const versionsDirectory = this.buildDirectory.join(buildConfig.versionsPath ?? 'versioned_docs'); + const versionsDirectory = this.buildDirectory.join(buildConfig.versionsPath()); if (!await this.fileService.directoryExists(versionsDirectory)) { return undefined; } @@ -112,10 +108,10 @@ export class BuildContext { public async getSelectedVersionedBuildDirectory(versionSelector: (versions: string[]) => Promise): Promise { const buildConfig = await this.getBuildFileContents(); - if (!buildConfig.generateVersionedPortal) { + if (!buildConfig.isVersioned()) { return undefined; } - const versionsDirectory = this.buildDirectory.join(buildConfig.versionsPath ?? 'versioned_docs'); + const versionsDirectory = this.buildDirectory.join(buildConfig.versionsPath()); if (!await this.fileService.directoryExists(versionsDirectory)) { return undefined; } diff --git a/src/types/build/build.ts b/src/types/build/build.ts index 86f18065..4fc55355 100644 --- a/src/types/build/build.ts +++ b/src/types/build/build.ts @@ -1,10 +1,12 @@ import { DirectoryPath } from "../file/directoryPath.js"; +import { UrlPath } from "../file/urlPath.js"; -export interface BuildConfig { +export interface BuildConfigData { generatePortal?: PortalConfig; generateVersionedPortal?: object; versionsPath?: string; apiCopilotConfig?: CopilotConfig; + recipes?: RecipesConfig; [key: string]: unknown; } @@ -51,9 +53,170 @@ export interface CopilotConfig { welcomeMessage: string; } +export interface RecipesConfig { + workflows?: RecipeWorkflow[]; +} + +export interface RecipeWorkflow { + name: string; + permalink: string; + functionName: string; + scriptPath: string; +} + export function getLanguagesConfig(selectedLanguages: string[]) { return selectedLanguages.reduce((config, lang) => { config[lang] = {}; return config; }, {} as { [key: string]: object }); } + +// Maps the CLI's friendly language identifiers (used as languageConfig keys) to +// codegen's SupportedTemplates ids, which is what portalSettings.languageSettings +// must be keyed by (codegen resolves these via SdkLanguage.FromSupportedTemplate). +// These ids are version-specific in codegen (e.g. php_generic_lib_v2) — keep in sync +// with APIMatic.CodeGen.Common.SdkLanguage. +const CODEGEN_TEMPLATE_ID_BY_LANGUAGE: Readonly> = { + typescript: "ts_generic_lib", + csharp: "cs_net_standard_lib", + java: "java_eclipse_jre_lib", + php: "php_generic_lib_v2", + python: "python_generic_lib", + ruby: "ruby_generic_lib", + go: "go_generic_lib" +}; +// codegen derives initialPlatform from languageConfig (http is always first), so a +// languageSettings entry for http must exist or the portal widget fails to render. +const HTTP_TEMPLATE_ID = "http_curl_v1" as const; + +// Rich wrapper around the parsed APIMATIC-BUILD.json. All build-config reads and +// mutations go through here so callers express intent rather than poking at the +// raw JSON shape. Construct via `BuildConfig.parse`; persist via `BuildContext`. +export class BuildConfig { + constructor(private readonly data: BuildConfigData) {} + + public static parse(json: string): BuildConfig { + return new BuildConfig(JSON.parse(json) as BuildConfigData); + } + + // Used implicitly by JSON.stringify when the config is written back to disk. + public toJSON(): BuildConfigData { + return this.data; + } + + /** Content directory for the portal, relative to the build directory. Defaults to "content". */ + public contentFolder(): string { + return this.data.generatePortal?.contentFolder ?? "content"; + } + + /** True when this build produces a multi-versioned portal. */ + public isVersioned(): boolean { + return this.data.generateVersionedPortal != null; + } + + /** Directory holding the versioned portals, relative to the build directory. Defaults to "versioned_docs". */ + public versionsPath(): string { + return this.data.versionsPath ?? "versioned_docs"; + } + + /** True when API Copilot is already configured for this build. */ + public hasApiCopilot(): boolean { + return this.data.apiCopilotConfig != null; + } + + /** Sets the portal's languageConfig from the selected friendly language ids. */ + public setPortalLanguages(languages: string[]): void { + this.portal().languageConfig = getLanguagesConfig(languages); + } + + /** Sets (or overwrites) the API Copilot configuration. */ + public setApiCopilotConfig(config: CopilotConfig): void { + this.data.apiCopilotConfig = config; + } + + // Enables API Copilot for a locally-served portal: stores the Copilot config, points + // the portal base URL at the local serve URL, and turns on AI editor integrations. + public enableApiCopilotForPortal(key: string, welcomeMessage: string, baseUrl: string): void { + this.data.apiCopilotConfig = { isEnabled: true, key, welcomeMessage }; + this.portal().baseUrl = baseUrl; + this.enableAiIntegrations(); + } + + // Aligns a localhost base URL's port with the actual serve port. Returns the + // before/after URLs when a change was made, or undefined when nothing changed + // (no base URL, non-localhost URL, or the port already matches). + public reconcileLocalhostBaseUrlPort(servePort: number): { previous: string; updated: string } | undefined { + // `portalSettings.baseUrl` is preferred for portal artifacts; otherwise fall back + // to `generatePortal.baseUrl`. Mirrors how codegen resolves the base URL. + const portalSettings = this.data.generatePortal?.portalSettings; + const baseUrl = portalSettings?.baseUrl ?? this.data.generatePortal?.baseUrl; + if (!baseUrl) { + return undefined; + } + + const parsedUrl = UrlPath.create(baseUrl); + if (!parsedUrl?.isLocalhost() || parsedUrl.port() === servePort) { + return undefined; + } + + const updated = parsedUrl.withPort(servePort).toString(); + if (portalSettings?.baseUrl) { + portalSettings.baseUrl = updated; + } else { + this.portal().baseUrl = updated; + } + return { previous: baseUrl, updated }; + } + + // Adds (or replaces, by permalink) a recipe workflow entry. + public addRecipeWorkflow(name: string, functionName: string, scriptPath: string): void { + const recipes = (this.data.recipes ??= {}); + const workflows = (recipes.workflows ??= []); + const permalink = `page:recipes/${functionName}`; + const workflow: RecipeWorkflow = { name, permalink, functionName, scriptPath }; + const existingIndex = workflows.findIndex((w) => w.permalink === permalink); + if (existingIndex !== -1) { + workflows[existingIndex] = workflow; + } else { + workflows.push(workflow); + } + } + + // Enables Cursor/Claude Code/VS Code integrations for the selected SDK languages. + // Supplying languageSettings suppresses codegen's own per-language auto-population, + // so the http entry (no AI integration) the portal needs to render is added too: + // initialPlatform defaults to http, and a missing entry leaves the widget unrendered. + public enableAiIntegrations(): void { + const portalSettings = (this.portal().portalSettings ??= {}); + const languageSettings = (portalSettings.languageSettings ??= {}); + + languageSettings[HTTP_TEMPLATE_ID] ??= {}; + + let firstSdkTemplateId: string | undefined; + for (const language of Object.keys(this.portal().languageConfig)) { + const templateId = CODEGEN_TEMPLATE_ID_BY_LANGUAGE[language]; + if (!templateId) { + continue; + } + firstSdkTemplateId ??= templateId; + languageSettings[templateId] = { + ...languageSettings[templateId], + aiIntegration: { + cursor: { isEnabled: true }, + claudeCode: { isEnabled: true }, + vscode: { isEnabled: true } + } + }; + } + + // Open the portal on the first SDK language (the entry after http) rather than http. + if (firstSdkTemplateId) { + portalSettings.initialPlatform = firstSdkTemplateId; + } + } + + // Portal-config accessor for mutations. Assumes a single (non-versioned) portal build. + private portal(): PortalConfig { + return this.data.generatePortal!; + } +} From 9400888629373792264e1b83a1aaa6b03ce90a1b Mon Sep 17 00:00:00 2001 From: Sohail Date: Tue, 23 Jun 2026 14:43:43 +0500 Subject: [PATCH 21/44] fix(portal): only enable AI integrations when a copilot key exists AI editor integrations build MCP install links from the API Copilot key, so they are meaningless without one. Drop the no-key branch that enabled them anyway; enableAiIntegrations is now private (only invoked via enableApiCopilotForPortal). Co-Authored-By: Claude Opus 4.8 --- src/actions/portal/quickstart.ts | 2 -- src/types/build/build.ts | 2 +- 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/src/actions/portal/quickstart.ts b/src/actions/portal/quickstart.ts index a51d1769..92a502f7 100644 --- a/src/actions/portal/quickstart.ts +++ b/src/actions/portal/quickstart.ts @@ -193,8 +193,6 @@ export class PortalQuickstartAction { buildConfig.setPortalLanguages(languages); if (copilotKeyResult.key) { buildConfig.enableApiCopilotForPortal(copilotKeyResult.key, DEFAULT_COPILOT_WELCOME_MESSAGE, defaultBaseUrl); - } else { - buildConfig.enableAiIntegrations(); } await buildContext.updateBuildFileContents(buildConfig); diff --git a/src/types/build/build.ts b/src/types/build/build.ts index 4fc55355..16c78a6f 100644 --- a/src/types/build/build.ts +++ b/src/types/build/build.ts @@ -186,7 +186,7 @@ export class BuildConfig { // Supplying languageSettings suppresses codegen's own per-language auto-population, // so the http entry (no AI integration) the portal needs to render is added too: // initialPlatform defaults to http, and a missing entry leaves the widget unrendered. - public enableAiIntegrations(): void { + private enableAiIntegrations(): void { const portalSettings = (this.portal().portalSettings ??= {}); const languageSettings = (portalSettings.languageSettings ??= {}); From 8b41a3cfdaa6bd92218d67b2a86162629c99f06b Mon Sep 17 00:00:00 2001 From: Sohail Date: Tue, 23 Jun 2026 14:54:37 +0500 Subject: [PATCH 22/44] fix(portal): handle a failed server bind gracefully on serve get-port only checks port availability; the port can be taken between that check and listen(), and the gap widened now that the port is resolved before generation. A failed bind emitted an unhandled "error" event and crashed the CLI with a stack trace. Wait for the server's "listening"/"error" event and, on error, close the live-reload server and return a clean failure ("port may have been taken, try again"). Also flip the negated condition in BuildConfig.addRecipeWorkflow per SonarCloud. Co-Authored-By: Claude Opus 4.8 --- src/actions/portal/serve.ts | 28 ++++++++++++++++++++++++++++ src/prompts/portal/serve.ts | 7 +++++++ src/types/build/build.ts | 6 +++--- 3 files changed, 38 insertions(+), 3 deletions(-) diff --git a/src/actions/portal/serve.ts b/src/actions/portal/serve.ts index 79cfaf17..79ded4d7 100644 --- a/src/actions/portal/serve.ts +++ b/src/actions/portal/serve.ts @@ -1,3 +1,4 @@ +import { Server } from "http"; import { createServer as createLiveReloadServer } from "livereload"; import connectLiveReload from "connect-livereload"; import express, { Express } from "express"; @@ -59,6 +60,16 @@ export class PortalServeAction { .use(express.static(portalDirectory.toString(), { extensions: ["html"] })) .listen(servePort); + // get-port only checks availability; the port can be taken between that check and + // now, so handle a failed bind gracefully instead of letting an unhandled "error" + // event crash the CLI. + const listenError = await this.waitForServerListening(server); + if (listenError) { + liveReloadServer.close(); + this.prompts.serverStartFailed(servePort); + return ActionResult.failed(); + } + const portalUrl = new UrlPath(`http://localhost:${servePort}`); this.prompts.portalServed(portalUrl); if (openInBrowser) { @@ -148,6 +159,23 @@ export class PortalServeAction { this.prompts.baseUrlPortUpdated(change.previous, change.updated); } + // Resolves once the server is bound, or with the bind error (e.g. EADDRINUSE) so a + // failed listen is reported cleanly instead of crashing via an unhandled "error" event. + private waitForServerListening(server: Server): Promise { + return new Promise((resolve) => { + const onListening = () => { + server.removeListener("error", onError); + resolve(undefined); + }; + const onError = (error: Error) => { + server.removeListener("listening", onListening); + resolve(error); + }; + server.once("listening", onListening); + server.once("error", onError); + }); + } + // This clears the standard input to allow interrupts like CTRL+C to work properly. private clearStandardInput() { if (process.platform !== "darwin" && process.stdin.isTTY) { diff --git a/src/prompts/portal/serve.ts b/src/prompts/portal/serve.ts index c9d4a6a2..c9624a78 100644 --- a/src/prompts/portal/serve.ts +++ b/src/prompts/portal/serve.ts @@ -13,6 +13,13 @@ export class PortalServePrompts { log.step(message); } + public serverStartFailed(port: number) { + const message = + `Could not start the portal server on port ${f.var(port.toString())}; ` + + `it may have just been taken by another process. Please try again.`; + log.error(message); + } + public baseUrlPortUpdated(previousUrl: string, updatedUrl: string) { const message = `The configured base URL ${f.var(previousUrl)} did not match the serve port. ` + diff --git a/src/types/build/build.ts b/src/types/build/build.ts index 16c78a6f..c9193def 100644 --- a/src/types/build/build.ts +++ b/src/types/build/build.ts @@ -175,10 +175,10 @@ export class BuildConfig { const permalink = `page:recipes/${functionName}`; const workflow: RecipeWorkflow = { name, permalink, functionName, scriptPath }; const existingIndex = workflows.findIndex((w) => w.permalink === permalink); - if (existingIndex !== -1) { - workflows[existingIndex] = workflow; - } else { + if (existingIndex === -1) { workflows.push(workflow); + } else { + workflows[existingIndex] = workflow; } } From fb1ec9df01aacbb85ebba588d252ae7492b8c1e3 Mon Sep 17 00:00:00 2001 From: Sohail Date: Tue, 23 Jun 2026 15:06:52 +0500 Subject: [PATCH 23/44] refactor(build): make BuildConfig (and PortalSettings/LanguageSetting) immutable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the in-place mutators with copy-on-write transforms so the build config is never mutated after creation. Every change now returns a NEW instance: - BuildConfig: withPortalLanguages, withApiCopilotConfig, withApiCopilotForPortal, reconcileLocalhostBaseUrlPort (returns { config, previous, updated }), withRecipeWorkflow. - New immutable value classes PortalSettings and LanguageSetting (private constructors, static `from` factories, with* transforms) own the portalSettings/languageSettings and AI-integration construction. Each transform deep-clones the config (JSON round-trip — the data is plain JSON) and builds the new value, leaving the receiver untouched. Callers (quickstart, copilot, serve, recipe-generator) thread the returned config into updateBuildFileContents. Interfaces renamed to *Data (BuildConfigData/PortalSettingsData/LanguageSettingData). Co-Authored-By: Claude Opus 4.8 --- src/actions/portal/copilot.ts | 4 +- src/actions/portal/quickstart.ts | 9 +- src/actions/portal/serve.ts | 2 +- .../portal/recipe/recipe-generator.ts | 4 +- src/types/build/build.ts | 201 ++++++++++++------ 5 files changed, 142 insertions(+), 78 deletions(-) diff --git a/src/actions/portal/copilot.ts b/src/actions/portal/copilot.ts index 93ea69d5..eeac8629 100644 --- a/src/actions/portal/copilot.ts +++ b/src/actions/portal/copilot.ts @@ -74,13 +74,13 @@ export class CopilotAction { const welcomeMessage = await this.prepareWelcomeMessage(); - buildJson.setApiCopilotConfig({ + const updatedBuild = buildJson.withApiCopilotConfig({ isEnabled: enable, key: apiCopilotKeyResult.value, welcomeMessage: welcomeMessage }); - await buildContext.updateBuildFileContents(buildJson); + await buildContext.updateBuildFileContents(updatedBuild); this.prompts.copilotConfigured(enable, apiCopilotKeyResult.value); diff --git a/src/actions/portal/quickstart.ts b/src/actions/portal/quickstart.ts index 92a502f7..f1baf66a 100644 --- a/src/actions/portal/quickstart.ts +++ b/src/actions/portal/quickstart.ts @@ -189,11 +189,10 @@ export class PortalQuickstartAction { // Update the build file in its final location via BuildContext, // mirroring exactly how CopilotAction reads and writes the build file const buildContext = new BuildContext(sourceDirectory); - const buildConfig = await buildContext.getBuildFileContents(); - buildConfig.setPortalLanguages(languages); - if (copilotKeyResult.key) { - buildConfig.enableApiCopilotForPortal(copilotKeyResult.key, DEFAULT_COPILOT_WELCOME_MESSAGE, defaultBaseUrl); - } + const baseConfig = (await buildContext.getBuildFileContents()).withPortalLanguages(languages); + const buildConfig = copilotKeyResult.key + ? baseConfig.withApiCopilotForPortal(copilotKeyResult.key, DEFAULT_COPILOT_WELCOME_MESSAGE, defaultBaseUrl) + : baseConfig; await buildContext.updateBuildFileContents(buildConfig); const specDirectory = sourceDirectory.join('spec'); diff --git a/src/actions/portal/serve.ts b/src/actions/portal/serve.ts index 79ded4d7..46a5a2a4 100644 --- a/src/actions/portal/serve.ts +++ b/src/actions/portal/serve.ts @@ -155,7 +155,7 @@ export class PortalServeAction { return; } - await buildContext.updateBuildFileContents(buildConfig); + await buildContext.updateBuildFileContents(change.config); this.prompts.baseUrlPortUpdated(change.previous, change.updated); } diff --git a/src/application/portal/recipe/recipe-generator.ts b/src/application/portal/recipe/recipe-generator.ts index 0aa74b70..f1da4488 100644 --- a/src/application/portal/recipe/recipe-generator.ts +++ b/src/application/portal/recipe/recipe-generator.ts @@ -77,12 +77,12 @@ export class PortalRecipeGenerator { recipeScriptFileName: FileName ): Promise { const buildConfig = await buildContext.getBuildFileContents(); - buildConfig.addRecipeWorkflow( + const updatedBuild = buildConfig.withRecipeWorkflow( recipeName, this.toPascalCase(recipeName), `./static/scripts/recipes/${recipeScriptFileName}` ); - await buildContext.updateBuildFileContents(buildConfig); + await buildContext.updateBuildFileContents(updatedBuild); } private async createMarkdownFile(recipeMarkdownFileName: FileName, contentFolder: DirectoryPath): Promise { diff --git a/src/types/build/build.ts b/src/types/build/build.ts index c9193def..fd86dd5a 100644 --- a/src/types/build/build.ts +++ b/src/types/build/build.ts @@ -16,22 +16,22 @@ export interface PortalConfig { /** URL where the portal will be hosted. Mirrors `generatePortal.baseUrl` in codegen. */ baseUrl?: string; /** Portal UI settings. Mirrors `generatePortal.portalSettings` in codegen. */ - portalSettings?: PortalSettings; + portalSettings?: PortalSettingsData; apiSpecPath?: DirectoryPath; [key: string]: unknown; } -export interface PortalSettings { +export interface PortalSettingsData { /** Base URL for the API calls made by the portal. Preferred over `generatePortal.baseUrl` for portal artifacts. */ baseUrl?: string; /** Language/template id the portal opens on first load. */ initialPlatform?: string; /** Per-language portal settings, keyed by language/template id. */ - languageSettings?: { [language: string]: LanguageSetting }; + languageSettings?: { [language: string]: LanguageSettingData }; [key: string]: unknown; } -export interface LanguageSetting { +export interface LanguageSettingData { aiIntegration?: AiIntegration; [key: string]: unknown; } @@ -89,11 +89,93 @@ const CODEGEN_TEMPLATE_ID_BY_LANGUAGE: Readonly> = { // languageSettings entry for http must exist or the portal widget fails to render. const HTTP_TEMPLATE_ID = "http_curl_v1" as const; -// Rich wrapper around the parsed APIMATIC-BUILD.json. All build-config reads and -// mutations go through here so callers express intent rather than poking at the -// raw JSON shape. Construct via `BuildConfig.parse`; persist via `BuildContext`. +// Deep clone used for copy-on-write transforms. The config is plain JSON, so a JSON +// round-trip is an exact, dependency-free copy. +function clone(value: T): T { + return JSON.parse(JSON.stringify(value)) as T; +} + +// Immutable per-language portal setting. Build new values via the static factory and +// the with* transforms; the wrapped data is never mutated after construction. +export class LanguageSetting { + private constructor(private readonly data: LanguageSettingData) {} + + public static from(data: LanguageSettingData = {}): LanguageSetting { + return new LanguageSetting(clone(data)); + } + + /** Returns a copy with all AI editor integrations (Cursor/Claude Code/VS Code) enabled. */ + public withAiIntegrationsEnabled(): LanguageSetting { + const data = clone(this.data); + data.aiIntegration = { + cursor: { isEnabled: true }, + claudeCode: { isEnabled: true }, + vscode: { isEnabled: true } + }; + return new LanguageSetting(data); + } + + public toJSON(): LanguageSettingData { + return this.data; + } +} + +// Immutable portal UI settings. Build new values via the static factory and the with* +// transforms; the wrapped data is never mutated after construction. +export class PortalSettings { + private constructor(private readonly data: PortalSettingsData) {} + + public static from(data: PortalSettingsData = {}): PortalSettings { + return new PortalSettings(clone(data)); + } + + public baseUrl(): string | undefined { + return this.data.baseUrl; + } + + /** Returns a copy with the API-call base URL set. */ + public withBaseUrl(baseUrl: string): PortalSettings { + const data = clone(this.data); + data.baseUrl = baseUrl; + return new PortalSettings(data); + } + + // Returns a copy with AI editor integrations enabled for the given SDK languages + // (friendly ids). Always adds the http entry the portal needs to render and opens the + // portal on the first SDK language. Languages without a codegen template id are skipped. + public withAiIntegrations(languages: string[]): PortalSettings { + const data = clone(this.data); + const languageSettings: { [language: string]: LanguageSettingData } = { ...data.languageSettings }; + languageSettings[HTTP_TEMPLATE_ID] = languageSettings[HTTP_TEMPLATE_ID] ?? {}; + + let firstSdkTemplateId: string | undefined; + for (const language of languages) { + const templateId = CODEGEN_TEMPLATE_ID_BY_LANGUAGE[language]; + if (!templateId) { + continue; + } + firstSdkTemplateId ??= templateId; + languageSettings[templateId] = LanguageSetting.from(languageSettings[templateId]).withAiIntegrationsEnabled().toJSON(); + } + + data.languageSettings = languageSettings; + if (firstSdkTemplateId) { + data.initialPlatform = firstSdkTemplateId; + } + return new PortalSettings(data); + } + + public toJSON(): PortalSettingsData { + return this.data; + } +} + +// Immutable wrapper around the parsed APIMATIC-BUILD.json. All build-config reads go +// through accessor methods and all changes go through with*/reconcile methods that +// return a NEW BuildConfig — the wrapped data is never mutated after construction. +// Construct via `BuildConfig.parse`; persist via `BuildContext`. export class BuildConfig { - constructor(private readonly data: BuildConfigData) {} + private constructor(private readonly data: BuildConfigData) {} public static parse(json: string): BuildConfig { return new BuildConfig(JSON.parse(json) as BuildConfigData); @@ -124,28 +206,40 @@ export class BuildConfig { return this.data.apiCopilotConfig != null; } - /** Sets the portal's languageConfig from the selected friendly language ids. */ - public setPortalLanguages(languages: string[]): void { - this.portal().languageConfig = getLanguagesConfig(languages); + /** Returns a copy with the portal's languageConfig set from the selected friendly language ids. */ + public withPortalLanguages(languages: string[]): BuildConfig { + const data = clone(this.data); + portalConfigOf(data).languageConfig = getLanguagesConfig(languages); + return new BuildConfig(data); } - /** Sets (or overwrites) the API Copilot configuration. */ - public setApiCopilotConfig(config: CopilotConfig): void { - this.data.apiCopilotConfig = config; + /** Returns a copy with the API Copilot configuration set (or overwritten). */ + public withApiCopilotConfig(config: CopilotConfig): BuildConfig { + const data = clone(this.data); + data.apiCopilotConfig = { ...config }; + return new BuildConfig(data); } - // Enables API Copilot for a locally-served portal: stores the Copilot config, points - // the portal base URL at the local serve URL, and turns on AI editor integrations. - public enableApiCopilotForPortal(key: string, welcomeMessage: string, baseUrl: string): void { - this.data.apiCopilotConfig = { isEnabled: true, key, welcomeMessage }; - this.portal().baseUrl = baseUrl; - this.enableAiIntegrations(); + // Returns a copy with API Copilot enabled for a locally-served portal: stores the + // Copilot config, points the portal base URL at the local serve URL, and turns on AI + // editor integrations for the configured SDK languages. + public withApiCopilotForPortal(key: string, welcomeMessage: string, baseUrl: string): BuildConfig { + const data = clone(this.data); + const portal = portalConfigOf(data); + data.apiCopilotConfig = { isEnabled: true, key, welcomeMessage }; + portal.baseUrl = baseUrl; + portal.portalSettings = PortalSettings.from(portal.portalSettings) + .withAiIntegrations(Object.keys(portal.languageConfig)) + .toJSON(); + return new BuildConfig(data); } - // Aligns a localhost base URL's port with the actual serve port. Returns the - // before/after URLs when a change was made, or undefined when nothing changed - // (no base URL, non-localhost URL, or the port already matches). - public reconcileLocalhostBaseUrlPort(servePort: number): { previous: string; updated: string } | undefined { + // Aligns a localhost base URL's port with the actual serve port. Returns the new + // config plus the before/after URLs when a change was made, or undefined when nothing + // changed (no base URL, non-localhost URL, or the port already matches). + public reconcileLocalhostBaseUrlPort( + servePort: number + ): { config: BuildConfig; previous: string; updated: string } | undefined { // `portalSettings.baseUrl` is preferred for portal artifacts; otherwise fall back // to `generatePortal.baseUrl`. Mirrors how codegen resolves the base URL. const portalSettings = this.data.generatePortal?.portalSettings; @@ -160,17 +254,20 @@ export class BuildConfig { } const updated = parsedUrl.withPort(servePort).toString(); - if (portalSettings?.baseUrl) { - portalSettings.baseUrl = updated; + const data = clone(this.data); + const portal = portalConfigOf(data); + if (portal.portalSettings?.baseUrl) { + portal.portalSettings = PortalSettings.from(portal.portalSettings).withBaseUrl(updated).toJSON(); } else { - this.portal().baseUrl = updated; + portal.baseUrl = updated; } - return { previous: baseUrl, updated }; + return { config: new BuildConfig(data), previous: baseUrl, updated }; } - // Adds (or replaces, by permalink) a recipe workflow entry. - public addRecipeWorkflow(name: string, functionName: string, scriptPath: string): void { - const recipes = (this.data.recipes ??= {}); + // Returns a copy with a recipe workflow added (or replaced, matched by permalink). + public withRecipeWorkflow(name: string, functionName: string, scriptPath: string): BuildConfig { + const data = clone(this.data); + const recipes = (data.recipes ??= {}); const workflows = (recipes.workflows ??= []); const permalink = `page:recipes/${functionName}`; const workflow: RecipeWorkflow = { name, permalink, functionName, scriptPath }; @@ -180,43 +277,11 @@ export class BuildConfig { } else { workflows[existingIndex] = workflow; } + return new BuildConfig(data); } +} - // Enables Cursor/Claude Code/VS Code integrations for the selected SDK languages. - // Supplying languageSettings suppresses codegen's own per-language auto-population, - // so the http entry (no AI integration) the portal needs to render is added too: - // initialPlatform defaults to http, and a missing entry leaves the widget unrendered. - private enableAiIntegrations(): void { - const portalSettings = (this.portal().portalSettings ??= {}); - const languageSettings = (portalSettings.languageSettings ??= {}); - - languageSettings[HTTP_TEMPLATE_ID] ??= {}; - - let firstSdkTemplateId: string | undefined; - for (const language of Object.keys(this.portal().languageConfig)) { - const templateId = CODEGEN_TEMPLATE_ID_BY_LANGUAGE[language]; - if (!templateId) { - continue; - } - firstSdkTemplateId ??= templateId; - languageSettings[templateId] = { - ...languageSettings[templateId], - aiIntegration: { - cursor: { isEnabled: true }, - claudeCode: { isEnabled: true }, - vscode: { isEnabled: true } - } - }; - } - - // Open the portal on the first SDK language (the entry after http) rather than http. - if (firstSdkTemplateId) { - portalSettings.initialPlatform = firstSdkTemplateId; - } - } - - // Portal-config accessor for mutations. Assumes a single (non-versioned) portal build. - private portal(): PortalConfig { - return this.data.generatePortal!; - } +// Portal-config accessor for transforms. Assumes a single (non-versioned) portal build. +function portalConfigOf(data: BuildConfigData): PortalConfig { + return data.generatePortal!; } From 43866cec4b2dd98d127a532ab5d25c71de0bd34b Mon Sep 17 00:00:00 2001 From: Sohail Date: Tue, 23 Jun 2026 15:46:43 +0500 Subject: [PATCH 24/44] rename --- src/actions/portal/copilot.ts | 8 ++++---- src/application/portal/recipe/recipe-generator.ts | 4 ++-- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/actions/portal/copilot.ts b/src/actions/portal/copilot.ts index eeac8629..81cc6800 100644 --- a/src/actions/portal/copilot.ts +++ b/src/actions/portal/copilot.ts @@ -50,9 +50,9 @@ export class CopilotAction { return ActionResult.failed(); } - const buildJson = await buildContext.getBuildFileContents(); + const buildConfig = await buildContext.getBuildFileContents(); - if (!force && buildJson.hasApiCopilot() && !(await this.prompts.confirmOverwrite())) { + if (!force && buildConfig.hasApiCopilot() && !(await this.prompts.confirmOverwrite())) { this.prompts.cancelled(); return ActionResult.cancelled(); } @@ -74,13 +74,13 @@ export class CopilotAction { const welcomeMessage = await this.prepareWelcomeMessage(); - const updatedBuild = buildJson.withApiCopilotConfig({ + const updatedBuildConfig = buildConfig.withApiCopilotConfig({ isEnabled: enable, key: apiCopilotKeyResult.value, welcomeMessage: welcomeMessage }); - await buildContext.updateBuildFileContents(updatedBuild); + await buildContext.updateBuildFileContents(updatedBuildConfig); this.prompts.copilotConfigured(enable, apiCopilotKeyResult.value); diff --git a/src/application/portal/recipe/recipe-generator.ts b/src/application/portal/recipe/recipe-generator.ts index f1da4488..d7fe8b3e 100644 --- a/src/application/portal/recipe/recipe-generator.ts +++ b/src/application/portal/recipe/recipe-generator.ts @@ -77,12 +77,12 @@ export class PortalRecipeGenerator { recipeScriptFileName: FileName ): Promise { const buildConfig = await buildContext.getBuildFileContents(); - const updatedBuild = buildConfig.withRecipeWorkflow( + const updatedBuildConfig = buildConfig.withRecipeWorkflow( recipeName, this.toPascalCase(recipeName), `./static/scripts/recipes/${recipeScriptFileName}` ); - await buildContext.updateBuildFileContents(updatedBuild); + await buildContext.updateBuildFileContents(updatedBuildConfig); } private async createMarkdownFile(recipeMarkdownFileName: FileName, contentFolder: DirectoryPath): Promise { From 8d7cbcba87809997ce381ba2fd4e075b72e8f2e8 Mon Sep 17 00:00:00 2001 From: Sohail Date: Tue, 23 Jun 2026 16:17:33 +0500 Subject: [PATCH 25/44] refactor(portal): inline copilot-key resolution into quickstart; use node:http - Inline the former getCopilotKey() helper into execute. A failed account lookup still continues silently without Copilot; no key / single key / multi-key behave as before (multi-key cancel aborts quickstart). The build file is still always written so the user's selected languages persist (pre-existing behavior), with the Copilot block applied only when a key is resolved. - Import Server from "node:http" (SonarCloud node-protocol preference). Co-Authored-By: Claude Opus 4.8 --- src/actions/portal/quickstart.ts | 59 ++++++++++++-------------------- src/actions/portal/serve.ts | 2 +- 2 files changed, 23 insertions(+), 38 deletions(-) diff --git a/src/actions/portal/quickstart.ts b/src/actions/portal/quickstart.ts index f1baf66a..16f5df70 100644 --- a/src/actions/portal/quickstart.ts +++ b/src/actions/portal/quickstart.ts @@ -160,10 +160,26 @@ export class PortalQuickstartAction { break; } - const copilotKeyResult = await this.getCopilotKey(); - if (copilotKeyResult.cancelled) { - this.prompts.noCopilotKeySelected(); - return ActionResult.cancelled(); + // Resolve the API Copilot key to enable, if any, before setting up the source + // directory so the user decides on Copilot up front. Copilot is opt-in: when the + // account has no key (or the lookup fails) it is skipped silently; cancelling the + // multi-key selection aborts quickstart. + let copilotKey: string | undefined; + const accountInfo = await this.apiService.getAccountInfo(this.configDir, this.commandMetadata.shell, null); + if (accountInfo.isOk()) { + const copilotKeys = accountInfo.value.ApiCopilotKeys ?? []; + if (copilotKeys.length === 1) { + copilotKey = copilotKeys[0]; + } else if (copilotKeys.length > 1) { + copilotKey = await this.prompts.selectCopilotKey(copilotKeys); + if (!copilotKey) { + this.prompts.noCopilotKeySelected(); + return ActionResult.cancelled(); + } + } + if (copilotKey) { + this.prompts.copilotEnabled(copilotKey); + } } const masterBuildFile = await this.prompts.downloadBuildDirectory( @@ -190,8 +206,8 @@ export class PortalQuickstartAction { // mirroring exactly how CopilotAction reads and writes the build file const buildContext = new BuildContext(sourceDirectory); const baseConfig = (await buildContext.getBuildFileContents()).withPortalLanguages(languages); - const buildConfig = copilotKeyResult.key - ? baseConfig.withApiCopilotForPortal(copilotKeyResult.key, DEFAULT_COPILOT_WELCOME_MESSAGE, defaultBaseUrl) + const buildConfig = copilotKey + ? baseConfig.withApiCopilotForPortal(copilotKey, DEFAULT_COPILOT_WELCOME_MESSAGE, defaultBaseUrl) : baseConfig; await buildContext.updateBuildFileContents(buildConfig); @@ -215,35 +231,4 @@ export class PortalQuickstartAction { return ActionResult.success(); }); }; - - // Resolves the API Copilot key to enable, if any, BEFORE the source directory is set - // up so the user decides on Copilot up front. Copilot is opt-in based on account - // access: when the user has no Copilot key (or the lookup fails) it is skipped - // silently ({ cancelled: false } with no key). When multiple keys exist and the user - // cancels selection, returns { cancelled: true } so quickstart aborts. - private async getCopilotKey(): Promise<{ cancelled: boolean; key?: string }> { - const accountInfo = await this.apiService.getAccountInfo(this.configDir, this.commandMetadata.shell, null); - if (accountInfo.isErr()) { - return { cancelled: false }; - } - - const copilotKeys = accountInfo.value.ApiCopilotKeys ?? []; - if (copilotKeys.length === 0) { - return { cancelled: false }; - } - - let copilotKey: string; - if (copilotKeys.length === 1) { - copilotKey = copilotKeys[0]; - } else { - const selectedKey = await this.prompts.selectCopilotKey(copilotKeys); - if (!selectedKey) { - return { cancelled: true }; - } - copilotKey = selectedKey; - } - - this.prompts.copilotEnabled(copilotKey); - return { cancelled: false, key: copilotKey }; - } } \ No newline at end of file diff --git a/src/actions/portal/serve.ts b/src/actions/portal/serve.ts index 46a5a2a4..92173b34 100644 --- a/src/actions/portal/serve.ts +++ b/src/actions/portal/serve.ts @@ -1,4 +1,4 @@ -import { Server } from "http"; +import { Server } from "node:http"; import { createServer as createLiveReloadServer } from "livereload"; import connectLiveReload from "connect-livereload"; import express, { Express } from "express"; From 08e64564f03a4c40672b3cf3cccf21404aadf1ee Mon Sep 17 00:00:00 2001 From: Sohail Date: Tue, 23 Jun 2026 16:33:45 +0500 Subject: [PATCH 26/44] refactor(portal): reconcile against the full serve URL and return neverthrow Result - reconcileLocalhostBaseUrl now takes the complete serve URL (UrlPath) instead of a bare port and replaces the whole configured localhost base URL with it when they differ. Returns Result instead of an ad-hoc `{...} | undefined`. - waitForServerListening returns Result instead of `Error | undefined`, applying the same result type to the other changed spot. - UrlPath: drop now-unused port()/withPort(); add isEqual() (matches DirectoryPath/ ProfileId). serve builds the serve URL once and reuses it for reconcile + display. Co-Authored-By: Claude Opus 4.8 --- src/actions/portal/serve.ts | 37 +++++++++++++++++++------------------ src/types/build/build.ts | 30 +++++++++++++++++++----------- src/types/file/urlPath.ts | 20 +++----------------- 3 files changed, 41 insertions(+), 46 deletions(-) diff --git a/src/actions/portal/serve.ts b/src/actions/portal/serve.ts index 92173b34..02717e16 100644 --- a/src/actions/portal/serve.ts +++ b/src/actions/portal/serve.ts @@ -1,4 +1,5 @@ import { Server } from "node:http"; +import { err, ok, Result } from "neverthrow"; import { createServer as createLiveReloadServer } from "livereload"; import connectLiveReload from "connect-livereload"; import express, { Express } from "express"; @@ -41,11 +42,12 @@ export class PortalServeAction { if (servePort != port) { this.prompts.usingFallbackPort(port, servePort); } + const serveUrl = new UrlPath(`http://localhost:${servePort}`); - // Align the configured localhost base URL with the actual serve port BEFORE + // Align the configured localhost base URL with the actual serve URL BEFORE // generation bakes it into the portal artifacts; otherwise the portal would load // its content from the wrong port and fail to render. - await this.reconcileBaseUrlPort(buildDirectory, servePort); + await this.reconcileBaseUrl(buildDirectory, serveUrl); const generatePortalAction = new GenerateAction(this.configDir, this.commandMetadata, this.authKey); const result = await generatePortalAction.execute(buildDirectory, portalDirectory, true, false); @@ -63,17 +65,16 @@ export class PortalServeAction { // get-port only checks availability; the port can be taken between that check and // now, so handle a failed bind gracefully instead of letting an unhandled "error" // event crash the CLI. - const listenError = await this.waitForServerListening(server); - if (listenError) { + const listenResult = await this.waitForServerListening(server); + if (listenResult.isErr()) { liveReloadServer.close(); this.prompts.serverStartFailed(servePort); return ActionResult.failed(); } - const portalUrl = new UrlPath(`http://localhost:${servePort}`); - this.prompts.portalServed(portalUrl); + this.prompts.portalServed(serveUrl); if (openInBrowser) { - await this.launcherService.openUrlInBrowser(portalUrl); + await this.launcherService.openUrlInBrowser(serveUrl); } this.prompts.promptForExit(); @@ -139,9 +140,9 @@ export class PortalServeAction { return ActionResult.success(); } - // Aligns the portal's configured localhost base URL with the actual serve port and + // Aligns the portal's configured localhost base URL with the actual serve URL and // persists the change, informing the user. Delegates the config logic to BuildConfig. - private async reconcileBaseUrlPort(buildDirectory: DirectoryPath, servePort: number): Promise { + private async reconcileBaseUrl(buildDirectory: DirectoryPath, serveUrl: UrlPath): Promise { const buildContext = new BuildContext(buildDirectory); let buildConfig; try { @@ -150,26 +151,26 @@ export class PortalServeAction { return; } - const change = buildConfig.reconcileLocalhostBaseUrlPort(servePort); - if (!change) { + const reconciliation = buildConfig.reconcileLocalhostBaseUrl(serveUrl); + if (reconciliation.isErr()) { return; } - await buildContext.updateBuildFileContents(change.config); - this.prompts.baseUrlPortUpdated(change.previous, change.updated); + await buildContext.updateBuildFileContents(reconciliation.value.config); + this.prompts.baseUrlPortUpdated(reconciliation.value.previous, reconciliation.value.updated); } - // Resolves once the server is bound, or with the bind error (e.g. EADDRINUSE) so a - // failed listen is reported cleanly instead of crashing via an unhandled "error" event. - private waitForServerListening(server: Server): Promise { + // Resolves ok once the server is bound, or err with the bind error (e.g. EADDRINUSE) + // so a failed listen is reported cleanly instead of crashing via an unhandled "error". + private waitForServerListening(server: Server): Promise> { return new Promise((resolve) => { const onListening = () => { server.removeListener("error", onError); - resolve(undefined); + resolve(ok(undefined)); }; const onError = (error: Error) => { server.removeListener("listening", onListening); - resolve(error); + resolve(err(error)); }; server.once("listening", onListening); server.once("error", onError); diff --git a/src/types/build/build.ts b/src/types/build/build.ts index fd86dd5a..475d9536 100644 --- a/src/types/build/build.ts +++ b/src/types/build/build.ts @@ -1,3 +1,4 @@ +import { err, ok, Result } from "neverthrow"; import { DirectoryPath } from "../file/directoryPath.js"; import { UrlPath } from "../file/urlPath.js"; @@ -64,6 +65,14 @@ export interface RecipeWorkflow { scriptPath: string; } +// Outcome of a successful base-URL reconciliation: the updated config plus the +// before/after URLs (for user messaging). +export interface BaseUrlReconciliation { + config: BuildConfig; + previous: string; + updated: string; +} + export function getLanguagesConfig(selectedLanguages: string[]) { return selectedLanguages.reduce((config, lang) => { config[lang] = {}; @@ -234,26 +243,25 @@ export class BuildConfig { return new BuildConfig(data); } - // Aligns a localhost base URL's port with the actual serve port. Returns the new - // config plus the before/after URLs when a change was made, or undefined when nothing - // changed (no base URL, non-localhost URL, or the port already matches). - public reconcileLocalhostBaseUrlPort( - servePort: number - ): { config: BuildConfig; previous: string; updated: string } | undefined { + // Aligns a configured localhost base URL with where the portal is actually served: + // when the effective base URL is localhost and differs from `serveUrl`, it is replaced + // wholesale with `serveUrl`. Returns ok(reconciliation) when a change was made, or + // err when nothing changed (no base URL, non-localhost URL, or already aligned). + public reconcileLocalhostBaseUrl(serveUrl: UrlPath): Result { // `portalSettings.baseUrl` is preferred for portal artifacts; otherwise fall back // to `generatePortal.baseUrl`. Mirrors how codegen resolves the base URL. const portalSettings = this.data.generatePortal?.portalSettings; const baseUrl = portalSettings?.baseUrl ?? this.data.generatePortal?.baseUrl; if (!baseUrl) { - return undefined; + return err("unchanged"); } const parsedUrl = UrlPath.create(baseUrl); - if (!parsedUrl?.isLocalhost() || parsedUrl.port() === servePort) { - return undefined; + if (!parsedUrl?.isLocalhost() || parsedUrl.isEqual(serveUrl)) { + return err("unchanged"); } - const updated = parsedUrl.withPort(servePort).toString(); + const updated = serveUrl.toString(); const data = clone(this.data); const portal = portalConfigOf(data); if (portal.portalSettings?.baseUrl) { @@ -261,7 +269,7 @@ export class BuildConfig { } else { portal.baseUrl = updated; } - return { config: new BuildConfig(data), previous: baseUrl, updated }; + return ok({ config: new BuildConfig(data), previous: baseUrl, updated }); } // Returns a copy with a recipe workflow added (or replaced, matched by permalink). diff --git a/src/types/file/urlPath.ts b/src/types/file/urlPath.ts index eea96c5f..ed5c0b30 100644 --- a/src/types/file/urlPath.ts +++ b/src/types/file/urlPath.ts @@ -29,22 +29,8 @@ export class UrlPath { return hostname === "localhost" || hostname === "127.0.0.1"; } - /** The URL's port. Falls back to the protocol default (443 for https, 80 otherwise) when none is specified. */ - public port(): number { - const parsed = new URL(this.url); - if (parsed.port) { - return Number(parsed.port); - } - return parsed.protocol === "https:" ? 443 : 80; - } - - /** Returns a copy of this URL with its port replaced, preserving the rest of the URL. */ - public withPort(port: number): UrlPath { - const parsed = new URL(this.url); - parsed.port = String(port); - const updated = parsed.toString(); - // URL.toString() appends a single trailing slash to origin-only URLs; keep the original's form. - const trimmed = updated.endsWith("/") ? updated.slice(0, -1) : updated; - return new UrlPath(this.url.endsWith("/") ? updated : trimmed); + /** Structural equality against another URL. */ + public isEqual(other: UrlPath): boolean { + return this.url === other.url; } } From 4ec32edfe0e516cd577384debacde5e2adc6ec51 Mon Sep 17 00:00:00 2001 From: Sohail Date: Tue, 23 Jun 2026 16:44:46 +0500 Subject: [PATCH 27/44] fix(portal): surface a clear error when the build file can't be read on serve Since the base-URL update reads the build file BEFORE GenerateAction validates it, a missing/malformed APIMATIC-BUILD.json would otherwise crash serve (or, for invalid JSON, slip through to a generic server error). updateBaseUrl now shows a clear "build configuration invalid" message and returns false so execute fails cleanly. Also rename reconcile -> update across the base-URL flow (updateBuildConfigBaseUrl, updateBaseUrl, BaseUrlChange) for simpler naming. Co-Authored-By: Claude Opus 4.8 --- src/actions/portal/serve.ts | 25 +++++++++++++++---------- src/prompts/portal/serve.ts | 11 +++++++++-- src/types/build/build.ts | 18 +++++++++--------- 3 files changed, 33 insertions(+), 21 deletions(-) diff --git a/src/actions/portal/serve.ts b/src/actions/portal/serve.ts index 02717e16..62ed1f8a 100644 --- a/src/actions/portal/serve.ts +++ b/src/actions/portal/serve.ts @@ -44,10 +44,12 @@ export class PortalServeAction { } const serveUrl = new UrlPath(`http://localhost:${servePort}`); - // Align the configured localhost base URL with the actual serve URL BEFORE + // Update the configured localhost base URL to the actual serve URL BEFORE // generation bakes it into the portal artifacts; otherwise the portal would load // its content from the wrong port and fail to render. - await this.reconcileBaseUrl(buildDirectory, serveUrl); + if (!(await this.updateBaseUrl(buildDirectory, serveUrl))) { + return ActionResult.failed(); + } const generatePortalAction = new GenerateAction(this.configDir, this.commandMetadata, this.authKey); const result = await generatePortalAction.execute(buildDirectory, portalDirectory, true, false); @@ -140,24 +142,27 @@ export class PortalServeAction { return ActionResult.success(); } - // Aligns the portal's configured localhost base URL with the actual serve URL and + // Updates the portal's configured localhost base URL to the actual serve URL and // persists the change, informing the user. Delegates the config logic to BuildConfig. - private async reconcileBaseUrl(buildDirectory: DirectoryPath, serveUrl: UrlPath): Promise { + // Returns false when the build file can't be read (missing/invalid) so the caller can + // fail with a clear message instead of crashing on the unread file. + private async updateBaseUrl(buildDirectory: DirectoryPath, serveUrl: UrlPath): Promise { const buildContext = new BuildContext(buildDirectory); let buildConfig; try { buildConfig = await buildContext.getBuildFileContents(); } catch { - return; + this.prompts.invalidBuildConfig(buildDirectory); + return false; } - const reconciliation = buildConfig.reconcileLocalhostBaseUrl(serveUrl); - if (reconciliation.isErr()) { - return; + const change = buildConfig.updateBuildConfigBaseUrl(serveUrl); + if (change.isOk()) { + await buildContext.updateBuildFileContents(change.value.config); + this.prompts.baseUrlPortUpdated(change.value.previous, change.value.updated); } - await buildContext.updateBuildFileContents(reconciliation.value.config); - this.prompts.baseUrlPortUpdated(reconciliation.value.previous, reconciliation.value.updated); + return true; } // Resolves ok once the server is bound, or err with the bind error (e.g. EADDRINUSE) diff --git a/src/prompts/portal/serve.ts b/src/prompts/portal/serve.ts index c9624a78..b3ee4e1c 100644 --- a/src/prompts/portal/serve.ts +++ b/src/prompts/portal/serve.ts @@ -7,9 +7,9 @@ import { noteWrapped } from "../prompt.js"; export class PortalServePrompts { public usingFallbackPort(currentPort: number, availablePort: number) { - const message = `Port ${f.var(currentPort.toString())} is already in use. Available port ${f.var( + const message = `Port ${f.var(currentPort.toString())} is already in use. The generated portal would use the available port ${f.var( availablePort.toString() - )} will be used.`; + )} instead.`; log.step(message); } @@ -20,6 +20,13 @@ export class PortalServePrompts { log.error(message); } + public invalidBuildConfig(buildDirectory: DirectoryPath) { + const message = + `Could not read the build configuration in ${f.path(buildDirectory)}. ` + + `Ensure ${f.var("APIMATIC-BUILD.json")} exists and is valid JSON.`; + log.error(message); + } + public baseUrlPortUpdated(previousUrl: string, updatedUrl: string) { const message = `The configured base URL ${f.var(previousUrl)} did not match the serve port. ` + diff --git a/src/types/build/build.ts b/src/types/build/build.ts index 475d9536..3b3d37e4 100644 --- a/src/types/build/build.ts +++ b/src/types/build/build.ts @@ -65,9 +65,9 @@ export interface RecipeWorkflow { scriptPath: string; } -// Outcome of a successful base-URL reconciliation: the updated config plus the -// before/after URLs (for user messaging). -export interface BaseUrlReconciliation { +// Outcome of a successful base-URL update: the new config plus the before/after URLs +// (for user messaging). +export interface BaseUrlChange { config: BuildConfig; previous: string; updated: string; @@ -180,7 +180,7 @@ export class PortalSettings { } // Immutable wrapper around the parsed APIMATIC-BUILD.json. All build-config reads go -// through accessor methods and all changes go through with*/reconcile methods that +// through accessor methods and all changes go through with*/update methods that // return a NEW BuildConfig — the wrapped data is never mutated after construction. // Construct via `BuildConfig.parse`; persist via `BuildContext`. export class BuildConfig { @@ -243,11 +243,11 @@ export class BuildConfig { return new BuildConfig(data); } - // Aligns a configured localhost base URL with where the portal is actually served: - // when the effective base URL is localhost and differs from `serveUrl`, it is replaced - // wholesale with `serveUrl`. Returns ok(reconciliation) when a change was made, or - // err when nothing changed (no base URL, non-localhost URL, or already aligned). - public reconcileLocalhostBaseUrl(serveUrl: UrlPath): Result { + // Updates a configured localhost base URL to match where the portal is actually + // served: when the effective base URL is localhost and differs from `serveUrl`, it is + // replaced wholesale with `serveUrl`. Returns ok(change) when updated, or err when + // nothing changed (no base URL, non-localhost URL, or already matching). + public updateBuildConfigBaseUrl(serveUrl: UrlPath): Result { // `portalSettings.baseUrl` is preferred for portal artifacts; otherwise fall back // to `generatePortal.baseUrl`. Mirrors how codegen resolves the base URL. const portalSettings = this.data.generatePortal?.portalSettings; From 20db681d691fb1131ec0f12d88ffad1049cf2f01 Mon Sep 17 00:00:00 2001 From: Sohail Date: Tue, 23 Jun 2026 16:48:03 +0500 Subject: [PATCH 28/44] refactor(build): use UrlPath for base-URL values in BaseUrlChange MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - BaseUrlChange.previous/updated are now UrlPath instead of raw strings. - PortalSettings.withBaseUrl takes a UrlPath (unwraps to string only at the JSON boundary); removed the unused baseUrl() getter. - baseUrlPortUpdated prompt takes UrlPath and unwraps at the display boundary. The *Data interfaces (BuildConfigData/PortalConfig/PortalSettingsData/…) stay primitive on purpose: they mirror the on-disk APIMATIC-BUILD.json and must round-trip through JSON.parse/stringify, which class instances don't. Co-Authored-By: Claude Opus 4.8 --- src/prompts/portal/serve.ts | 6 +++--- src/types/build/build.ts | 19 +++++++------------ 2 files changed, 10 insertions(+), 15 deletions(-) diff --git a/src/prompts/portal/serve.ts b/src/prompts/portal/serve.ts index b3ee4e1c..a9c13257 100644 --- a/src/prompts/portal/serve.ts +++ b/src/prompts/portal/serve.ts @@ -27,10 +27,10 @@ export class PortalServePrompts { log.error(message); } - public baseUrlPortUpdated(previousUrl: string, updatedUrl: string) { + public baseUrlPortUpdated(previousUrl: UrlPath, updatedUrl: UrlPath) { const message = - `The configured base URL ${f.var(previousUrl)} did not match the serve port. ` + - `Updated it to ${f.var(updatedUrl)}.`; + `The configured base URL ${f.var(previousUrl.toString())} did not match the serve port. ` + + `Updated it to ${f.var(updatedUrl.toString())}.`; log.info(message); } diff --git a/src/types/build/build.ts b/src/types/build/build.ts index 3b3d37e4..31e57cba 100644 --- a/src/types/build/build.ts +++ b/src/types/build/build.ts @@ -69,8 +69,8 @@ export interface RecipeWorkflow { // (for user messaging). export interface BaseUrlChange { config: BuildConfig; - previous: string; - updated: string; + previous: UrlPath; + updated: UrlPath; } export function getLanguagesConfig(selectedLanguages: string[]) { @@ -138,14 +138,10 @@ export class PortalSettings { return new PortalSettings(clone(data)); } - public baseUrl(): string | undefined { - return this.data.baseUrl; - } - /** Returns a copy with the API-call base URL set. */ - public withBaseUrl(baseUrl: string): PortalSettings { + public withBaseUrl(baseUrl: UrlPath): PortalSettings { const data = clone(this.data); - data.baseUrl = baseUrl; + data.baseUrl = baseUrl.toString(); return new PortalSettings(data); } @@ -261,15 +257,14 @@ export class BuildConfig { return err("unchanged"); } - const updated = serveUrl.toString(); const data = clone(this.data); const portal = portalConfigOf(data); if (portal.portalSettings?.baseUrl) { - portal.portalSettings = PortalSettings.from(portal.portalSettings).withBaseUrl(updated).toJSON(); + portal.portalSettings = PortalSettings.from(portal.portalSettings).withBaseUrl(serveUrl).toJSON(); } else { - portal.baseUrl = updated; + portal.baseUrl = serveUrl.toString(); } - return ok({ config: new BuildConfig(data), previous: baseUrl, updated }); + return ok({ config: new BuildConfig(data), previous: parsedUrl, updated: serveUrl }); } // Returns a copy with a recipe workflow added (or replaced, matched by permalink). From 8ab51e7fb02c2fc0f04a9a329543ddd5c966b789 Mon Sep 17 00:00:00 2001 From: Sohail Date: Tue, 23 Jun 2026 16:50:01 +0500 Subject: [PATCH 29/44] refactor(portal): inline updateBaseUrl into serve execute The single-use helper is inlined into execute: read the build file (failing cleanly on a missing/invalid file), update the localhost base URL via BuildConfig, persist, and notify. Removes the indirection of returning a boolean just to signal failure. Co-Authored-By: Claude Opus 4.8 --- src/actions/portal/serve.ts | 38 +++++++++++++------------------------ 1 file changed, 13 insertions(+), 25 deletions(-) diff --git a/src/actions/portal/serve.ts b/src/actions/portal/serve.ts index 62ed1f8a..6fd16549 100644 --- a/src/actions/portal/serve.ts +++ b/src/actions/portal/serve.ts @@ -46,10 +46,21 @@ export class PortalServeAction { // Update the configured localhost base URL to the actual serve URL BEFORE // generation bakes it into the portal artifacts; otherwise the portal would load - // its content from the wrong port and fail to render. - if (!(await this.updateBaseUrl(buildDirectory, serveUrl))) { + // its content from the wrong port and fail to render. The build file is read here, + // before GenerateAction validates it, so a missing/invalid file is reported cleanly. + const buildContext = new BuildContext(buildDirectory); + let buildConfig; + try { + buildConfig = await buildContext.getBuildFileContents(); + } catch { + this.prompts.invalidBuildConfig(buildDirectory); return ActionResult.failed(); } + const baseUrlChange = buildConfig.updateBuildConfigBaseUrl(serveUrl); + if (baseUrlChange.isOk()) { + await buildContext.updateBuildFileContents(baseUrlChange.value.config); + this.prompts.baseUrlPortUpdated(baseUrlChange.value.previous, baseUrlChange.value.updated); + } const generatePortalAction = new GenerateAction(this.configDir, this.commandMetadata, this.authKey); const result = await generatePortalAction.execute(buildDirectory, portalDirectory, true, false); @@ -142,29 +153,6 @@ export class PortalServeAction { return ActionResult.success(); } - // Updates the portal's configured localhost base URL to the actual serve URL and - // persists the change, informing the user. Delegates the config logic to BuildConfig. - // Returns false when the build file can't be read (missing/invalid) so the caller can - // fail with a clear message instead of crashing on the unread file. - private async updateBaseUrl(buildDirectory: DirectoryPath, serveUrl: UrlPath): Promise { - const buildContext = new BuildContext(buildDirectory); - let buildConfig; - try { - buildConfig = await buildContext.getBuildFileContents(); - } catch { - this.prompts.invalidBuildConfig(buildDirectory); - return false; - } - - const change = buildConfig.updateBuildConfigBaseUrl(serveUrl); - if (change.isOk()) { - await buildContext.updateBuildFileContents(change.value.config); - this.prompts.baseUrlPortUpdated(change.value.previous, change.value.updated); - } - - return true; - } - // Resolves ok once the server is bound, or err with the bind error (e.g. EADDRINUSE) // so a failed listen is reported cleanly instead of crashing via an unhandled "error". private waitForServerListening(server: Server): Promise> { From 81f4360df244cbcc71f3c16d59716ac3112a3faa Mon Sep 17 00:00:00 2001 From: Sohail Date: Tue, 23 Jun 2026 16:59:34 +0500 Subject: [PATCH 30/44] refactor(build): updateBuildConfigBaseUrl returns only the updated config The before/after URLs were unnecessary: the serve action already has the new URL (serveUrl). updateBuildConfigBaseUrl now returns Result, the BaseUrlChange type is removed, and baseUrlPortUpdated takes just the new URL. Co-Authored-By: Claude Opus 4.8 --- src/actions/portal/serve.ts | 8 ++++---- src/prompts/portal/serve.ts | 6 ++---- src/types/build/build.ts | 16 ++-------------- 3 files changed, 8 insertions(+), 22 deletions(-) diff --git a/src/actions/portal/serve.ts b/src/actions/portal/serve.ts index 6fd16549..5ead3ec0 100644 --- a/src/actions/portal/serve.ts +++ b/src/actions/portal/serve.ts @@ -56,10 +56,10 @@ export class PortalServeAction { this.prompts.invalidBuildConfig(buildDirectory); return ActionResult.failed(); } - const baseUrlChange = buildConfig.updateBuildConfigBaseUrl(serveUrl); - if (baseUrlChange.isOk()) { - await buildContext.updateBuildFileContents(baseUrlChange.value.config); - this.prompts.baseUrlPortUpdated(baseUrlChange.value.previous, baseUrlChange.value.updated); + const updatedBuildConfig = buildConfig.updateBuildConfigBaseUrl(serveUrl); + if (updatedBuildConfig.isOk()) { + await buildContext.updateBuildFileContents(updatedBuildConfig.value); + this.prompts.baseUrlPortUpdated(serveUrl); } const generatePortalAction = new GenerateAction(this.configDir, this.commandMetadata, this.authKey); diff --git a/src/prompts/portal/serve.ts b/src/prompts/portal/serve.ts index a9c13257..a93521dc 100644 --- a/src/prompts/portal/serve.ts +++ b/src/prompts/portal/serve.ts @@ -27,10 +27,8 @@ export class PortalServePrompts { log.error(message); } - public baseUrlPortUpdated(previousUrl: UrlPath, updatedUrl: UrlPath) { - const message = - `The configured base URL ${f.var(previousUrl.toString())} did not match the serve port. ` + - `Updated it to ${f.var(updatedUrl.toString())}.`; + public baseUrlPortUpdated(updatedUrl: UrlPath) { + const message = `Updated the configured base URL to ${f.var(updatedUrl.toString())} to match the serve port.`; log.info(message); } diff --git a/src/types/build/build.ts b/src/types/build/build.ts index 31e57cba..68b87b84 100644 --- a/src/types/build/build.ts +++ b/src/types/build/build.ts @@ -65,14 +65,6 @@ export interface RecipeWorkflow { scriptPath: string; } -// Outcome of a successful base-URL update: the new config plus the before/after URLs -// (for user messaging). -export interface BaseUrlChange { - config: BuildConfig; - previous: UrlPath; - updated: UrlPath; -} - export function getLanguagesConfig(selectedLanguages: string[]) { return selectedLanguages.reduce((config, lang) => { config[lang] = {}; @@ -239,11 +231,7 @@ export class BuildConfig { return new BuildConfig(data); } - // Updates a configured localhost base URL to match where the portal is actually - // served: when the effective base URL is localhost and differs from `serveUrl`, it is - // replaced wholesale with `serveUrl`. Returns ok(change) when updated, or err when - // nothing changed (no base URL, non-localhost URL, or already matching). - public updateBuildConfigBaseUrl(serveUrl: UrlPath): Result { + public updateBuildConfigBaseUrl(serveUrl: UrlPath): Result { // `portalSettings.baseUrl` is preferred for portal artifacts; otherwise fall back // to `generatePortal.baseUrl`. Mirrors how codegen resolves the base URL. const portalSettings = this.data.generatePortal?.portalSettings; @@ -264,7 +252,7 @@ export class BuildConfig { } else { portal.baseUrl = serveUrl.toString(); } - return ok({ config: new BuildConfig(data), previous: parsedUrl, updated: serveUrl }); + return ok(new BuildConfig(data)); } // Returns a copy with a recipe workflow added (or replaced, matched by permalink). From 1c9978d05d6b793dee8fca56eb2056ff48d4af77 Mon Sep 17 00:00:00 2001 From: Sohail Date: Tue, 23 Jun 2026 17:03:03 +0500 Subject: [PATCH 31/44] fix(portal): fail quickstart when the API Copilot key lookup errors Previously a failed getAccountInfo was treated like "no key" and silently skipped. Now a lookup error is fatal: show "Failed to fetch your API Copilot key" and return ActionResult.failed(). Only an account with no key continues silently (no Copilot); multi-key cancel still aborts. Co-Authored-By: Claude Opus 4.8 --- src/actions/portal/quickstart.ts | 37 +++++++++++++++++--------------- src/prompts/portal/quickstart.ts | 4 ++++ 2 files changed, 24 insertions(+), 17 deletions(-) diff --git a/src/actions/portal/quickstart.ts b/src/actions/portal/quickstart.ts index 16f5df70..0c08e25e 100644 --- a/src/actions/portal/quickstart.ts +++ b/src/actions/portal/quickstart.ts @@ -161,26 +161,29 @@ 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. Copilot is opt-in: when the - // account has no key (or the lookup fails) it is skipped silently; cancelling the - // multi-key selection aborts quickstart. - let copilotKey: string | undefined; + // 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.isOk()) { - const copilotKeys = accountInfo.value.ApiCopilotKeys ?? []; - if (copilotKeys.length === 1) { - copilotKey = copilotKeys[0]; - } else if (copilotKeys.length > 1) { - copilotKey = await this.prompts.selectCopilotKey(copilotKeys); - if (!copilotKey) { - this.prompts.noCopilotKeySelected(); - return ActionResult.cancelled(); - } - } - if (copilotKey) { - this.prompts.copilotEnabled(copilotKey); + if (accountInfo.isErr()) { + this.prompts.copilotKeyFetchFailed(accountInfo.error); + return ActionResult.failed(); + } + + let copilotKey: string | undefined; + const copilotKeys = accountInfo.value.ApiCopilotKeys ?? []; + if (copilotKeys.length === 1) { + copilotKey = copilotKeys[0]; + } else if (copilotKeys.length > 1) { + copilotKey = await this.prompts.selectCopilotKey(copilotKeys); + if (!copilotKey) { + this.prompts.noCopilotKeySelected(); + return ActionResult.cancelled(); } } + if (copilotKey) { + this.prompts.copilotEnabled(copilotKey); + } const masterBuildFile = await this.prompts.downloadBuildDirectory( this.fileDownloadService.downloadFile(this.buildFileUrl) diff --git a/src/prompts/portal/quickstart.ts b/src/prompts/portal/quickstart.ts index 497d23a5..e096e861 100644 --- a/src/prompts/portal/quickstart.ts +++ b/src/prompts/portal/quickstart.ts @@ -230,6 +230,10 @@ ${f.link(referenceDocumentationUrl)}`; log.error("No API Copilot key was selected."); } + public copilotKeyFetchFailed(serviceError: ServiceError) { + log.error(`Failed to fetch your API Copilot key. ${serviceError.errorMessage}`); + } + public copilotEnabled(key: string) { const message = `API Copilot is enabled with key ${f.var(key)}. ` + From 658de1ba02146222c2e87484f267a04d9b4ccd2d Mon Sep 17 00:00:00 2001 From: Sohail Date: Tue, 23 Jun 2026 17:47:58 +0500 Subject: [PATCH 32/44] fix(portal): handle a failed live-reload server bind gracefully livereload binds its own internal HTTP server and only error-handles the WebSocket server, so a taken live-reload port (race after get-port) emitted an unhandled "error" and crashed the CLI. Wait on livereload's internal HTTP server (config.server) right after creation and fail cleanly with serverStartFailed, mirroring the main server's bind handling. Co-Authored-By: Claude Opus 4.8 --- src/actions/portal/serve.ts | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/src/actions/portal/serve.ts b/src/actions/portal/serve.ts index 5ead3ec0..aa9c8189 100644 --- a/src/actions/portal/serve.ts +++ b/src/actions/portal/serve.ts @@ -70,14 +70,22 @@ export class PortalServeAction { const liveReloadPort = await this.networkService.getServerPort([35729, 35730, 35731, 35732]); const liveReloadServer = createLiveReloadServer({ port: liveReloadPort }); + + // get-port only checks availability; a port can be taken between that check and the + // actual bind, so wait for each server to bind and fail cleanly instead of letting an + // unhandled "error" event crash the CLI. livereload surfaces its bind error only on + // its internal HTTP server (config.server), which isn't part of its public type. + const liveReloadHttpServer = (liveReloadServer as unknown as { config: { server: Server } }).config.server; + if ((await this.waitForServerListening(liveReloadHttpServer)).isErr()) { + this.prompts.serverStartFailed(liveReloadPort); + return ActionResult.failed(); + } + const server = this.application .use(connectLiveReload()) .use(express.static(portalDirectory.toString(), { extensions: ["html"] })) .listen(servePort); - // get-port only checks availability; the port can be taken between that check and - // now, so handle a failed bind gracefully instead of letting an unhandled "error" - // event crash the CLI. const listenResult = await this.waitForServerListening(server); if (listenResult.isErr()) { liveReloadServer.close(); From b78ed3fb3e2ea8c613b094753e805fb0988bd2bf Mon Sep 17 00:00:00 2001 From: Sohail Date: Tue, 23 Jun 2026 17:53:15 +0500 Subject: [PATCH 33/44] refactor(build): inline portalConfigOf helper Replace the single-line portalConfigOf(data) helper with direct data.generatePortal! access at its three call sites and drop the function. Co-Authored-By: Claude Opus 4.8 --- src/types/build/build.ts | 11 +++-------- 1 file changed, 3 insertions(+), 8 deletions(-) diff --git a/src/types/build/build.ts b/src/types/build/build.ts index 68b87b84..eb14c605 100644 --- a/src/types/build/build.ts +++ b/src/types/build/build.ts @@ -206,7 +206,7 @@ export class BuildConfig { /** Returns a copy with the portal's languageConfig set from the selected friendly language ids. */ public withPortalLanguages(languages: string[]): BuildConfig { const data = clone(this.data); - portalConfigOf(data).languageConfig = getLanguagesConfig(languages); + data.generatePortal!.languageConfig = getLanguagesConfig(languages); return new BuildConfig(data); } @@ -222,7 +222,7 @@ export class BuildConfig { // editor integrations for the configured SDK languages. public withApiCopilotForPortal(key: string, welcomeMessage: string, baseUrl: string): BuildConfig { const data = clone(this.data); - const portal = portalConfigOf(data); + const portal = data.generatePortal!; data.apiCopilotConfig = { isEnabled: true, key, welcomeMessage }; portal.baseUrl = baseUrl; portal.portalSettings = PortalSettings.from(portal.portalSettings) @@ -246,7 +246,7 @@ export class BuildConfig { } const data = clone(this.data); - const portal = portalConfigOf(data); + const portal = data.generatePortal!; if (portal.portalSettings?.baseUrl) { portal.portalSettings = PortalSettings.from(portal.portalSettings).withBaseUrl(serveUrl).toJSON(); } else { @@ -271,8 +271,3 @@ export class BuildConfig { return new BuildConfig(data); } } - -// Portal-config accessor for transforms. Assumes a single (non-versioned) portal build. -function portalConfigOf(data: BuildConfigData): PortalConfig { - return data.generatePortal!; -} From 1af63a7b70e10a269e826ac74aa452a989a5bf30 Mon Sep 17 00:00:00 2001 From: Sohail Date: Tue, 23 Jun 2026 18:02:51 +0500 Subject: [PATCH 34/44] refactor --- src/actions/portal/quickstart.ts | 2 +- src/actions/portal/serve.ts | 6 +- src/prompts/portal/quickstart.ts | 6 +- src/types/build/build.ts | 101 ++++++++++++++----------------- 4 files changed, 50 insertions(+), 65 deletions(-) diff --git a/src/actions/portal/quickstart.ts b/src/actions/portal/quickstart.ts index 0c08e25e..3d6995c4 100644 --- a/src/actions/portal/quickstart.ts +++ b/src/actions/portal/quickstart.ts @@ -166,7 +166,7 @@ export class PortalQuickstartAction { // selection aborts quickstart. const accountInfo = await this.apiService.getAccountInfo(this.configDir, this.commandMetadata.shell, null); if (accountInfo.isErr()) { - this.prompts.copilotKeyFetchFailed(accountInfo.error); + this.prompts.accountInfoFetchFailed(accountInfo.error); return ActionResult.failed(); } diff --git a/src/actions/portal/serve.ts b/src/actions/portal/serve.ts index aa9c8189..db782bd9 100644 --- a/src/actions/portal/serve.ts +++ b/src/actions/portal/serve.ts @@ -70,11 +70,7 @@ export class PortalServeAction { const liveReloadPort = await this.networkService.getServerPort([35729, 35730, 35731, 35732]); const liveReloadServer = createLiveReloadServer({ port: liveReloadPort }); - - // get-port only checks availability; a port can be taken between that check and the - // actual bind, so wait for each server to bind and fail cleanly instead of letting an - // unhandled "error" event crash the CLI. livereload surfaces its bind error only on - // its internal HTTP server (config.server), which isn't part of its public type. + // get error if the live reload server fails to start (e.g. EADDRINUSE) const liveReloadHttpServer = (liveReloadServer as unknown as { config: { server: Server } }).config.server; if ((await this.waitForServerListening(liveReloadHttpServer)).isErr()) { this.prompts.serverStartFailed(liveReloadPort); diff --git a/src/prompts/portal/quickstart.ts b/src/prompts/portal/quickstart.ts index e096e861..a0fc31a7 100644 --- a/src/prompts/portal/quickstart.ts +++ b/src/prompts/portal/quickstart.ts @@ -230,14 +230,14 @@ ${f.link(referenceDocumentationUrl)}`; log.error("No API Copilot key was selected."); } - public copilotKeyFetchFailed(serviceError: ServiceError) { - log.error(`Failed to fetch your API Copilot key. ${serviceError.errorMessage}`); + public accountInfoFetchFailed(serviceError: ServiceError) { + log.error(`Failed to fetch your account information. ${serviceError.errorMessage}`); } public copilotEnabled(key: string) { const message = `API Copilot is enabled with key ${f.var(key)}. ` + - `Any existing training data associated with this key will be overwritten when the portal is generated.`; + `Any existing AI context associated with this key will be overwritten when the portal is generated.`; log.warn(message); } diff --git a/src/types/build/build.ts b/src/types/build/build.ts index eb14c605..e52cb0cd 100644 --- a/src/types/build/build.ts +++ b/src/types/build/build.ts @@ -90,30 +90,25 @@ const CODEGEN_TEMPLATE_ID_BY_LANGUAGE: Readonly> = { // languageSettings entry for http must exist or the portal widget fails to render. const HTTP_TEMPLATE_ID = "http_curl_v1" as const; -// Deep clone used for copy-on-write transforms. The config is plain JSON, so a JSON -// round-trip is an exact, dependency-free copy. -function clone(value: T): T { - return JSON.parse(JSON.stringify(value)) as T; -} - // Immutable per-language portal setting. Build new values via the static factory and // the with* transforms; the wrapped data is never mutated after construction. export class LanguageSetting { private constructor(private readonly data: LanguageSettingData) {} public static from(data: LanguageSettingData = {}): LanguageSetting { - return new LanguageSetting(clone(data)); + return new LanguageSetting(data); } /** Returns a copy with all AI editor integrations (Cursor/Claude Code/VS Code) enabled. */ public withAiIntegrationsEnabled(): LanguageSetting { - const data = clone(this.data); - data.aiIntegration = { - cursor: { isEnabled: true }, - claudeCode: { isEnabled: true }, - vscode: { isEnabled: true } - }; - return new LanguageSetting(data); + return new LanguageSetting({ + ...this.data, + aiIntegration: { + cursor: { isEnabled: true }, + claudeCode: { isEnabled: true }, + vscode: { isEnabled: true } + } + }); } public toJSON(): LanguageSettingData { @@ -127,22 +122,19 @@ export class PortalSettings { private constructor(private readonly data: PortalSettingsData) {} public static from(data: PortalSettingsData = {}): PortalSettings { - return new PortalSettings(clone(data)); + return new PortalSettings(data); } /** Returns a copy with the API-call base URL set. */ public withBaseUrl(baseUrl: UrlPath): PortalSettings { - const data = clone(this.data); - data.baseUrl = baseUrl.toString(); - return new PortalSettings(data); + return new PortalSettings({ ...this.data, baseUrl: baseUrl.toString() }); } // Returns a copy with AI editor integrations enabled for the given SDK languages // (friendly ids). Always adds the http entry the portal needs to render and opens the // portal on the first SDK language. Languages without a codegen template id are skipped. public withAiIntegrations(languages: string[]): PortalSettings { - const data = clone(this.data); - const languageSettings: { [language: string]: LanguageSettingData } = { ...data.languageSettings }; + const languageSettings: { [language: string]: LanguageSettingData } = { ...this.data.languageSettings }; languageSettings[HTTP_TEMPLATE_ID] = languageSettings[HTTP_TEMPLATE_ID] ?? {}; let firstSdkTemplateId: string | undefined; @@ -155,11 +147,11 @@ export class PortalSettings { languageSettings[templateId] = LanguageSetting.from(languageSettings[templateId]).withAiIntegrationsEnabled().toJSON(); } - data.languageSettings = languageSettings; - if (firstSdkTemplateId) { - data.initialPlatform = firstSdkTemplateId; - } - return new PortalSettings(data); + return new PortalSettings({ + ...this.data, + languageSettings, + ...(firstSdkTemplateId ? { initialPlatform: firstSdkTemplateId } : {}) + }); } public toJSON(): PortalSettingsData { @@ -205,30 +197,34 @@ export class BuildConfig { /** Returns a copy with the portal's languageConfig set from the selected friendly language ids. */ public withPortalLanguages(languages: string[]): BuildConfig { - const data = clone(this.data); - data.generatePortal!.languageConfig = getLanguagesConfig(languages); - return new BuildConfig(data); + const portal = this.data.generatePortal!; + return new BuildConfig({ + ...this.data, + generatePortal: { ...portal, languageConfig: getLanguagesConfig(languages) } + }); } /** Returns a copy with the API Copilot configuration set (or overwritten). */ public withApiCopilotConfig(config: CopilotConfig): BuildConfig { - const data = clone(this.data); - data.apiCopilotConfig = { ...config }; - return new BuildConfig(data); + return new BuildConfig({ ...this.data, apiCopilotConfig: { ...config } }); } // Returns a copy with API Copilot enabled for a locally-served portal: stores the // Copilot config, points the portal base URL at the local serve URL, and turns on AI // editor integrations for the configured SDK languages. public withApiCopilotForPortal(key: string, welcomeMessage: string, baseUrl: string): BuildConfig { - const data = clone(this.data); - const portal = data.generatePortal!; - data.apiCopilotConfig = { isEnabled: true, key, welcomeMessage }; - portal.baseUrl = baseUrl; - portal.portalSettings = PortalSettings.from(portal.portalSettings) - .withAiIntegrations(Object.keys(portal.languageConfig)) - .toJSON(); - return new BuildConfig(data); + const portal = this.data.generatePortal!; + return new BuildConfig({ + ...this.data, + apiCopilotConfig: { isEnabled: true, key, welcomeMessage }, + generatePortal: { + ...portal, + baseUrl, + portalSettings: PortalSettings.from(portal.portalSettings) + .withAiIntegrations(Object.keys(portal.languageConfig)) + .toJSON() + } + }); } public updateBuildConfigBaseUrl(serveUrl: UrlPath): Result { @@ -245,29 +241,22 @@ export class BuildConfig { return err("unchanged"); } - const data = clone(this.data); - const portal = data.generatePortal!; - if (portal.portalSettings?.baseUrl) { - portal.portalSettings = PortalSettings.from(portal.portalSettings).withBaseUrl(serveUrl).toJSON(); - } else { - portal.baseUrl = serveUrl.toString(); - } - return ok(new BuildConfig(data)); + const portal = this.data.generatePortal!; + const updatedPortal: PortalConfig = portal.portalSettings?.baseUrl + ? { ...portal, portalSettings: PortalSettings.from(portal.portalSettings).withBaseUrl(serveUrl).toJSON() } + : { ...portal, baseUrl: serveUrl.toString() }; + return ok(new BuildConfig({ ...this.data, generatePortal: updatedPortal })); } // Returns a copy with a recipe workflow added (or replaced, matched by permalink). public withRecipeWorkflow(name: string, functionName: string, scriptPath: string): BuildConfig { - const data = clone(this.data); - const recipes = (data.recipes ??= {}); - const workflows = (recipes.workflows ??= []); + const recipes = this.data.recipes ?? {}; + const workflows = recipes.workflows ?? []; const permalink = `page:recipes/${functionName}`; const workflow: RecipeWorkflow = { name, permalink, functionName, scriptPath }; const existingIndex = workflows.findIndex((w) => w.permalink === permalink); - if (existingIndex === -1) { - workflows.push(workflow); - } else { - workflows[existingIndex] = workflow; - } - return new BuildConfig(data); + const updatedWorkflows = + existingIndex === -1 ? [...workflows, workflow] : workflows.map((w, index) => (index === existingIndex ? workflow : w)); + return new BuildConfig({ ...this.data, recipes: { ...recipes, workflows: updatedWorkflows } }); } } From 6570f0591ed7ac2dc5bdd762931d3294bccf3204 Mon Sep 17 00:00:00 2001 From: saeedjamshaid Date: Tue, 23 Jun 2026 18:18:36 +0500 Subject: [PATCH 35/44] refactor(portal): inline single-use listenResult in serve Match the livereload bind check above it; the guard logic (close the livereload server, report serverStartFailed, fail) is unchanged. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/actions/portal/serve.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/actions/portal/serve.ts b/src/actions/portal/serve.ts index db782bd9..1fda274c 100644 --- a/src/actions/portal/serve.ts +++ b/src/actions/portal/serve.ts @@ -82,8 +82,7 @@ export class PortalServeAction { .use(express.static(portalDirectory.toString(), { extensions: ["html"] })) .listen(servePort); - const listenResult = await this.waitForServerListening(server); - if (listenResult.isErr()) { + if ((await this.waitForServerListening(server)).isErr()) { liveReloadServer.close(); this.prompts.serverStartFailed(servePort); return ActionResult.failed(); From 0c1a550d73e784eb10c8d2ccd44af2d4dc799032 Mon Sep 17 00:00:00 2001 From: saeedjamshaid Date: Tue, 23 Jun 2026 18:22:44 +0500 Subject: [PATCH 36/44] refactor(portal): drop livereload internals listen check Stop reaching into livereload's private config.server to await its bind; the cast into library internals was the only thing requiring it. The express portal server's bind is still guarded. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/actions/portal/serve.ts | 6 ------ 1 file changed, 6 deletions(-) diff --git a/src/actions/portal/serve.ts b/src/actions/portal/serve.ts index 1fda274c..6e3754ab 100644 --- a/src/actions/portal/serve.ts +++ b/src/actions/portal/serve.ts @@ -70,12 +70,6 @@ export class PortalServeAction { const liveReloadPort = await this.networkService.getServerPort([35729, 35730, 35731, 35732]); const liveReloadServer = createLiveReloadServer({ port: liveReloadPort }); - // get error if the live reload server fails to start (e.g. EADDRINUSE) - const liveReloadHttpServer = (liveReloadServer as unknown as { config: { server: Server } }).config.server; - if ((await this.waitForServerListening(liveReloadHttpServer)).isErr()) { - this.prompts.serverStartFailed(liveReloadPort); - return ActionResult.failed(); - } const server = this.application .use(connectLiveReload()) From 3ea399ba739234223193a2341e4bde26a9505ae0 Mon Sep 17 00:00:00 2001 From: Sohail Date: Tue, 23 Jun 2026 18:36:41 +0500 Subject: [PATCH 37/44] rename --- src/prompts/portal/quickstart.ts | 2 +- src/types/build/build.ts | 12 ++++++------ 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/src/prompts/portal/quickstart.ts b/src/prompts/portal/quickstart.ts index a0fc31a7..14694473 100644 --- a/src/prompts/portal/quickstart.ts +++ b/src/prompts/portal/quickstart.ts @@ -214,7 +214,7 @@ ${f.link(referenceDocumentationUrl)}`; public async selectCopilotKey(keys: string[]): Promise { const selectedKey = await select({ - message: "Select the API Copilot key you would like to enable for this Portal:", + message: "Select the API Copilot key you would like to use for this Portal:", maxItems: 10, options: keys.map((key) => ({ value: key, label: key })) }); diff --git a/src/types/build/build.ts b/src/types/build/build.ts index e52cb0cd..315786ba 100644 --- a/src/types/build/build.ts +++ b/src/types/build/build.ts @@ -111,7 +111,7 @@ export class LanguageSetting { }); } - public toJSON(): LanguageSettingData { + public toLanguageSettingData(): LanguageSettingData { return this.data; } } @@ -144,7 +144,7 @@ export class PortalSettings { continue; } firstSdkTemplateId ??= templateId; - languageSettings[templateId] = LanguageSetting.from(languageSettings[templateId]).withAiIntegrationsEnabled().toJSON(); + languageSettings[templateId] = LanguageSetting.from(languageSettings[templateId]).withAiIntegrationsEnabled().toLanguageSettingData(); } return new PortalSettings({ @@ -154,7 +154,7 @@ export class PortalSettings { }); } - public toJSON(): PortalSettingsData { + public toPortalSettingsData(): PortalSettingsData { return this.data; } } @@ -171,7 +171,7 @@ export class BuildConfig { } // Used implicitly by JSON.stringify when the config is written back to disk. - public toJSON(): BuildConfigData { + public toBuildConfigDData(): BuildConfigData { return this.data; } @@ -222,7 +222,7 @@ export class BuildConfig { baseUrl, portalSettings: PortalSettings.from(portal.portalSettings) .withAiIntegrations(Object.keys(portal.languageConfig)) - .toJSON() + .toPortalSettingsData() } }); } @@ -243,7 +243,7 @@ export class BuildConfig { const portal = this.data.generatePortal!; const updatedPortal: PortalConfig = portal.portalSettings?.baseUrl - ? { ...portal, portalSettings: PortalSettings.from(portal.portalSettings).withBaseUrl(serveUrl).toJSON() } + ? { ...portal, portalSettings: PortalSettings.from(portal.portalSettings).withBaseUrl(serveUrl).toPortalSettingsData() } : { ...portal, baseUrl: serveUrl.toString() }; return ok(new BuildConfig({ ...this.data, generatePortal: updatedPortal })); } From 4a11233a417f400e2d0b03a0f5e1db7541c4b3e0 Mon Sep 17 00:00:00 2001 From: Sohail Date: Tue, 23 Jun 2026 18:47:11 +0500 Subject: [PATCH 38/44] updatge --- src/prompts/portal/serve.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/prompts/portal/serve.ts b/src/prompts/portal/serve.ts index a93521dc..31dd7885 100644 --- a/src/prompts/portal/serve.ts +++ b/src/prompts/portal/serve.ts @@ -28,7 +28,7 @@ export class PortalServePrompts { } public baseUrlPortUpdated(updatedUrl: UrlPath) { - const message = `Updated the configured base URL to ${f.var(updatedUrl.toString())} to match the serve port.`; + const message = `Updated the configured base URL in ${f.var("APIMATIC-BUILD.json")} to ${f.var(updatedUrl.toString())} to match the serve port.`; log.info(message); } From 61a41b614d82a5cc67bc9fb2b3e2b6c5f4d6addf Mon Sep 17 00:00:00 2001 From: saeedjamshaid Date: Tue, 23 Jun 2026 18:50:42 +0500 Subject: [PATCH 39/44] refactor --- src/actions/portal/serve.ts | 4 ++-- src/types/build/build.ts | 12 +++++++----- 2 files changed, 9 insertions(+), 7 deletions(-) diff --git a/src/actions/portal/serve.ts b/src/actions/portal/serve.ts index 6e3754ab..a90acd9b 100644 --- a/src/actions/portal/serve.ts +++ b/src/actions/portal/serve.ts @@ -57,8 +57,8 @@ export class PortalServeAction { return ActionResult.failed(); } const updatedBuildConfig = buildConfig.updateBuildConfigBaseUrl(serveUrl); - if (updatedBuildConfig.isOk()) { - await buildContext.updateBuildFileContents(updatedBuildConfig.value); + if (updatedBuildConfig !== buildConfig) { + await buildContext.updateBuildFileContents(updatedBuildConfig); this.prompts.baseUrlPortUpdated(serveUrl); } diff --git a/src/types/build/build.ts b/src/types/build/build.ts index 315786ba..3988f9ac 100644 --- a/src/types/build/build.ts +++ b/src/types/build/build.ts @@ -1,4 +1,3 @@ -import { err, ok, Result } from "neverthrow"; import { DirectoryPath } from "../file/directoryPath.js"; import { UrlPath } from "../file/urlPath.js"; @@ -227,25 +226,28 @@ export class BuildConfig { }); } - public updateBuildConfigBaseUrl(serveUrl: UrlPath): Result { + // Returns a copy with the portal base URL repointed at the serve URL, or `this` + // unchanged when there's no localhost base URL to rewrite (or it already matches the + // serve URL). Callers detect a change via reference identity: `result !== original`. + public updateBuildConfigBaseUrl(serveUrl: UrlPath): BuildConfig { // `portalSettings.baseUrl` is preferred for portal artifacts; otherwise fall back // to `generatePortal.baseUrl`. Mirrors how codegen resolves the base URL. const portalSettings = this.data.generatePortal?.portalSettings; const baseUrl = portalSettings?.baseUrl ?? this.data.generatePortal?.baseUrl; if (!baseUrl) { - return err("unchanged"); + return this; } const parsedUrl = UrlPath.create(baseUrl); if (!parsedUrl?.isLocalhost() || parsedUrl.isEqual(serveUrl)) { - return err("unchanged"); + return this; } const portal = this.data.generatePortal!; const updatedPortal: PortalConfig = portal.portalSettings?.baseUrl ? { ...portal, portalSettings: PortalSettings.from(portal.portalSettings).withBaseUrl(serveUrl).toPortalSettingsData() } : { ...portal, baseUrl: serveUrl.toString() }; - return ok(new BuildConfig({ ...this.data, generatePortal: updatedPortal })); + return new BuildConfig({ ...this.data, generatePortal: updatedPortal }); } // Returns a copy with a recipe workflow added (or replaced, matched by permalink). From 8fc81346ade1fe120e94b92801fb1ac002884971 Mon Sep 17 00:00:00 2001 From: Sohail Date: Tue, 23 Jun 2026 20:10:58 +0500 Subject: [PATCH 40/44] fix: portal generation error --- src/types/build/build.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/types/build/build.ts b/src/types/build/build.ts index 3988f9ac..3ea1dd06 100644 --- a/src/types/build/build.ts +++ b/src/types/build/build.ts @@ -169,8 +169,8 @@ export class BuildConfig { return new BuildConfig(JSON.parse(json) as BuildConfigData); } - // Used implicitly by JSON.stringify when the config is written back to disk. - public toBuildConfigDData(): BuildConfigData { + // Called implicitly by JSON.stringify when the config is written back to disk. + public toJSON(): BuildConfigData { return this.data; } From 5916df1ed69f2399b404e79fb8f1b0b45076df38 Mon Sep 17 00:00:00 2001 From: Sohail Date: Tue, 23 Jun 2026 20:12:47 +0500 Subject: [PATCH 41/44] update messages --- src/prompts/portal/quickstart.ts | 2 +- src/prompts/portal/serve.ts | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/prompts/portal/quickstart.ts b/src/prompts/portal/quickstart.ts index 14694473..7d46f41c 100644 --- a/src/prompts/portal/quickstart.ts +++ b/src/prompts/portal/quickstart.ts @@ -214,7 +214,7 @@ ${f.link(referenceDocumentationUrl)}`; public async selectCopilotKey(keys: string[]): Promise { const selectedKey = await select({ - message: "Select the API Copilot key you would like to use for this Portal:", + message: "Select an API Copilot key for this portal:", maxItems: 10, options: keys.map((key) => ({ value: key, label: key })) }); diff --git a/src/prompts/portal/serve.ts b/src/prompts/portal/serve.ts index 31dd7885..d29dfa82 100644 --- a/src/prompts/portal/serve.ts +++ b/src/prompts/portal/serve.ts @@ -7,7 +7,7 @@ import { noteWrapped } from "../prompt.js"; export class PortalServePrompts { public usingFallbackPort(currentPort: number, availablePort: number) { - const message = `Port ${f.var(currentPort.toString())} is already in use. The generated portal would use the available port ${f.var( + const message = `Port ${f.var(currentPort.toString())} is already in use. The portal will use port ${f.var( availablePort.toString() )} instead.`; log.step(message); @@ -28,7 +28,7 @@ export class PortalServePrompts { } public baseUrlPortUpdated(updatedUrl: UrlPath) { - const message = `Updated the configured base URL in ${f.var("APIMATIC-BUILD.json")} to ${f.var(updatedUrl.toString())} to match the serve port.`; + const message = `Updated the base URL in ${f.var("APIMATIC-BUILD.json")} to ${f.var(updatedUrl.toString())} to match the serve port.`; log.info(message); } From 859c7391af43300faa718abf5901cc3441d5e001 Mon Sep 17 00:00:00 2001 From: Sohail Date: Tue, 23 Jun 2026 20:40:01 +0500 Subject: [PATCH 42/44] fix(portal): validate source dir before port negotiation; reconcile base URL on hot reload - Check for portal source directory before negotiating a serve port so empty-directory errors are diagnosed immediately with a pointer to portal:quickstart rather than a misleading JSON config error - Re-run base URL reconciliation on every hot-reload cycle so that a portalSettings.baseUrl added or changed mid-session (which takes precedence over generatePortal.baseUrl) is corrected to the actual serve port before generation bakes it in Co-Authored-By: Claude Sonnet 4.6 --- src/actions/portal/serve.ts | 40 +++++++++++++++++++++++++++---------- src/prompts/portal/serve.ts | 7 +++++++ 2 files changed, 37 insertions(+), 10 deletions(-) diff --git a/src/actions/portal/serve.ts b/src/actions/portal/serve.ts index a90acd9b..5acd9aee 100644 --- a/src/actions/portal/serve.ts +++ b/src/actions/portal/serve.ts @@ -38,17 +38,11 @@ export class PortalServeAction { hotReload: boolean, onAfterServe?: () => void ): Promise { - const servePort = await this.networkService.getServerPort([port, 3000, 3001, 3002]); - if (servePort != port) { - this.prompts.usingFallbackPort(port, servePort); - } - const serveUrl = new UrlPath(`http://localhost:${servePort}`); - - // Update the configured localhost base URL to the actual serve URL BEFORE - // generation bakes it into the portal artifacts; otherwise the portal would load - // its content from the wrong port and fail to render. The build file is read here, - // before GenerateAction validates it, so a missing/invalid file is reported cleanly. const buildContext = new BuildContext(buildDirectory); + if (!(await buildContext.exists())) { + this.prompts.noPortalSource(buildDirectory); + return ActionResult.failed(); + } let buildConfig; try { buildConfig = await buildContext.getBuildFileContents(); @@ -56,6 +50,16 @@ export class PortalServeAction { this.prompts.invalidBuildConfig(buildDirectory); return ActionResult.failed(); } + + const servePort = await this.networkService.getServerPort([port, 3000, 3001, 3002]); + if (servePort != port) { + this.prompts.usingFallbackPort(port, servePort); + } + const serveUrl = new UrlPath(`http://localhost:${servePort}`); + + // Update the configured localhost base URL to the actual serve URL BEFORE + // generation bakes it into the portal artifacts; otherwise the portal would load + // its content from the wrong port and fail to render. const updatedBuildConfig = buildConfig.updateBuildConfigBaseUrl(serveUrl); if (updatedBuildConfig !== buildConfig) { await buildContext.updateBuildFileContents(updatedBuildConfig); @@ -130,6 +134,22 @@ export class PortalServeAction { await debounceService.batchSingleRequest(async () => { this.prompts.changesDetected(); + // Re-reconcile on every hot-reload cycle: portalSettings.baseUrl takes + // precedence over generatePortal.baseUrl and may have been added or changed + // since serve started. If a mismatch is found the file is rewritten, which + // triggers a second watcher event — the debounce queues it, and the second + // cycle sees no mismatch and generates cleanly. + try { + const latestConfig = await buildContext.getBuildFileContents(); + const reconciledConfig = latestConfig.updateBuildConfigBaseUrl(serveUrl); + if (reconciledConfig !== latestConfig) { + await buildContext.updateBuildFileContents(reconciledConfig); + this.prompts.baseUrlPortUpdated(serveUrl); + } + } catch { + // Build file temporarily unreadable (e.g. mid-save); skip reconciliation + // this cycle. GenerateAction will surface the error if the file is broken. + } await generatePortalAction.execute(buildDirectory, portalDirectory, true, false, false); liveReloadServer.refresh(portalDirectory.toString()); this.clearStandardInput(); diff --git a/src/prompts/portal/serve.ts b/src/prompts/portal/serve.ts index d29dfa82..33c15dd3 100644 --- a/src/prompts/portal/serve.ts +++ b/src/prompts/portal/serve.ts @@ -20,6 +20,13 @@ export class PortalServePrompts { log.error(message); } + public noPortalSource(buildDirectory: DirectoryPath) { + const message = + `No portal source found at ${f.path(buildDirectory)}. ` + + `Run ${f.cmdAlt("apimatic", "portal", "quickstart")} to set one up.`; + log.error(message); + } + public invalidBuildConfig(buildDirectory: DirectoryPath) { const message = `Could not read the build configuration in ${f.path(buildDirectory)}. ` + From 285ca8be50d7260fa3d742178efc9e4eb97b8cca Mon Sep 17 00:00:00 2001 From: Sohail Date: Tue, 23 Jun 2026 20:52:33 +0500 Subject: [PATCH 43/44] update msg --- 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 81cc6800..b46d6406 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" + - "- `[Enter another prompt here]`"; + "- `What endpoints are available in this API?`\n" ; export class CopilotAction { private readonly apiService = new ApiService(); From 6c1639110670d2067261a61ef51098bbf0ffa1b2 Mon Sep 17 00:00:00 2001 From: Sohail Date: Tue, 23 Jun 2026 20:57:30 +0500 Subject: [PATCH 44/44] fix(portal): guard livereload bind and type base URL as UrlPath Guard the livereload HTTP server's bind the same way as the main server: the livereload package attaches its error handler to the inner WebSocket server, not the HTTP server it binds the port on, so a failed bind emitted an unhandled "error" that crashed the process. On failure we now close the server and report serverStartFailed instead. Also model the portal base URL as a UrlPath instead of a raw string in withApiCopilotForPortal and quickstart's defaultBaseUrl, matching the sibling updateBuildConfigBaseUrl signature and the value-object convention. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01MXbT3oxYm19pgHaviemkfa --- src/actions/portal/quickstart.ts | 2 +- src/actions/portal/serve.ts | 11 +++++++++++ src/types/build/build.ts | 4 ++-- 3 files changed, 14 insertions(+), 3 deletions(-) diff --git a/src/actions/portal/quickstart.ts b/src/actions/portal/quickstart.ts index 3d6995c4..090366d1 100644 --- a/src/actions/portal/quickstart.ts +++ b/src/actions/portal/quickstart.ts @@ -21,7 +21,7 @@ import { ApiService } from '../../infrastructure/services/api-service.js'; import { DEFAULT_COPILOT_WELCOME_MESSAGE } from './copilot.js'; const defaultPort: number = 23513 as const; -const defaultBaseUrl: string = `http://localhost:${defaultPort}` as const; +const defaultBaseUrl = new UrlPath(`http://localhost:${defaultPort}`); export class PortalQuickstartAction { private readonly prompts: PortalQuickstartPrompts = new PortalQuickstartPrompts(); diff --git a/src/actions/portal/serve.ts b/src/actions/portal/serve.ts index 5acd9aee..31310db1 100644 --- a/src/actions/portal/serve.ts +++ b/src/actions/portal/serve.ts @@ -75,6 +75,17 @@ export class PortalServeAction { const liveReloadPort = await this.networkService.getServerPort([35729, 35730, 35731, 35732]); const liveReloadServer = createLiveReloadServer({ port: liveReloadPort }); + // livereload attaches its "error" handler to the inner WebSocket server, not to the + // HTTP server it binds the port on, so a failed bind (e.g. the port was taken in the + // gap since getServerPort) emits an unhandled "error" that would crash the process. + // Guard the HTTP server the same way as the main server below. + const liveReloadHttpServer = (liveReloadServer as unknown as { config: { server: Server } }).config.server; + if ((await this.waitForServerListening(liveReloadHttpServer)).isErr()) { + liveReloadServer.close(); + this.prompts.serverStartFailed(liveReloadPort); + return ActionResult.failed(); + } + const server = this.application .use(connectLiveReload()) .use(express.static(portalDirectory.toString(), { extensions: ["html"] })) diff --git a/src/types/build/build.ts b/src/types/build/build.ts index 3ea1dd06..dd7ddd27 100644 --- a/src/types/build/build.ts +++ b/src/types/build/build.ts @@ -211,14 +211,14 @@ export class BuildConfig { // Returns a copy with API Copilot enabled for a locally-served portal: stores the // Copilot config, points the portal base URL at the local serve URL, and turns on AI // editor integrations for the configured SDK languages. - public withApiCopilotForPortal(key: string, welcomeMessage: string, baseUrl: string): BuildConfig { + public withApiCopilotForPortal(key: string, welcomeMessage: string, baseUrl: UrlPath): BuildConfig { const portal = this.data.generatePortal!; return new BuildConfig({ ...this.data, apiCopilotConfig: { isEnabled: true, key, welcomeMessage }, generatePortal: { ...portal, - baseUrl, + baseUrl: baseUrl.toString(), portalSettings: PortalSettings.from(portal.portalSettings) .withAiIntegrations(Object.keys(portal.languageConfig)) .toPortalSettingsData()