Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
44 commits
Select commit Hold shift + click to select a range
1f84180
feat(portal): prefer port 23513 and auto-configure API Copilot in qui…
sohail2721 Jun 22, 2026
7ffa43e
refactor(portal): address code review on copilot/serve changes
sohail2721 Jun 23, 2026
57776bc
style(portal): align with .ai/skills prompt & value-object conventions
sohail2721 Jun 23, 2026
2f87013
fix(portal): key copilot languageSettings by codegen template id
sohail2721 Jun 23, 2026
9085ebc
chore(portal): drop the "API Copilot enabled" message in quickstart
sohail2721 Jun 23, 2026
e921560
fix(portal): stop forcing an absolute baseUrl in quickstart copilot s…
sohail2721 Jun 23, 2026
1f4e096
revert(portal): restore default baseUrl in quickstart, rename to defa…
sohail2721 Jun 23, 2026
f0025c3
fix(portal): write languageSettings for every language so the portal …
sohail2721 Jun 23, 2026
62f4b29
refactor(portal): limit aiIntegration to selected SDK languages, add …
sohail2721 Jun 23, 2026
c214970
feat(portal): open quickstart portal on the first SDK language
sohail2721 Jun 23, 2026
5353e17
fix(portal): cancel quickstart when copilot key selection is cancelled
sohail2721 Jun 23, 2026
54c5ec3
style(portal): resolve SonarCloud warnings
sohail2721 Jun 23, 2026
1fe7393
fix(portal): reconcile localhost baseUrl port with serve port before …
sohail2721 Jun 23, 2026
60b29ed
fix(portal): surface fallback-port notice during quickstart
sohail2721 Jun 23, 2026
aec2820
feat(portal): warn about training-data overwrite when enabling copilo…
sohail2721 Jun 23, 2026
2205192
style(portal): avoid regex backtracking in UrlPath.withPort
sohail2721 Jun 23, 2026
a3222fb
feat(portal): inform user of baseUrl port reconciliation in quickstar…
sohail2721 Jun 23, 2026
7175693
refactor(portal): resolve copilot key before source-directory setup
sohail2721 Jun 23, 2026
ffca7e2
fix: refactor quickstart code to use build Context
sohail2721 Jun 23, 2026
54ca5db
refactor(build): encapsulate build-config manipulation in a BuildConf…
sohail2721 Jun 23, 2026
9400888
fix(portal): only enable AI integrations when a copilot key exists
sohail2721 Jun 23, 2026
8b41a3c
fix(portal): handle a failed server bind gracefully on serve
sohail2721 Jun 23, 2026
fb1ec9d
refactor(build): make BuildConfig (and PortalSettings/LanguageSetting…
sohail2721 Jun 23, 2026
43866ce
rename
sohail2721 Jun 23, 2026
8d7cbcb
refactor(portal): inline copilot-key resolution into quickstart; use …
sohail2721 Jun 23, 2026
08e6456
refactor(portal): reconcile against the full serve URL and return nev…
sohail2721 Jun 23, 2026
4ec32ed
fix(portal): surface a clear error when the build file can't be read …
sohail2721 Jun 23, 2026
20db681
refactor(build): use UrlPath for base-URL values in BaseUrlChange
sohail2721 Jun 23, 2026
8ab51e7
refactor(portal): inline updateBaseUrl into serve execute
sohail2721 Jun 23, 2026
81f4360
refactor(build): updateBuildConfigBaseUrl returns only the updated co…
sohail2721 Jun 23, 2026
1c9978d
fix(portal): fail quickstart when the API Copilot key lookup errors
sohail2721 Jun 23, 2026
658de1b
fix(portal): handle a failed live-reload server bind gracefully
sohail2721 Jun 23, 2026
b78ed3f
refactor(build): inline portalConfigOf helper
sohail2721 Jun 23, 2026
1af63a7
refactor
sohail2721 Jun 23, 2026
6570f05
refactor(portal): inline single-use listenResult in serve
saeedjamshaid Jun 23, 2026
0c1a550
refactor(portal): drop livereload internals listen check
saeedjamshaid Jun 23, 2026
3ea399b
rename
sohail2721 Jun 23, 2026
4a11233
updatge
sohail2721 Jun 23, 2026
61a41b6
refactor
saeedjamshaid Jun 23, 2026
8fc8134
fix: portal generation error
sohail2721 Jun 23, 2026
5916df1
update messages
sohail2721 Jun 23, 2026
859c739
fix(portal): validate source dir before port negotiation; reconcile b…
sohail2721 Jun 23, 2026
285ca8b
update msg
sohail2721 Jun 23, 2026
6c16391
fix(portal): guard livereload bind and type base URL as UrlPath
sohail2721 Jun 23, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 14 additions & 13 deletions src/actions/portal/copilot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,14 @@ import { err, ok, Result } from "neverthrow";
type SelectKeyFailure = "failed" | "cancelled";
type SelectKeyResult = Result<string, SelectKeyFailure>;

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();
Expand Down Expand Up @@ -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();
}
Expand All @@ -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);

Expand Down Expand Up @@ -104,14 +112,7 @@ export class CopilotAction {
private async prepareWelcomeMessage(): Promise<string> {
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);
Expand Down
49 changes: 42 additions & 7 deletions src/actions/portal/quickstart.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -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`
);
Expand Down Expand Up @@ -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)
);
Expand All @@ -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);
Expand All @@ -199,4 +234,4 @@ export class PortalQuickstartAction {
return ActionResult.success();
});
};
}
}
90 changes: 83 additions & 7 deletions src/actions/portal/serve.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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();
Expand All @@ -35,28 +38,68 @@ export class PortalServeAction {
hotReload: boolean,
onAfterServe?: () => void
): Promise<ActionResult> {
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();

Expand Down Expand Up @@ -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();
Expand All @@ -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<Result<void, Error>> {
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) {
Expand Down
2 changes: 1 addition & 1 deletion src/actions/portal/toc/new-toc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
32 changes: 5 additions & 27 deletions src/application/portal/recipe/recipe-generator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,34 +77,12 @@ export class PortalRecipeGenerator {
recipeScriptFileName: FileName
): Promise<void> {
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<void> {
Expand Down
6 changes: 3 additions & 3 deletions src/commands/portal/serve.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Expand All @@ -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")}`
];
Expand Down
29 changes: 29 additions & 0 deletions src/prompts/portal/quickstart.ts
Original file line number Diff line number Diff line change
Expand Up @@ -212,6 +212,35 @@ ${f.link(referenceDocumentationUrl)}`;
log.error(serviceError.errorMessage);
}

public async selectCopilotKey(keys: string[]): Promise<string | undefined> {
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());
Expand Down
Loading
Loading