Skip to content
62 changes: 49 additions & 13 deletions src/actions/portal/copilot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,16 @@ import { DirectoryPath } from "../../types/file/directoryPath.js";
import { ActionResult } from "../action-result.js";
import { SubscriptionInfo } from "../../types/api/account.js";
import { BuildContext } from "../../types/build-context.js";
import { withDirPath } from "../../infrastructure/tmp-extensions.js";
import { FilePath } from "../../types/file/filePath.js";
import { FileName } from "../../types/file/fileName.js";
import { FileService } from "../../infrastructure/file-service.js";
import { LauncherService } from "../../infrastructure/launcher-service.js";

export class CopilotAction {
private readonly apiService = new ApiService();
private readonly fileService = new FileService();
private readonly launcherService = new LauncherService();
private readonly prompts = new PortalCopilotPrompts();
private readonly configDir: DirectoryPath;
private readonly authKey: string | null;
Expand All @@ -16,7 +23,7 @@ export class CopilotAction {
this.authKey = authKey;
}

public async execute(buildDirectory: DirectoryPath, enable: boolean): Promise<ActionResult> {
public async execute(buildDirectory: DirectoryPath, force: boolean, enable: boolean): Promise<ActionResult> {
const buildContext = new BuildContext(buildDirectory);

if (!(await buildContext.validate())) {
Expand All @@ -25,21 +32,22 @@ export class CopilotAction {

const buildJson = await buildContext.getBuildFileContents();

if (buildJson.apiCopilotConfig != null && !(await this.prompts.confirmOverwrite()))
if (!force && buildJson.apiCopilotConfig != null && !(await this.prompts.confirmOverwrite()))
return ActionResult.error("Exiting without making any change.");

const response = await this.apiService.getAccountInfo(this.configDir, this.authKey);
const response = await this.prompts.spinnerAccountInfo(
() => this.apiService.getAccountInfo(this.configDir, this.authKey));

if (response.isErr()) {
return ActionResult.error(response._unsafeUnwrapErr());
}
const apiCopilotKey = await this.selectCopilotKey(response._unsafeUnwrap());
if (apiCopilotKey === null) {
return ActionResult.error("No copilot key found for the current subscription. Please contact support at support@apimatic.io.");

const apiCopilotKey = await this.selectCopilotKey(response._unsafeUnwrap(), force);
if (apiCopilotKey instanceof Error) {
return ActionResult.error(apiCopilotKey.message);
}

const welcomeMessage = await this.prompts.getWelcomeMessage();
if (welcomeMessage === undefined)
return ActionResult.error("Exiting without making any change.");
const welcomeMessage = await this.getWelcomeMessage();

buildJson.apiCopilotConfig = {
isEnabled: enable,
Expand All @@ -49,19 +57,47 @@ export class CopilotAction {

await buildContext.updateBuildFileContents(buildJson);

this.prompts.copilotConfigured(buildJson.apiCopilotConfig);
this.prompts.copilotConfigured(enable, apiCopilotKey);


return ActionResult.success();
}

private async selectCopilotKey(subscription: SubscriptionInfo | undefined): Promise<string | null> {
private async selectCopilotKey(subscription: SubscriptionInfo | undefined, force: boolean): Promise<string | Error> {
if (
subscription === undefined ||
subscription.ApiCopilotKeys === undefined ||
subscription.ApiCopilotKeys.length === 0
) {
return null;
return new Error("No copilot key found for the current subscription. Please contact support at support@apimatic.io.");
}

return await this.prompts.selectCopilotKey(subscription.ApiCopilotKeys);
if (subscription.ApiCopilotKeys.length === 1) {
if (force || (await this.prompts.confirmSingleKeyUsage(subscription.ApiCopilotKeys[0])))
return subscription.ApiCopilotKeys[0];
return new Error("Operation cancelled. No API Copilot key was selected.");
}

const key = await this.prompts.selectCopilotKey(subscription.ApiCopilotKeys);
if (key === null) return new Error("Operation cancelled. No API Copilot key was selected.");
await this.prompts.displayApiCopilotKeyUsageWarning();

return key;
}

private async getWelcomeMessage(): 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.launcherService.openInEditor(tempFile);
const welcomeMessage = await this.fileService.getContents(tempFile);
return welcomeMessage.replace(/\r\n|\r/g, "\n");
});
}
}
4 changes: 3 additions & 1 deletion src/actions/portal/generate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,13 @@ import { ActionResult } from "../action-result.js";
import { BuildContext } from "../../types/build-context.js";
import { PortalContext } from "../../types/portal-context.js";
import { withDirPath } from "../../infrastructure/tmp-extensions.js";
import { LauncherService } from "../../infrastructure/launcher-service.js";

export class GenerateAction {
private readonly prompts: PortalGeneratePrompts = new PortalGeneratePrompts();
private readonly zipArchiver: ZipService = new ZipService();
private readonly fileService: FileService = new FileService();
private readonly launcherService: LauncherService = new LauncherService();
private readonly portalService: PortalService = new PortalService();
private readonly configDir: DirectoryPath;
private readonly authKey: string | null;
Expand Down Expand Up @@ -86,7 +88,7 @@ export class GenerateAction {
const errorReportPath = portalDirectory.join("apimatic-debug");

const htmlFilePath = new FilePath(errorReportPath, new FileName("apimatic-report.html"));
await this.fileService.openFile(htmlFilePath); // Open the error report in the default browser
await this.launcherService.openFile(htmlFilePath); // Open the error report in the default browser

return (
"An error occurred during portal generation due to an issue with the input. " +
Expand Down
5 changes: 3 additions & 2 deletions src/commands/portal/copilot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ export default class PortalCopilotEnable extends Command {
default: false,
description: "marks the API Copilot as disabled in the configuration"
}),
...FlagsProvider.force,
...FlagsProvider.authKey
};

Expand All @@ -30,13 +31,13 @@ export default class PortalCopilotEnable extends Command {

async run(): Promise<void> {
const {
flags: { input, "auth-key": authKey, disable}
flags: { input, "auth-key": authKey, disable, force}
} = await this.parse(PortalCopilotEnable);

const workingDirectory = new DirectoryPath(input ?? DEFAULT_WORKING_DIRECTORY);
const buildDirectory = input ? new DirectoryPath(input, "src") : workingDirectory.join("src");
const copilotConfigAction = new CopilotAction(new DirectoryPath(this.config.configDir), authKey);
const result = await copilotConfigAction.execute(buildDirectory, !disable);
const result = await copilotConfigAction.execute(buildDirectory, force, !disable);
result.map((message) => this.prompts.logError(message));
}
}
34 changes: 1 addition & 33 deletions src/infrastructure/file-service.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,10 @@
import fs from "fs";
import fsExtra from "fs-extra";
import os from "os";
import * as path from "path";
import { FilePath } from "../types/file/filePath.js";
import { DirectoryPath } from "../types/file/directoryPath.js";
import { pipeline } from "stream";
import { promisify } from "util";
import { spawn } from "child_process";

export class FileService {
public async fileExists(file: FilePath): Promise<boolean> {
Expand Down Expand Up @@ -77,7 +75,7 @@ export class FileService {
}

public async writeContents(filePath: FilePath, contents: string) {
await fsExtra.writeFile(filePath.toString(), contents, 'utf-8');
await fsExtra.writeFile(filePath.toString(), contents, "utf-8");
}

public async writeBuffer(filePath: FilePath, buffer: Buffer) {
Expand All @@ -87,36 +85,6 @@ export class FileService {
public async copy(source: FilePath, destination: FilePath) {
await fsExtra.copyFile(source.toString(), destination.toString());
}

public async openFile(filePath: FilePath): Promise<void> {
const targetPath = filePath.toString();

// Determine the command and args without using the shell
let command: string;
let args: string[];

switch (os.platform()) {
case "win32":
command = "cmd";
args = ["/c", "start", "", targetPath];
break;
case "darwin":
command = "open";
args = [targetPath];
break;
default:
command = "xdg-open";
args = [targetPath];
break;
}

try {
const child = spawn(command, args, { stdio: "ignore", detached: true });
child.unref(); // Let it run without blocking
} catch {
// Silently ignore errors
}
}
}

const streamPipeline = promisify(pipeline);
50 changes: 50 additions & 0 deletions src/infrastructure/launcher-service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import { FilePath } from "../types/file/filePath.js";
import { execa } from "execa";
import os from "os";
import { spawn } from "child_process";

export class LauncherService {

public async openInEditor(filePath: FilePath): Promise<void> {
try {
await execa("code", ["--wait", filePath.toString()]);
} catch {
// TODO: check for fallback (start)
if (process.platform === "win32") {
await execa("cmd", ["/c", "start", "/wait", "notepad", filePath.toString()], { stdio: "ignore" });
} else if (process.platform === "darwin") {
await execa("vim", [filePath.toString()], { stdio: "inherit"});
}
}
}

public async openFile(filePath: FilePath): Promise<void> {
const targetPath = filePath.toString();

// Determine the command and args without using the shell
let command: string;
let args: string[];

switch (os.platform()) {
case "win32":
command = "cmd";
args = ["/c", "start", "", targetPath];
break;
case "darwin":
command = "open";
args = [targetPath];
break;
default:
command = "xdg-open";
args = [targetPath];
break;
}

try {
const child = spawn(command, args, { stdio: "ignore", detached: true });
child.unref(); // Let it run without blocking
} catch {
// Silently ignore errors
}
}
}
65 changes: 46 additions & 19 deletions src/prompts/portal/copilot.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,14 @@
import { select, cancel, isCancel, outro, confirm, log, text, } from "@clack/prompts";
import { CopilotConfig } from "../../types/build/build.js";
import { confirm, isCancel, log, outro, select, spinner } from "@clack/prompts";
import { getMessageInCyanColor } from "../../utils/utils.js";
import { Result } from "neverthrow";
import { SubscriptionInfo } from "../../types/api/account.js";
import { ServiceError } from "../../infrastructure/api-utils.js";

export class PortalCopilotPrompts {
public async selectCopilotKey(keys: string[]): Promise<string> {
public async displayApiCopilotKeyUsageWarning() {
log.warn('API Copilot can only be active on one Portal at a time. Configuring it on this Portal will disable it on any previously configured Portal.')
}
public async selectCopilotKey(keys: string[]): Promise<string | null> {
const selectedKey = await select({
message: "Select the ID for the API Copilot you would like to add to this API Portal:",
maxItems: 10,
Expand All @@ -14,30 +19,30 @@ export class PortalCopilotPrompts {
});

if (isCancel(selectedKey)) {
cancel("Operation cancelled.");
return process.exit(0);
return null;
}

return selectedKey;
}

public copilotConfigured(apiCopilotConfig: CopilotConfig) {
public copilotConfigured(status: boolean, copilotId: string): void {
outro(
`API Copilot configured successfully!

Copilot ID: ${getMessageInCyanColor(apiCopilotConfig.key)}
Welcome Message: ${getMessageInCyanColor(apiCopilotConfig.welcomeMessage)}
Status: ${getMessageInCyanColor(apiCopilotConfig.isEnabled ? "Enabled" : "Disabled")}
Copilot ID: ${getMessageInCyanColor(copilotId)}
Status: ${getMessageInCyanColor(status ? "Enabled" : "Disabled")}

Configuration saved to: APIMATIC-BUILD.json

Run 'apimatic portal:serve' to preview your API Portal and try out the API Copilot.`
API Copilot will index your content the next time you run \`apimatic portal:generate\` or \`apimatic portal:serve\`. This process can take up to 10 minutes, depending on your API’s size.

To see your copilot: If your portal is already running, refresh the page. Otherwise, run \`apimatic portal:serve\`, select any programming language in the Portal and look for the chat icon in the bottom-right corner.`
);
}

public async confirmOverwrite(): Promise<boolean> {
const shouldOverwrite = await confirm({
message: "API Copilot configuration already exists. Do you want to overwrite?",
message: "API Copilot is already configured for this Portal, do you want to overwrite it?",
initialValue: false
});

Expand All @@ -48,20 +53,42 @@ Run 'apimatic portal:serve' to preview your API Portal and try out the API Copil
return shouldOverwrite;
}

logError(error: string): void {
public logError(error: string): void {
log.error(error);
}

async getWelcomeMessage(): Promise<string | undefined> {
const welcomeMessage = await text({
message: "Enter a welcome message for your API Copilot:",
placeholder: "Enter your welcome message here..."
public async spinnerAccountInfo(fn: () => Promise<Result<SubscriptionInfo, ServiceError>>) {
return this.withSpinner(
"Retrieving your subscription info",
"Retrieved subscription info",
"Error retrieving subscription info",
fn
);
}

private async withSpinner<T, E>(intro: string, success: string, failure: string, fn: () => Promise<Result<T, E>>) {
const s = spinner();
s.start(intro);
const result = await fn();
result.match(
() => s.stop(success, 0),
() => s.stop(failure, 1)
);
return result;
}

public async confirmSingleKeyUsage(apiCopilotKey: string) {
const confirmKeyUsage = await confirm({
message:
"API Copilot can only be active on one Portal at a time. Configuring it on this Portal will disable it on any previously configured Portal.\n" +
`Do you want to use this key: '${apiCopilotKey}'?`,
initialValue: true
});

if (isCancel(welcomeMessage)) {
return undefined;
if (isCancel(confirmKeyUsage)) {
return false;
}

return welcomeMessage;
return confirmKeyUsage;
}
}
4 changes: 2 additions & 2 deletions src/types/file/fileName.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
export class FileName {
private readonly name: string;

constructor(path: string) {
this.name = path;
constructor(name: string) {
this.name = name;
}

public toString(): string {
Expand Down