Skip to content

Commit 45cc0e6

Browse files
feat(portal): prefer port 23513 and auto-configure API Copilot in quickstart (#290)
* build: local dev scripts (#291) * build: utility scripts for local testing * doc: update run commands --------- Co-authored-by: Muhammad Sohail <62895181+sohail2721@users.noreply.github.com>
1 parent 1c98477 commit 45cc0e6

13 files changed

Lines changed: 469 additions & 78 deletions

File tree

.ai/instructions.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,9 @@ pnpm test # tsx + mocha, runs test/**/*.test.ts
2626
pnpm exec tsx node_modules/mocha/bin/_mocha "test/actions/portal/serve.test.ts" --timeout 99999
2727

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

3234
## Adding / upgrading dependencies

package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,8 @@
3838
"types": "lib/index.d.ts",
3939
"scripts": {
4040
"build": "tsc -b",
41+
"build:watch": "tsc -b --watch",
42+
"apimatic": "node ./bin/run.js",
4143
"postpack": "rimraf oclif.manifest.json",
4244
"posttest": "eslint . --ext .ts",
4345
"prettier": "prettier \"src/**/*.{js,ts}\"",

src/actions/portal/copilot.ts

Lines changed: 14 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,14 @@ import { err, ok, Result } from "neverthrow";
1515
type SelectKeyFailure = "failed" | "cancelled";
1616
type SelectKeyResult = Result<string, SelectKeyFailure>;
1717

18+
export const DEFAULT_COPILOT_WELCOME_MESSAGE =
19+
"Hi there! I'm your API Integration Assistant, here to help you learn and integrate with this API.\n" +
20+
"\n" +
21+
"Ask me anything about this API or try one of these example prompts:\n" +
22+
"\n" +
23+
"- `What authentication methods does this API support?`\n" +
24+
"- `What endpoints are available in this API?`\n" ;
25+
1826
export class CopilotAction {
1927
private readonly apiService = new ApiService();
2028
private readonly fileService = new FileService();
@@ -42,9 +50,9 @@ export class CopilotAction {
4250
return ActionResult.failed();
4351
}
4452

45-
const buildJson = await buildContext.getBuildFileContents();
53+
const buildConfig = await buildContext.getBuildFileContents();
4654

47-
if (!force && buildJson.apiCopilotConfig != null && !(await this.prompts.confirmOverwrite())) {
55+
if (!force && buildConfig.hasApiCopilot() && !(await this.prompts.confirmOverwrite())) {
4856
this.prompts.cancelled();
4957
return ActionResult.cancelled();
5058
}
@@ -66,13 +74,13 @@ export class CopilotAction {
6674

6775
const welcomeMessage = await this.prepareWelcomeMessage();
6876

69-
buildJson.apiCopilotConfig = {
77+
const updatedBuildConfig = buildConfig.withApiCopilotConfig({
7078
isEnabled: enable,
7179
key: apiCopilotKeyResult.value,
7280
welcomeMessage: welcomeMessage
73-
};
81+
});
7482

75-
await buildContext.updateBuildFileContents(buildJson);
83+
await buildContext.updateBuildFileContents(updatedBuildConfig);
7684

7785
this.prompts.copilotConfigured(enable, apiCopilotKeyResult.value);
7886

@@ -104,14 +112,7 @@ export class CopilotAction {
104112
private async prepareWelcomeMessage(): Promise<string> {
105113
return await withDirPath(async (tempDir) => {
106114
const tempFile = new FilePath(tempDir, new FileName("welcome-message.md"));
107-
const defaultContent =
108-
"Hi there! I'm your API Integration Assistant, here to help you learn and integrate with this API.\n" +
109-
"\n" +
110-
"Ask me anything about this API or try one of these example prompts:\n" +
111-
"\n" +
112-
"- `What authentication methods does this API support?`\n" +
113-
"- `[Enter another prompt here]`";
114-
await this.fileService.writeContents(tempFile, defaultContent);
115+
await this.fileService.writeContents(tempFile, DEFAULT_COPILOT_WELCOME_MESSAGE);
115116
this.prompts.openWelcomeMessageEditor();
116117
await this.launcherService.openInEditor(tempFile);
117118
const welcomeMessage = await this.fileService.getContents(tempFile);

src/actions/portal/quickstart.ts

Lines changed: 42 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -13,13 +13,15 @@ import { ValidateAction } from '../api/validate.js';
1313
import { BuildContext } from '../../types/build-context.js';
1414
import { TempContext } from '../../types/temp-context.js';
1515
import { FileDownloadService } from '../../infrastructure/services/file-download-service.js';
16-
import { getLanguagesConfig } from '../../types/build/build.js';
1716
import { FilePath } from '../../types/file/filePath.js';
1817
import { SpecContext } from '../../types/spec-context.js';
1918
import { FeaturesToRemove, ValidationService } from '../../infrastructure/services/validation-service.js';
2019
import { FileName } from '../../types/file/fileName.js';
20+
import { ApiService } from '../../infrastructure/services/api-service.js';
21+
import { DEFAULT_COPILOT_WELCOME_MESSAGE } from './copilot.js';
2122

22-
const defaultPort: number = 3000 as const;
23+
const defaultPort: number = 23513 as const;
24+
const defaultBaseUrl = new UrlPath(`http://localhost:${defaultPort}`);
2325

2426
export class PortalQuickstartAction {
2527
private readonly prompts: PortalQuickstartPrompts = new PortalQuickstartPrompts();
@@ -28,6 +30,7 @@ export class PortalQuickstartAction {
2830
private readonly configDir: DirectoryPath;
2931
private readonly commandMetadata: CommandMetadata;
3032
private readonly fileDownloadService = new FileDownloadService();
33+
private readonly apiService = new ApiService();
3134
private readonly buildFileUrl = new UrlPath(
3235
`https://github.com/apimatic/sample-docs-as-code-portal/archive/refs/heads/master.zip`
3336
);
@@ -157,6 +160,31 @@ export class PortalQuickstartAction {
157160
break;
158161
}
159162

163+
// Resolve the API Copilot key to enable, if any, before setting up the source
164+
// directory so the user decides on Copilot up front. The lookup failing is fatal;
165+
// an account with no key continues silently (no Copilot); cancelling the multi-key
166+
// selection aborts quickstart.
167+
const accountInfo = await this.apiService.getAccountInfo(this.configDir, this.commandMetadata.shell, null);
168+
if (accountInfo.isErr()) {
169+
this.prompts.accountInfoFetchFailed(accountInfo.error);
170+
return ActionResult.failed();
171+
}
172+
173+
let copilotKey: string | undefined;
174+
const copilotKeys = accountInfo.value.ApiCopilotKeys ?? [];
175+
if (copilotKeys.length === 1) {
176+
copilotKey = copilotKeys[0];
177+
} else if (copilotKeys.length > 1) {
178+
copilotKey = await this.prompts.selectCopilotKey(copilotKeys);
179+
if (!copilotKey) {
180+
this.prompts.noCopilotKeySelected();
181+
return ActionResult.cancelled();
182+
}
183+
}
184+
if (copilotKey) {
185+
this.prompts.copilotEnabled(copilotKey);
186+
}
187+
160188
const masterBuildFile = await this.prompts.downloadBuildDirectory(
161189
this.fileDownloadService.downloadFile(this.buildFileUrl)
162190
);
@@ -169,16 +197,23 @@ export class PortalQuickstartAction {
169197
await this.zipService.unArchive(masterBuildFilePath, tempDirectory);
170198
const extractedFolder = tempDirectory.join(this.repositoryFolderName);
171199

200+
// Clean up the workflow dir from the template before copying
172201
const tempBuildContext = new BuildContext(extractedFolder);
173202
await tempBuildContext.deleteWorkflowDir();
174203

175-
const buildFile = await tempBuildContext.getBuildFileContents();
176-
buildFile.generatePortal!.languageConfig = getLanguagesConfig(languages);
177-
await tempBuildContext.updateBuildFileContents(buildFile);
178-
204+
// Copy the template into the final destination
179205
const sourceDirectory = inputDirectory.join('src');
180206
await this.fileService.copyDirectoryContents(extractedFolder, sourceDirectory);
181207

208+
// Update the build file in its final location via BuildContext,
209+
// mirroring exactly how CopilotAction reads and writes the build file
210+
const buildContext = new BuildContext(sourceDirectory);
211+
const baseConfig = (await buildContext.getBuildFileContents()).withPortalLanguages(languages);
212+
const buildConfig = copilotKey
213+
? baseConfig.withApiCopilotForPortal(copilotKey, DEFAULT_COPILOT_WELCOME_MESSAGE, defaultBaseUrl)
214+
: baseConfig;
215+
await buildContext.updateBuildFileContents(buildConfig);
216+
182217
const specDirectory = sourceDirectory.join('spec');
183218
const specContext = new SpecContext(specDirectory);
184219
await specContext.replaceDefaultSpec(specPath);
@@ -199,4 +234,4 @@ export class PortalQuickstartAction {
199234
return ActionResult.success();
200235
});
201236
};
202-
}
237+
}

src/actions/portal/serve.ts

Lines changed: 83 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import { Server } from "node:http";
2+
import { err, ok, Result } from "neverthrow";
13
import { createServer as createLiveReloadServer } from "livereload";
24
import connectLiveReload from "connect-livereload";
35
import express, { Express } from "express";
@@ -11,6 +13,7 @@ import { NetworkService } from "../../infrastructure/network-service.js";
1113
import { UrlPath } from "../../types/file/urlPath.js";
1214
import { LauncherService } from "../../infrastructure/launcher-service.js";
1315
import { DebounceService } from "../../infrastructure/debounce-service.js";
16+
import { BuildContext } from "../../types/build-context.js";
1417

1518
export class PortalServeAction {
1619
private readonly prompts: PortalServePrompts = new PortalServePrompts();
@@ -35,28 +38,68 @@ export class PortalServeAction {
3538
hotReload: boolean,
3639
onAfterServe?: () => void
3740
): Promise<ActionResult> {
38-
const generatePortalAction = new GenerateAction(this.configDir, this.commandMetadata, this.authKey);
39-
const result = await generatePortalAction.execute(buildDirectory, portalDirectory, true, false);
40-
if (result.isFailed()) {
41+
const buildContext = new BuildContext(buildDirectory);
42+
if (!(await buildContext.exists())) {
43+
this.prompts.noPortalSource(buildDirectory);
44+
return ActionResult.failed();
45+
}
46+
let buildConfig;
47+
try {
48+
buildConfig = await buildContext.getBuildFileContents();
49+
} catch {
50+
this.prompts.invalidBuildConfig(buildDirectory);
4151
return ActionResult.failed();
4252
}
4353

4454
const servePort = await this.networkService.getServerPort([port, 3000, 3001, 3002]);
45-
if (servePort != port && !onAfterServe) {
55+
if (servePort != port) {
4656
this.prompts.usingFallbackPort(port, servePort);
4757
}
58+
const serveUrl = new UrlPath(`http://localhost:${servePort}`);
59+
60+
// Update the configured localhost base URL to the actual serve URL BEFORE
61+
// generation bakes it into the portal artifacts; otherwise the portal would load
62+
// its content from the wrong port and fail to render.
63+
const updatedBuildConfig = buildConfig.updateBuildConfigBaseUrl(serveUrl);
64+
if (updatedBuildConfig !== buildConfig) {
65+
await buildContext.updateBuildFileContents(updatedBuildConfig);
66+
this.prompts.baseUrlPortUpdated(serveUrl);
67+
}
68+
69+
const generatePortalAction = new GenerateAction(this.configDir, this.commandMetadata, this.authKey);
70+
const result = await generatePortalAction.execute(buildDirectory, portalDirectory, true, false);
71+
if (result.isFailed()) {
72+
return ActionResult.failed();
73+
}
4874

4975
const liveReloadPort = await this.networkService.getServerPort([35729, 35730, 35731, 35732]);
5076
const liveReloadServer = createLiveReloadServer({ port: liveReloadPort });
77+
78+
// livereload attaches its "error" handler to the inner WebSocket server, not to the
79+
// HTTP server it binds the port on, so a failed bind (e.g. the port was taken in the
80+
// gap since getServerPort) emits an unhandled "error" that would crash the process.
81+
// Guard the HTTP server the same way as the main server below.
82+
const liveReloadHttpServer = (liveReloadServer as unknown as { config: { server: Server } }).config.server;
83+
if ((await this.waitForServerListening(liveReloadHttpServer)).isErr()) {
84+
liveReloadServer.close();
85+
this.prompts.serverStartFailed(liveReloadPort);
86+
return ActionResult.failed();
87+
}
88+
5189
const server = this.application
5290
.use(connectLiveReload())
5391
.use(express.static(portalDirectory.toString(), { extensions: ["html"] }))
5492
.listen(servePort);
5593

56-
const portalUrl = new UrlPath(`http://localhost:${servePort}`);
57-
this.prompts.portalServed(portalUrl);
94+
if ((await this.waitForServerListening(server)).isErr()) {
95+
liveReloadServer.close();
96+
this.prompts.serverStartFailed(servePort);
97+
return ActionResult.failed();
98+
}
99+
100+
this.prompts.portalServed(serveUrl);
58101
if (openInBrowser) {
59-
await this.launcherService.openUrlInBrowser(portalUrl);
102+
await this.launcherService.openUrlInBrowser(serveUrl);
60103
}
61104
this.prompts.promptForExit();
62105

@@ -102,6 +145,22 @@ export class PortalServeAction {
102145

103146
await debounceService.batchSingleRequest(async () => {
104147
this.prompts.changesDetected();
148+
// Re-reconcile on every hot-reload cycle: portalSettings.baseUrl takes
149+
// precedence over generatePortal.baseUrl and may have been added or changed
150+
// since serve started. If a mismatch is found the file is rewritten, which
151+
// triggers a second watcher event — the debounce queues it, and the second
152+
// cycle sees no mismatch and generates cleanly.
153+
try {
154+
const latestConfig = await buildContext.getBuildFileContents();
155+
const reconciledConfig = latestConfig.updateBuildConfigBaseUrl(serveUrl);
156+
if (reconciledConfig !== latestConfig) {
157+
await buildContext.updateBuildFileContents(reconciledConfig);
158+
this.prompts.baseUrlPortUpdated(serveUrl);
159+
}
160+
} catch {
161+
// Build file temporarily unreadable (e.g. mid-save); skip reconciliation
162+
// this cycle. GenerateAction will surface the error if the file is broken.
163+
}
105164
await generatePortalAction.execute(buildDirectory, portalDirectory, true, false, false);
106165
liveReloadServer.refresh(portalDirectory.toString());
107166
this.clearStandardInput();
@@ -122,6 +181,23 @@ export class PortalServeAction {
122181
return ActionResult.success();
123182
}
124183

184+
// Resolves ok once the server is bound, or err with the bind error (e.g. EADDRINUSE)
185+
// so a failed listen is reported cleanly instead of crashing via an unhandled "error".
186+
private waitForServerListening(server: Server): Promise<Result<void, Error>> {
187+
return new Promise((resolve) => {
188+
const onListening = () => {
189+
server.removeListener("error", onError);
190+
resolve(ok(undefined));
191+
};
192+
const onError = (error: Error) => {
193+
server.removeListener("listening", onListening);
194+
resolve(err(error));
195+
};
196+
server.once("listening", onListening);
197+
server.once("error", onError);
198+
});
199+
}
200+
125201
// This clears the standard input to allow interrupts like CTRL+C to work properly.
126202
private clearStandardInput() {
127203
if (process.platform !== "darwin" && process.stdin.isTTY) {

src/actions/portal/toc/new-toc.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,7 @@ export class PortalNewTocAction {
5353
return ActionResult.failed();
5454
}
5555
const buildConfig = await buildContext.getBuildFileContents();
56-
const contentDirectory = buildDirectory.join(buildConfig.generatePortal?.contentFolder ?? 'content');
56+
const contentDirectory = buildDirectory.join(buildConfig.contentFolder());
5757

5858
const tocDir = tocDirectory ?? contentDirectory;
5959
const tocContext = new TocContext(tocDir);

src/application/portal/recipe/recipe-generator.ts

Lines changed: 5 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -77,34 +77,12 @@ export class PortalRecipeGenerator {
7777
recipeScriptFileName: FileName
7878
): Promise<void> {
7979
const buildConfig = await buildContext.getBuildFileContents();
80-
if (!buildConfig.recipes) {
81-
buildConfig.recipes = {};
82-
}
83-
const recipesConfig = buildConfig.recipes as any;
84-
85-
if (!recipesConfig.workflows) {
86-
recipesConfig.workflows = [];
87-
}
88-
const existingIndex = recipesConfig.workflows.findIndex(
89-
(workflow: any) => workflow.permalink === `page:recipes/${this.toPascalCase(recipeName)}`
80+
const updatedBuildConfig = buildConfig.withRecipeWorkflow(
81+
recipeName,
82+
this.toPascalCase(recipeName),
83+
`./static/scripts/recipes/${recipeScriptFileName}`
9084
);
91-
92-
const newWorkflow = {
93-
name: recipeName,
94-
permalink: `page:recipes/${this.toPascalCase(recipeName)}`,
95-
functionName: this.toPascalCase(recipeName),
96-
scriptPath: `./static/scripts/recipes/${recipeScriptFileName}`
97-
};
98-
99-
if (existingIndex !== -1) {
100-
// Replace the existing workflow
101-
recipesConfig.workflows[existingIndex] = newWorkflow;
102-
} else {
103-
// Add as new workflow
104-
recipesConfig.workflows.push(newWorkflow);
105-
}
106-
107-
await buildContext.updateBuildFileContents(buildConfig);
85+
await buildContext.updateBuildFileContents(updatedBuildConfig);
10886
}
10987

11088
private async createMarkdownFile(recipeMarkdownFileName: FileName, contentFolder: DirectoryPath): Promise<void> {

src/commands/portal/serve.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,8 +15,8 @@ export default class PortalServe extends Command {
1515
port: Flags.integer({
1616
char: "p",
1717
description: "port to serve the portal.",
18-
default: 3000,
19-
helpValue: "3000"
18+
default: 23513,
19+
helpValue: "23513"
2020
}),
2121
...FlagsProvider.input,
2222
...FlagsProvider.destination("portal", "portal"),
@@ -38,7 +38,7 @@ export default class PortalServe extends Command {
3838
`${this.cmdTxt} ` +
3939
`${format.flag("input", './')} ` +
4040
`${format.flag("destination", './portal')} ` +
41-
`${format.flag("port", "3000")} ` +
41+
`${format.flag("port", "23513")} ` +
4242
`${format.flag("open")} ` +
4343
`${format.flag("no-reload")}`
4444
];

0 commit comments

Comments
 (0)