diff --git a/src/actions/portal/copilot.ts b/src/actions/portal/copilot.ts index cb126ff5..4e8fc810 100644 --- a/src/actions/portal/copilot.ts +++ b/src/actions/portal/copilot.ts @@ -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; @@ -16,7 +23,7 @@ export class CopilotAction { this.authKey = authKey; } - public async execute(buildDirectory: DirectoryPath, enable: boolean): Promise { + public async execute(buildDirectory: DirectoryPath, force: boolean, enable: boolean): Promise { const buildContext = new BuildContext(buildDirectory); if (!(await buildContext.validate())) { @@ -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, @@ -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 { + private async selectCopilotKey(subscription: SubscriptionInfo | undefined, force: boolean): Promise { 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 { + 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"); + }); } } diff --git a/src/actions/portal/generate.ts b/src/actions/portal/generate.ts index 112b5c41..60f39e34 100644 --- a/src/actions/portal/generate.ts +++ b/src/actions/portal/generate.ts @@ -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; @@ -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. " + diff --git a/src/commands/portal/copilot.ts b/src/commands/portal/copilot.ts index 5083b35c..e907d12f 100644 --- a/src/commands/portal/copilot.ts +++ b/src/commands/portal/copilot.ts @@ -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 }; @@ -30,13 +31,13 @@ export default class PortalCopilotEnable extends Command { async run(): Promise { 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)); } } diff --git a/src/infrastructure/file-service.ts b/src/infrastructure/file-service.ts index 6e0de34d..a32beb54 100644 --- a/src/infrastructure/file-service.ts +++ b/src/infrastructure/file-service.ts @@ -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 { @@ -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) { @@ -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 { - 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); diff --git a/src/infrastructure/launcher-service.ts b/src/infrastructure/launcher-service.ts new file mode 100644 index 00000000..5570f8be --- /dev/null +++ b/src/infrastructure/launcher-service.ts @@ -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 { + 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 { + 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 + } + } +} diff --git a/src/prompts/portal/copilot.ts b/src/prompts/portal/copilot.ts index e4e5d4f3..f303c3e8 100644 --- a/src/prompts/portal/copilot.ts +++ b/src/prompts/portal/copilot.ts @@ -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 { + 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 { const selectedKey = await select({ message: "Select the ID for the API Copilot you would like to add to this API Portal:", maxItems: 10, @@ -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 { 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 }); @@ -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 { - const welcomeMessage = await text({ - message: "Enter a welcome message for your API Copilot:", - placeholder: "Enter your welcome message here..." + public async spinnerAccountInfo(fn: () => Promise>) { + return this.withSpinner( + "Retrieving your subscription info", + "Retrieved subscription info", + "Error retrieving subscription info", + fn + ); + } + + private async withSpinner(intro: string, success: string, failure: string, fn: () => Promise>) { + 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; } } diff --git a/src/types/file/fileName.ts b/src/types/file/fileName.ts index 6f86d457..53b29129 100644 --- a/src/types/file/fileName.ts +++ b/src/types/file/fileName.ts @@ -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 {