diff --git a/src/actions/portal/copilot.ts b/src/actions/portal/copilot.ts index fdf46248..b46d6406 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" + + "- `What endpoints are available in this API?`\n" ; + export class CopilotAction { private readonly apiService = new ApiService(); private readonly fileService = new FileService(); @@ -42,9 +50,9 @@ export class CopilotAction { return ActionResult.failed(); } - const buildJson = await buildContext.getBuildFileContents(); + const buildConfig = await buildContext.getBuildFileContents(); - if (!force && buildJson.apiCopilotConfig != null && !(await this.prompts.confirmOverwrite())) { + if (!force && buildConfig.hasApiCopilot() && !(await this.prompts.confirmOverwrite())) { this.prompts.cancelled(); return ActionResult.cancelled(); } @@ -66,13 +74,13 @@ export class CopilotAction { const welcomeMessage = await this.prepareWelcomeMessage(); - buildJson.apiCopilotConfig = { + const updatedBuildConfig = buildConfig.withApiCopilotConfig({ isEnabled: enable, key: apiCopilotKeyResult.value, welcomeMessage: welcomeMessage - }; + }); - await buildContext.updateBuildFileContents(buildJson); + await buildContext.updateBuildFileContents(updatedBuildConfig); this.prompts.copilotConfigured(enable, apiCopilotKeyResult.value); @@ -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 8834460a..090366d1 100644 --- a/src/actions/portal/quickstart.ts +++ b/src/actions/portal/quickstart.ts @@ -13,13 +13,15 @@ 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 { 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 { DEFAULT_COPILOT_WELCOME_MESSAGE } from './copilot.js'; -const defaultPort: number = 3000 as const; +const defaultPort: number = 23513 as const; +const defaultBaseUrl = new UrlPath(`http://localhost:${defaultPort}`); export class PortalQuickstartAction { private readonly prompts: PortalQuickstartPrompts = new PortalQuickstartPrompts(); @@ -28,6 +30,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` ); @@ -157,6 +160,31 @@ export class PortalQuickstartAction { break; } + // Resolve the API Copilot key to enable, if any, before setting up the source + // directory so the user decides on Copilot up front. The lookup failing is fatal; + // an account with no key continues silently (no Copilot); cancelling the multi-key + // selection aborts quickstart. + const accountInfo = await this.apiService.getAccountInfo(this.configDir, this.commandMetadata.shell, null); + if (accountInfo.isErr()) { + this.prompts.accountInfoFetchFailed(accountInfo.error); + return ActionResult.failed(); + } + + 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) ); @@ -169,16 +197,23 @@ 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); - 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 baseConfig = (await buildContext.getBuildFileContents()).withPortalLanguages(languages); + const buildConfig = copilotKey + ? baseConfig.withApiCopilotForPortal(copilotKey, DEFAULT_COPILOT_WELCOME_MESSAGE, defaultBaseUrl) + : baseConfig; + await buildContext.updateBuildFileContents(buildConfig); + const specDirectory = sourceDirectory.join('spec'); const specContext = new SpecContext(specDirectory); await specContext.replaceDefaultSpec(specPath); @@ -199,4 +234,4 @@ export class PortalQuickstartAction { return ActionResult.success(); }); }; -} +} \ No newline at end of file diff --git a/src/actions/portal/serve.ts b/src/actions/portal/serve.ts index d6ebdf54..31310db1 100644 --- a/src/actions/portal/serve.ts +++ b/src/actions/portal/serve.ts @@ -1,3 +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"; @@ -11,6 +13,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(); @@ -35,28 +38,68 @@ 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()) { + const buildContext = new BuildContext(buildDirectory); + if (!(await buildContext.exists())) { + this.prompts.noPortalSource(buildDirectory); + return ActionResult.failed(); + } + let buildConfig; + try { + buildConfig = await buildContext.getBuildFileContents(); + } catch { + this.prompts.invalidBuildConfig(buildDirectory); return ActionResult.failed(); } const servePort = await this.networkService.getServerPort([port, 3000, 3001, 3002]); - if (servePort != port && !onAfterServe) { + 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); + this.prompts.baseUrlPortUpdated(serveUrl); + } + + 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]); 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"] })) .listen(servePort); - const portalUrl = new UrlPath(`http://localhost:${servePort}`); - this.prompts.portalServed(portalUrl); + if ((await this.waitForServerListening(server)).isErr()) { + liveReloadServer.close(); + this.prompts.serverStartFailed(servePort); + return ActionResult.failed(); + } + + this.prompts.portalServed(serveUrl); if (openInBrowser) { - await this.launcherService.openUrlInBrowser(portalUrl); + await this.launcherService.openUrlInBrowser(serveUrl); } this.prompts.promptForExit(); @@ -102,6 +145,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(); @@ -122,6 +181,23 @@ export class PortalServeAction { return ActionResult.success(); } + // 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(ok(undefined)); + }; + const onError = (error: Error) => { + server.removeListener("listening", onListening); + resolve(err(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/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..d7fe8b3e 100644 --- a/src/application/portal/recipe/recipe-generator.ts +++ b/src/application/portal/recipe/recipe-generator.ts @@ -77,34 +77,12 @@ 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)}` + const updatedBuildConfig = buildConfig.withRecipeWorkflow( + 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); + await buildContext.updateBuildFileContents(updatedBuildConfig); } private async createMarkdownFile(recipeMarkdownFileName: FileName, contentFolder: DirectoryPath): Promise { 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..7d46f41c 100644 --- a/src/prompts/portal/quickstart.ts +++ b/src/prompts/portal/quickstart.ts @@ -212,6 +212,35 @@ ${f.link(referenceDocumentationUrl)}`; log.error(serviceError.errorMessage); } + public async selectCopilotKey(keys: string[]): Promise { + const selectedKey = await select({ + message: "Select an API Copilot key for this portal:", + maxItems: 10, + options: keys.map((key) => ({ value: key, label: key })) + }); + + if (isCancel(selectedKey)) { + return undefined; + } + + return selectedKey; + } + + public noCopilotKeySelected() { + log.error("No API Copilot key was selected."); + } + + 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 AI context 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()); diff --git a/src/prompts/portal/serve.ts b/src/prompts/portal/serve.ts index 5a75d66e..33c15dd3 100644 --- a/src/prompts/portal/serve.ts +++ b/src/prompts/portal/serve.ts @@ -7,12 +7,38 @@ 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 portal will use port ${f.var( availablePort.toString() - )} will be used.`; + )} instead.`; 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 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)}. ` + + `Ensure ${f.var("APIMATIC-BUILD.json")} exists and is valid JSON.`; + log.error(message); + } + + public baseUrlPortUpdated(updatedUrl: UrlPath) { + 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); + } + public portalServed(urlPath: UrlPath) { const message = `The portal is running at ${f.link(urlPath.toString())}`; log.message(message); 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 1424b757..dd7ddd27 100644 --- a/src/types/build/build.ts +++ b/src/types/build/build.ts @@ -1,18 +1,50 @@ 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; } 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?: PortalSettingsData; apiSpecPath?: DirectoryPath; + [key: string]: unknown; +} + +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]: LanguageSettingData }; + [key: string]: unknown; +} + +export interface LanguageSettingData { + aiIntegration?: AiIntegration; + [key: string]: unknown; +} + +export interface AiIntegration { + cursor?: AiIntegrationSetting; + claudeCode?: AiIntegrationSetting; + vscode?: AiIntegrationSetting; +} + +export interface AiIntegrationSetting { + isEnabled: boolean; + stabilityLevelTag?: string; } export interface CopilotConfig { @@ -21,9 +53,212 @@ 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; + +// 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(data); + } + + /** Returns a copy with all AI editor integrations (Cursor/Claude Code/VS Code) enabled. */ + public withAiIntegrationsEnabled(): LanguageSetting { + return new LanguageSetting({ + ...this.data, + aiIntegration: { + cursor: { isEnabled: true }, + claudeCode: { isEnabled: true }, + vscode: { isEnabled: true } + } + }); + } + + public toLanguageSettingData(): 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(data); + } + + /** Returns a copy with the API-call base URL set. */ + public withBaseUrl(baseUrl: UrlPath): PortalSettings { + 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 languageSettings: { [language: string]: LanguageSettingData } = { ...this.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().toLanguageSettingData(); + } + + return new PortalSettings({ + ...this.data, + languageSettings, + ...(firstSdkTemplateId ? { initialPlatform: firstSdkTemplateId } : {}) + }); + } + + public toPortalSettingsData(): 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*/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 { + private constructor(private readonly data: BuildConfigData) {} + + public static parse(json: string): BuildConfig { + return new BuildConfig(JSON.parse(json) as BuildConfigData); + } + + // Called 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; + } + + /** Returns a copy with the portal's languageConfig set from the selected friendly language ids. */ + public withPortalLanguages(languages: string[]): BuildConfig { + 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 { + 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: UrlPath): BuildConfig { + const portal = this.data.generatePortal!; + return new BuildConfig({ + ...this.data, + apiCopilotConfig: { isEnabled: true, key, welcomeMessage }, + generatePortal: { + ...portal, + baseUrl: baseUrl.toString(), + portalSettings: PortalSettings.from(portal.portalSettings) + .withAiIntegrations(Object.keys(portal.languageConfig)) + .toPortalSettingsData() + } + }); + } + + // 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 this; + } + + const parsedUrl = UrlPath.create(baseUrl); + if (!parsedUrl?.isLocalhost() || parsedUrl.isEqual(serveUrl)) { + 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 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 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); + const updatedWorkflows = + existingIndex === -1 ? [...workflows, workflow] : workflows.map((w, index) => (index === existingIndex ? workflow : w)); + return new BuildConfig({ ...this.data, recipes: { ...recipes, workflows: updatedWorkflows } }); + } +} diff --git a/src/types/file/urlPath.ts b/src/types/file/urlPath.ts index 242143e7..ed5c0b30 100644 --- a/src/types/file/urlPath.ts +++ b/src/types/file/urlPath.ts @@ -22,4 +22,15 @@ export class UrlPath { public toString(): string { return this.url; } -} \ No newline at end of file + + /** 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"; + } + + /** Structural equality against another URL. */ + public isEqual(other: UrlPath): boolean { + return this.url === other.url; + } +}