Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
4 changes: 3 additions & 1 deletion .ai/instructions.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,9 @@ pnpm test # tsx + mocha, runs test/**/*.test.ts
pnpm exec tsx node_modules/mocha/bin/_mocha "test/actions/portal/serve.test.ts" --timeout 99999

# Run CLI locally
node bin/run.js <command>
pnpm build:watch # tsc -b --watch: recompiles src/ → lib/ on change; keep running in a separate terminal
pnpm apimatic <command>
pnpm apimatic <command> -i <abs-path-to-dir-containing-src> # -i: for commands operating on a project directory containing src/
```

## Adding / upgrading dependencies
Expand Down
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,8 @@
"types": "lib/index.d.ts",
"scripts": {
"build": "tsc -b",
"build:watch": "tsc -b --watch",
"apimatic": "node ./bin/run.js",
"postpack": "rimraf oclif.manifest.json",
"posttest": "eslint . --ext .ts",
"prettier": "prettier \"src/**/*.{js,ts}\"",
Expand Down
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
Loading
Loading