Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
5d50968
feat(quickstart): prune build file to subscription before generating …
sohail2721 Jul 14, 2026
adaa8ae
feat: surface API 401 error message with login suggestion in portal g…
MuhammadRafay1 Jul 14, 2026
d5f9fe0
refactor: centralize SDK ApiError-to-ServiceError mapping in handleSe…
sohail2721 Jul 15, 2026
82746fd
refactor: extract mapApiError to reduce handleServiceError complexity
sohail2721 Jul 20, 2026
948e8aa
refactor: centralize ProblemDetails mapping to remove duplicated catc…
sohail2721 Jul 20, 2026
177de58
feat: AI-first messaging and Copilot-aware portal quickstart next steps
MuhammadRafay1 Jul 21, 2026
d0bb8e1
refactor: refine AI-first copy accuracy and revert generated README
MuhammadRafay1 Jul 22, 2026
996f8f5
refactor: disambiguate unauthorized factory and harden ProblemDetails…
MuhammadRafay1 Jul 23, 2026
376fd2b
Merge pull request #297 from apimatic/error-message-enhancement
MuhammadRafay1 Jul 27, 2026
e522c1c
feat: lead with Context Plugins in quickstart and portal messaging
MuhammadRafay1 Jul 27, 2026
c1f3297
Merge pull request #299 from apimatic/ai-first-messaging
MuhammadRafay1 Jul 27, 2026
a1ab840
fix: surface actionable auth hint on validate, transform and publishi…
MuhammadRafay1 Jul 28, 2026
2993889
fix: report logged-out state correctly in auth status
MuhammadRafay1 Jul 28, 2026
86e9ff5
Merge pull request #300 from apimatic/error-message-enhancement
MuhammadRafay1 Jul 29, 2026
8bb35eb
fix: report a rejected auth key accurately on login
MuhammadRafay1 Jul 30, 2026
2ad98cd
fix: point the auth hint at the command that accepts the key
MuhammadRafay1 Jul 30, 2026
d789545
Merge pull request #302 from apimatic/error-message-enhancement
MuhammadRafay1 Jul 30, 2026
5332a99
fix: surface TOC extraction failures instead of writing a partial toc…
MuhammadRafay1 Aug 3, 2026
0871f65
fix: guard the 401 body parse on the transformation error path
MuhammadRafay1 Aug 4, 2026
c1e826c
fix: fail when --expand-* flags cannot be honoured without a spec
MuhammadRafay1 Aug 4, 2026
8432629
fix: require a spec directory for portal toc new
MuhammadRafay1 Aug 4, 2026
9a56786
fix: report the build directory in the toc spec errors
MuhammadRafay1 Aug 4, 2026
d6556c6
refactor: move discardStreamBody to utils and simplify its call sites
saeedjamshaid Aug 5, 2026
191642f
refactor: reuse BuildContext.getSpecContext in portal toc new
saeedjamshaid Aug 5, 2026
d73c5c3
fix: name the build directory correctly in the empty spec error
saeedjamshaid Aug 5, 2026
15b9cc7
docs: correct the ordering claim in handleServiceError
saeedjamshaid Aug 5, 2026
ad229c8
test: cover discardStreamBody and the spec directory requirement
saeedjamshaid Aug 5, 2026
60245b0
style: drop a redundant local and an unneeded union in the cast
saeedjamshaid Aug 5, 2026
b2429e8
docs: add branching and comment conventions to .ai/instructions.md (#…
mrafnadeem-apimatic Aug 5, 2026
e823b4d
fix: report every validation error and extract shared generation stat…
mrafnadeem-apimatic Aug 5, 2026
5f19ef3
Merge pull request #303 from apimatic/toc-command-bug-fix
mehnoorsiddiqui Aug 5, 2026
0a2ef4b
Merge branch 'main' into dev
saeedjamshaid Aug 6, 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
11 changes: 11 additions & 0 deletions .ai/instructions.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,8 +67,19 @@ Supporting: **Types** (`src/types/`) for value objects, context objects, and dom
- **Commands use `export default class`** — oclif requires default export; actions, prompts, and services use named exports (`export class`)
- **Static fields use `readonly`** — `static readonly summary`, `static readonly description`, `static readonly cmdTxt` on every Command
- **Topic separator is space** — `apimatic portal generate`, not `apimatic portal:generate`
- **No AI-generated comments** — comment only to explain non-obvious *why*: a constraint, a workaround, a subtle invariant. Never restate what the code already says, narrate a change, or leave notes addressed to a future agent. Default to no comment; existing comment density in nearby code is never a reason to add more.
- **Telemetry** — After `outro(result)`, commands optionally track failures via `result.mapAll(() => {}, async () => { await new TelemetryService(configDir).trackEvent(new SomeFailedEvent(...), shell) }, () => {})`. Event classes extend `DomainEvent` (`src/types/events/`). Only the failure callback is populated; success/cancel are no-ops.

## Branching

Always start work from `dev` — never from `main`. This applies to branches and worktrees alike.

- Make sure `dev` is current (`git fetch origin dev`) and branch from `origin/dev`.
- Open pull requests against `dev`.
- Never commit to, branch from, or target `main` directly. If a task appears to require it, stop and ask.

**Worktrees** — a new worktree starts from this repo's default branch, `beta`, so move it onto `dev` before making any changes: `git fetch origin dev && git reset --hard origin/dev` (fresh, clean worktrees only). Confirm with `git log --oneline -1` that HEAD matches the `origin/dev` tip.

## Commit Conventions

Uses [Conventional Commits](https://www.conventionalcommits.org/) enforced by commitlint + husky. Pre-commit runs lint-staged (ESLint + Prettier).
Expand Down
3 changes: 2 additions & 1 deletion .ai/skills/service.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ Services live at `src/infrastructure/services/` and are the only layer that make

- Instantiate controller per method call: `new {ControllerName}(client)` inside the method.
- `apiClientFactory.createApiClient(authHeader, shell)` provides the configured client.
- For async/polling SDK methods, poll until status is terminalsee `portal-service.ts` for the pattern.
- For async/polling SDK methods, don't hand-roll the poll loopreuse `pollUntilCompleted()` in `portal-service.ts`, passing the poll interval as its first argument. Add a `static readonly` instance to `GenerationStatusEndpoint` for the new `{basePath}/{requestId}/status` endpoint and pass it to `ApiService.getGenerationStatus()`. Only supply a `ValidationErrorFormatter` when the endpoint needs custom validation wording (see `formatSdkValidationError`).

### Axios-auth variant rules

Expand Down Expand Up @@ -72,6 +72,7 @@ Services live at `src/infrastructure/services/` and are the only layer that make
| Pattern | File |
|---|---|
| SDK controller + async polling | `src/infrastructure/services/portal-service.ts` |
| Pollable generation endpoints | `src/types/api/generation-status-endpoint.ts` |
| SDK controller + FormData | `src/infrastructure/services/validation-service.ts` |
| Raw axios with auth + axiosInstance | `src/infrastructure/services/api-service.ts` |
| Raw axios with different base URL | `src/infrastructure/services/auth-service.ts` |
Expand Down
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

This file provides guidance to Claude Code when working in this repository.

Read `.ai/instructions.md` for full project instructions (architecture, conventions, testing, commits).
Read `.ai/instructions.md` for full project instructions (architecture, conventions, branching, testing, commits).

## Skills

Expand Down
6 changes: 5 additions & 1 deletion src/actions/auth/status.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,11 @@ export class StatusAction {

public async execute(authKey: string | null): Promise<ActionResult> {
const accountInfo = await getAuthInfo(this.configDir.toString());
if (accountInfo === null) {
// `auth logout` blanks config.json rather than deleting it, so a logged-out user still
// has a non-null AuthInfo with an empty key. Checking the key catches both that and a
// missing config file, and avoids a request that can only come back 401.
if (!accountInfo?.authKey) {
this.prompts.notLoggedIn();
return ActionResult.failed();
}
const result = await this.prompts.accountInfoSpinner(
Expand Down
2 changes: 1 addition & 1 deletion src/actions/portal/quickstart.ts
Original file line number Diff line number Diff line change
Expand Up @@ -260,7 +260,7 @@ export class PortalQuickstartAction {
const portalDirectory = inputDirectory.join('portal');
const portalServeAction = new PortalServeAction(this.configDir, this.commandMetadata, null);
const result = await portalServeAction.execute(sourceDirectory, portalDirectory, defaultPort, true, false, () => {
this.prompts.nextSteps();
this.prompts.nextSteps(prunedConfig.hasAiIntegration());
});

if (result.isFailed()) {
Expand Down
49 changes: 31 additions & 18 deletions src/actions/portal/toc/new-toc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ import {
import { withDirPath } from '../../../infrastructure/tmp-extensions.js';
import { TempContext } from '../../../types/temp-context.js';
import { PortalService } from '../../../infrastructure/services/portal-service.js';
import { err, ok, Result } from 'neverthrow';
import { ServiceError } from '../../../infrastructure/service-error.js';

export class ContentContext {
private readonly fileService = new FileService();
Expand Down Expand Up @@ -52,6 +54,17 @@ export class PortalNewTocAction {
this.prompts.invalidBuildDirectory(buildDirectory);
return ActionResult.failed();
}

const specDirectory = buildDirectory.join('spec');
if (!(await this.fileService.directoryExists(specDirectory))) {
this.prompts.specDirectoryNotFound(buildDirectory);
return ActionResult.failed();
}
if (!(await buildContext.getSpecContext().validate())) {
this.prompts.specDirectoryEmpty(buildDirectory);
return ActionResult.failed();
}

const buildConfig = await buildContext.getBuildFileContents();
const contentDirectory = buildDirectory.join(buildConfig.contentFolder());

Expand All @@ -63,34 +76,34 @@ export class PortalNewTocAction {
return ActionResult.cancelled();
}

const tocComponents: TocComponents = await (async () => {
const specDirectory = buildDirectory.join('spec');

if (!(await this.fileService.directoryExists(specDirectory))) {
this.prompts.fallingBackToDefault();
return TocComponents.empty();
}

return await withDirPath(async (tempDirectory) => {
const tempContext = new TempContext(tempDirectory);
const specZipPath = await tempContext.zip(specDirectory);
const specFileStream = await this.fileService.getStream(specZipPath);
const tocComponentsResult: Result<TocComponents, ServiceError> = await withDirPath(async (tempDirectory) => {
const tempContext = new TempContext(tempDirectory);
const specZipPath = await tempContext.zip(specDirectory);
const specFileStream = await this.fileService.getStream(specZipPath);
try {
const result = await this.prompts.extractTocData(
this.portalService.generateTocData(specFileStream, this.configDirectory, this.commandMetadata),
expandEndpoints,
expandModels,
expandWebhooks,
expandCallbacks
);
specFileStream.close();
if (result.isErr()) {
this.prompts.fallingBackToDefault();
return TocComponents.empty();
return err(result.error);
}

return TocComponents.fromTocData(result.value);
});
})();
return ok(TocComponents.fromTocData(result.value));
} finally {
specFileStream.close();
}
});

if (tocComponentsResult.isErr()) {
this.prompts.tocExtractionFailed(tocComponentsResult.error.errorMessage);
return ActionResult.failed();
}
const tocComponents = tocComponentsResult.value;

const contentContext = new ContentContext(contentDirectory);
const contentExists = await contentContext.exists();

Expand Down
2 changes: 1 addition & 1 deletion src/commands/portal/generate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { format, intro, outro } from "../../prompts/format.js";
import { CommandMetadata } from "../../types/common/command-metadata.js";

export class PortalGenerate extends Command {
static summary = "Generate an API Documentation portal";
static summary = "Generate Context Plugins and API Documentation Portal with AI Assist Features.";

static description =
"Generate an API Documentation portal. Requires an input directory containing API specifications, a config file and optionally, markdown guides. For details, refer to the [documentation](https://docs.apimatic.io/platform-api/#/http/guides/generating-on-prem-api-portal/build-file-reference)";
Expand Down
2 changes: 1 addition & 1 deletion src/commands/quickstart.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import { QuickstartCompletedEvent } from "../types/events/quickstart-completed.j
export default class Quickstart extends Command {
static description = "Get started with your first SDK or API Portal in four easy steps.";

static summary = "Create your first SDK or API Portal using APIMatic.";
static summary = "Create your first API Portal, Context Plugins and SDKs, or a standalone SDK, using APIMatic.";

static cmdTxt = format.cmd("apimatic", "quickstart");

Expand Down
53 changes: 53 additions & 0 deletions src/infrastructure/service-error.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import axios from "axios";
import { ApiError, ProblemDetailsError } from "@apimatic/sdk";
import { format as f } from "../prompts/format.js";
import { discardStreamBody } from "../utils/utils.js";

export enum ServiceErrorCode {
NotFound = "NOT_FOUND",
Expand Down Expand Up @@ -30,6 +32,17 @@
static notFound(customMessage: string): ServiceError {
return new ServiceError(ServiceErrorCode.NotFound, customMessage, {});
}
static unauthorizedWithHint(apiMessage: string | null): ServiceError {
// Both remedies name the full `auth login` command: the key is supplied to
// that command, not to whichever one hit the 401 — most of them don't accept
// an --auth-key flag at all.
const loginCommand = f.cmdAlt("apimatic", "auth", "login");
const message =
`${apiMessage ?? "Authorization has been denied for this request."} ` +
`Please run ${loginCommand} to log in via browser, ` +
`or provide a valid auth key using the ${loginCommand} ${f.flag("auth-key")}`;
return new ServiceError(ServiceErrorCode.UnAuthorized, message, {});
}

static readonly values: ServiceError[] = [
ServiceError.NotFound,
Expand All @@ -54,7 +67,47 @@
}
}

// A ProblemDetails body carries a `title` plus a map of field errors; surface
// the title and the first field message.
function mapProblemDetailsError(error: ProblemDetailsError): ServiceError | null {
// ProblemDetails.title and .errors are optional and result may be absent — guard
// all three so a sparse body can't crash or render a trailing "- null".
// TODO: This only picks the first error message, improve it to show all errors.

Check warning on line 75 in src/infrastructure/service-error.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Complete the task associated to this "TODO" comment.

See more on https://sonarcloud.io/project/issues?id=apimatic_apimatic-cli&issues=AZ_VbaD60o3HLYZsR19o&open=AZ_VbaD60o3HLYZsR19o&pullRequest=308
const errors = (error.result?.errors ?? {}) as Record<string, string[]>;
const firstFieldMessage = Object.values(errors)[0]?.[0];
const title = error.result?.title ?? "Request failed.";
const errorMessage = firstFieldMessage ? `${title}\n- ${firstFieldMessage}` : title;
if (error.statusCode === 400) return ServiceError.badRequest(errorMessage, errors);
if (error.statusCode === 403) return ServiceError.forbidden(errorMessage);
return null;
}

// SDK controllers throw typed `ApiError`s (not axios errors). The API reports
// the reason as {"message": "..."} deserialized into `result`.
function mapApiError(error: ApiError): ServiceError {
if (error instanceof ProblemDetailsError) {
const serviceError = mapProblemDetailsError(error);
if (serviceError) return serviceError;
}
if (error.statusCode === 401) {
const apiMessage = (error.result as { message?: string } | undefined)?.message ?? null;
return ServiceError.unauthorizedWithHint(apiMessage);
}
if (error.statusCode === 404) return ServiceError.NotFound;
return ServiceError.ServerError;
}

export function handleServiceError(error: unknown): ServiceError {
if (error instanceof ApiError) {
// A `callAsStream` error body would otherwise hang the CLI. Order against
// `mapApiError` is free: `error.result` is only populated when the SDK
// drained the body itself, so a live stream and a readable `result` never
// coexist.
const serviceError = mapApiError(error);
discardStreamBody(error.body);
return serviceError;
}

if (axios.isAxiosError(error)) {
const status = error.response?.status;
if (status === 401) return ServiceError.UnAuthorized;
Expand Down
91 changes: 23 additions & 68 deletions src/infrastructure/services/api-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,9 @@ import { SubscriptionInfo } from "../../types/api/account.js";
import { envInfo } from "../env-info.js";
import { err, ok, Result } from "neverthrow";
import { handleServiceError, ServiceError } from "../service-error.js";
import { PortalGenerationStatusResponse, SdkGenerationStatusResponse } from "@apimatic/sdk";
import { Status } from "@apimatic/sdk";
import { GenerationStatusEndpoint } from "../../types/api/generation-status-endpoint.js";
import { GenerationStatusResponse } from "../../types/api/generation-status.js";

export class ApiService {
private readonly apiBaseUrl = "https://api.apimatic.io" as const;
Expand Down Expand Up @@ -33,97 +35,50 @@ export class ApiService {
}
}

public async getPortalGenerationStatus(
/**
* Reads the status of one in-flight generation request. Shared by every
* async generation endpoint — see `GenerationStatusEndpoint`.
*/
public async getGenerationStatus(
endpoint: GenerationStatusEndpoint,
requestId: string,
configDir: DirectoryPath,
shell: string,
authKey: string | null
): Promise<Result<PortalGenerationStatusResponse, ServiceError>> {
): Promise<Result<GenerationStatusResponse, ServiceError>> {
const authInfo: AuthInfo | null = await getAuthInfo(configDir.toString());
if (authInfo === null && !authKey) {
return err(ServiceError.UnAuthorized);
}

try {
const token = authKey || authInfo?.authKey;
const response = await this.axiosInstance(shell, token).get(`/portal/v2/${requestId}/status`, {
const response = await this.axiosInstance(shell, token).get(`${endpoint}/${requestId}/status`, {
headers: { Accept: "application/json" },
maxRedirects: 0,
validateStatus: () => true
});

if (response.status === 200) {
return ok(response.data as PortalGenerationStatusResponse);
return ok(response.data as GenerationStatusResponse);
}

// Once generation finishes, the API redirects to the download location.
if (response.status === 302) {
return ok({ status: "Completed" } as PortalGenerationStatusResponse);
return ok({ status: Status.Completed });
}

return err(ServiceError.InvalidResponse);
} catch (error: unknown) {
return err(handleServiceError(error));
}
}

public async getSdkGenerationStatus(
requestId: string,
configDir: DirectoryPath,
shell: string,
authKey: string | null
): Promise<Result<SdkGenerationStatusResponse, ServiceError>> {
const authInfo: AuthInfo | null = await getAuthInfo(configDir.toString());
if (authInfo === null && !authKey) {
return err(ServiceError.UnAuthorized);
}

try {
const token = authKey || authInfo?.authKey;
const response = await this.axiosInstance(shell, token).get(`/sdk/${requestId}/status`, {
headers: { Accept: 'application/json' },
maxRedirects: 0,
validateStatus: () => true
});

if (response.status === 200) {
return ok(response.data as SdkGenerationStatusResponse);
}

if (response.status === 302) {
return ok({ status: 'Completed' } as SdkGenerationStatusResponse);
// `validateStatus` above stops axios throwing, so nothing reaches the
// catch block — classify the status here, or a mistyped endpoint path and
// an expired auth key both surface as a generic "unexpected error".
if (response.status === 401) {
return err(ServiceError.UnAuthorized);
}

return err(ServiceError.InvalidResponse);
} catch (error: unknown) {
return err(handleServiceError(error));
}
}

public async getV4SdkGenerationStatus(
requestId: string,
configDir: DirectoryPath,
shell: string,
authKey: string | null
): Promise<Result<SdkGenerationStatusResponse, ServiceError>> {
const authInfo: AuthInfo | null = await getAuthInfo(configDir.toString());
if (authInfo === null && !authKey) {
return err(ServiceError.UnAuthorized);
}

try {
const token = authKey || authInfo?.authKey;
const response = await this.axiosInstance(shell, token).get(`/sdk/v2/${requestId}/status`, {
headers: { Accept: 'application/json' },
maxRedirects: 0,
validateStatus: () => true
});

if (response.status === 200) {
return ok(response.data as SdkGenerationStatusResponse);
if (response.status === 404) {
return err(ServiceError.NotFound);
}

if (response.status === 302) {
return ok({ status: 'Completed' } as SdkGenerationStatusResponse);
if (response.status === 500) {
return err(ServiceError.ServerError);
}

return err(ServiceError.InvalidResponse);
Expand Down
Loading