From 5332a99740edf011c171b208613e71965a7a13d9 Mon Sep 17 00:00:00 2001 From: MuhammadRafay1 Date: Mon, 3 Aug 2026 15:20:49 +0500 Subject: [PATCH 01/11] fix: surface TOC extraction failures instead of writing a partial toc.yml `portal toc new` fell back to the default TOC whenever endpoint extraction failed, so a logged-out user (or any auth/network/spec error) got a toc.yml missing its Endpoints, Events and Models sections written over a possibly valid file, reported as success. Propagate the ServiceError instead: report the reason and leave the file alone. The no-spec-directory case is still an expected fallback, not a failure. The command also hung after printing its outro: stream endpoints hand back an undrained IncomingMessage on ApiError.body, whose open socket keeps the event loop alive since outro() only sets process.exitCode. Discard it centrally in handleServiceError, after the error has been mapped, plus in the two services that catch ApiError without going through it. Co-Authored-By: Claude Opus 5 (1M context) --- src/actions/portal/toc/new-toc.ts | 46 +++++++++++++------ src/infrastructure/service-error.ts | 23 +++++++++- .../services/transformation-service.ts | 21 ++++++--- .../services/validation-service.ts | 6 ++- src/prompts/portal/toc/new-toc.ts | 2 +- 5 files changed, 73 insertions(+), 25 deletions(-) diff --git a/src/actions/portal/toc/new-toc.ts b/src/actions/portal/toc/new-toc.ts index 5fe5b1db..364784a3 100644 --- a/src/actions/portal/toc/new-toc.ts +++ b/src/actions/portal/toc/new-toc.ts @@ -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(); @@ -63,34 +65,48 @@ export class PortalNewTocAction { return ActionResult.cancelled(); } - const tocComponents: TocComponents = await (async () => { + const tocComponentsResult: Result = await (async () => { const specDirectory = buildDirectory.join('spec'); + // No spec to extract from is an expected case, not a failure: the default + // TOC is the correct output. A failed extraction is not — see below. if (!(await this.fileService.directoryExists(specDirectory))) { this.prompts.fallingBackToDefault(); - return TocComponents.empty(); + return ok(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 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(); + try { + const result = await this.prompts.extractTocData( + this.portalService.generateTocData(specFileStream, this.configDirectory, this.commandMetadata), + expandEndpoints, + expandModels, + expandWebhooks, + expandCallbacks + ); + if (result.isErr()) { + return err(result.error); + } + + return ok(TocComponents.fromTocData(result.value)); + } finally { + specFileStream.close(); } - - return TocComponents.fromTocData(result.value); }); })(); + + // Falling back here would write a TOC missing its Endpoints, Events and + // Models sections over a possibly valid toc.yml, and report success. Surface + // the reason (auth, network, invalid spec) and leave the file alone. + 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(); diff --git a/src/infrastructure/service-error.ts b/src/infrastructure/service-error.ts index 840b31cd..c7fa310e 100644 --- a/src/infrastructure/service-error.ts +++ b/src/infrastructure/service-error.ts @@ -96,8 +96,29 @@ function mapApiError(error: ApiError): ServiceError { return ServiceError.ServerError; } +// Endpoints the SDK issues via `callAsStream` (TOC data, portal/SDK downloads, +// transformed files) hand back an undrained `IncomingMessage` on `ApiError.body` +// when they fail. Its socket stays open and keeps the Node event loop alive, so +// the CLI hangs after printing its outro — `outro()` only sets +// `process.exitCode` and we never call `process.exit()`. Discard the body once +// we've decided to map the error to a message. +// +// Callers that need the body read it *before* reaching here (see +// `PortalService.generatePortal`, which returns the 422 body as the error +// report). Non-stream bodies are strings or Blobs and have no `destroy`. +export function discardStreamBody(error: ApiError): void { + const body = error.body as { destroy?: () => void } | undefined; + if (typeof body?.destroy === "function") { + body.destroy(); + } +} + export function handleServiceError(error: unknown): ServiceError { - if (error instanceof ApiError) return mapApiError(error); + if (error instanceof ApiError) { + const serviceError = mapApiError(error); + discardStreamBody(error); + return serviceError; + } if (axios.isAxiosError(error)) { const status = error.response?.status; diff --git a/src/infrastructure/services/transformation-service.ts b/src/infrastructure/services/transformation-service.ts index 119d4268..faf66ead 100644 --- a/src/infrastructure/services/transformation-service.ts +++ b/src/infrastructure/services/transformation-service.ts @@ -17,7 +17,7 @@ import { apiClientFactory } from "./api-client-factory.js"; import { FilePath } from "../../types/file/filePath.js"; import { CommandMetadata } from "../../types/common/command-metadata.js"; import { err, ok, Result} from "neverthrow"; -import { ServiceError } from "../service-error.js"; +import { discardStreamBody, ServiceError } from "../service-error.js"; export interface TransformViaFileParams { file: FilePath; @@ -83,13 +83,20 @@ export class TransformationService { private readonly handleTransformationErrors = async (error: unknown): Promise => { if (error instanceof ApiError) { const apiError = error as ApiError; - if (apiError.statusCode === 400) { - return "Your API Definition is invalid. Please use the APIMatic VS Code Extension to fix the errors and try again."; - } else if (apiError.statusCode === 401) { - const message = JSON.parse(apiError.body as string).message; - return ServiceError.unauthorizedWithHint(message).errorMessage; + try { + if (apiError.statusCode === 400) { + return "Your API Definition is invalid. Please use the APIMatic VS Code Extension to fix the errors and try again."; + } else if (apiError.statusCode === 401) { + const message = JSON.parse(apiError.body as string).message; + return ServiceError.unauthorizedWithHint(message).errorMessage; + } + return `Error ${apiError.statusCode}: An error occurred during the transformation. Please try again or contact support@apimatic.io for assistance.`; + } finally { + // `downloadTransformedFile` is a stream endpoint: its error body is an + // undrained response that would keep the event loop alive. Released + // after the message is built, so the 401 branch can still read it. + discardStreamBody(apiError); } - return `Error ${apiError.statusCode}: An error occurred during the transformation. Please try again or contact support@apimatic.io for assistance.`; } else { return "An unexpected error occurred while validating your API Definition. Please try again later. If the problem persists, please reach out to our team at support@apimatic.io"; } diff --git a/src/infrastructure/services/validation-service.ts b/src/infrastructure/services/validation-service.ts index 462af9dd..e80dc231 100644 --- a/src/infrastructure/services/validation-service.ts +++ b/src/infrastructure/services/validation-service.ts @@ -20,7 +20,7 @@ import FormData from "form-data"; import { ZipService } from "../zip-service.js"; import { FileService } from "../file-service.js"; import { withDirPath } from "../tmp-extensions.js"; -import { handleServiceError, ServiceError } from "../service-error.js"; +import { discardStreamBody, handleServiceError, ServiceError } from "../service-error.js"; import axios from "axios"; import { envInfo } from "../env-info.js"; import { Buffer } from "node:buffer"; @@ -243,6 +243,10 @@ export class ValidationService { if (error instanceof ApiError) { const apiError = error as ApiError; + // A stream-endpoint error body is an undrained response that would keep + // the event loop alive and hang the CLI after its outro. + discardStreamBody(apiError); + switch (apiError.statusCode) { case 400: return "Your API Definition is invalid. Please fix the issues and try again."; diff --git a/src/prompts/portal/toc/new-toc.ts b/src/prompts/portal/toc/new-toc.ts index e4e8ea93..8a5d1285 100644 --- a/src/prompts/portal/toc/new-toc.ts +++ b/src/prompts/portal/toc/new-toc.ts @@ -29,7 +29,7 @@ export class PortalNewTocPrompts { log.error(`Please enter a different destination path or delete the existing toc.yml file and try again.`); } - public logError(message: string) { + public tocExtractionFailed(message: string) { log.error(message); } From 0871f65b599225221634a04b0a265de781b6c169 Mon Sep 17 00:00:00 2001 From: MuhammadRafay1 Date: Tue, 4 Aug 2026 14:27:59 +0500 Subject: [PATCH 02/11] fix: guard the 401 body parse on the transformation error path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `JSON.parse(apiError.body as string)` assumed a JSON string body. A stream endpoint hands back an `IncomingMessage` (stringified to "[object Object]"), an empty body parses to nothing, and an authenticating proxy can answer with an HTML error page — each throws a SyntaxError. It escaped `handleTransformationErrors` and the catch in `transformViaFile`, because the call sits inside the `err(await ...)` that catch builds, so it surfaced as an unhandled rejection: a raw stack trace instead of the message, no outro, wrong exit code, and no failure telemetry. Parse defensively and fall back to `unauthorizedWithHint`'s own default message, matching the safe-parse pattern already in `validation-service`. Co-Authored-By: Claude Opus 5 (1M context) --- .../services/transformation-service.ts | 20 +++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/src/infrastructure/services/transformation-service.ts b/src/infrastructure/services/transformation-service.ts index faf66ead..14785f8d 100644 --- a/src/infrastructure/services/transformation-service.ts +++ b/src/infrastructure/services/transformation-service.ts @@ -80,6 +80,23 @@ export class TransformationService { }; }; + /** + * A 401 body is not guaranteed to be JSON: a stream endpoint hands back an + * `IncomingMessage`, an empty body parses to nothing, and an authenticating + * proxy can answer with an HTML error page. Throwing here would escape the + * catch in `transformViaFile` — this runs inside the `err(await ...)` it + * builds — and surface as an unhandled rejection, skipping the outro and the + * failure telemetry. Fall back to the hint's own default message instead. + */ + private parseApiMessage(body: unknown): string | null { + if (typeof body !== "string") return null; + try { + return (JSON.parse(body) as { message?: string })?.message ?? null; + } catch { + return null; + } + } + private readonly handleTransformationErrors = async (error: unknown): Promise => { if (error instanceof ApiError) { const apiError = error as ApiError; @@ -87,8 +104,7 @@ export class TransformationService { if (apiError.statusCode === 400) { return "Your API Definition is invalid. Please use the APIMatic VS Code Extension to fix the errors and try again."; } else if (apiError.statusCode === 401) { - const message = JSON.parse(apiError.body as string).message; - return ServiceError.unauthorizedWithHint(message).errorMessage; + return ServiceError.unauthorizedWithHint(this.parseApiMessage(apiError.body)).errorMessage; } return `Error ${apiError.statusCode}: An error occurred during the transformation. Please try again or contact support@apimatic.io for assistance.`; } finally { From c1e826c9bb2308333b00dfa4d02f80a816f2e345 Mon Sep 17 00:00:00 2001 From: MuhammadRafay1 Date: Tue, 4 Aug 2026 15:32:44 +0500 Subject: [PATCH 03/11] fix: fail when --expand-* flags cannot be honoured without a spec MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The generator only enumerates endpoints, models and events when it has data: `getEndpointsSection` short-circuits on `data.size === 0` before it looks at the expand flag, and the models and events sections drop out entirely. So `portal toc new --expand-endpoints` against a build directory with no `spec/` wrote a collapsed TOC, ignored the flag and exited 0 — the flag help text documents the requirement but nothing enforced it. Fail instead when any expand flag is set and `spec/` is absent, naming the flags that cannot be honoured. Checked before the overwrite prompt so a run that cannot succeed does not ask to replace a file first. Unchanged: with no expand flags a missing `spec/` still falls back to the default TOC, since that is the correct output when there is nothing to expand. Auth is deliberately not part of this check — with no spec no request is made, so reporting an unauthorized error here would name a cause that did not occur. Co-Authored-By: Claude Opus 5 (1M context) --- src/actions/portal/toc/new-toc.ts | 28 ++++++++++++++++++++++++---- src/prompts/portal/toc/new-toc.ts | 11 +++++++++++ 2 files changed, 35 insertions(+), 4 deletions(-) diff --git a/src/actions/portal/toc/new-toc.ts b/src/actions/portal/toc/new-toc.ts index 364784a3..a7c47128 100644 --- a/src/actions/portal/toc/new-toc.ts +++ b/src/actions/portal/toc/new-toc.ts @@ -54,6 +54,27 @@ export class PortalNewTocAction { this.prompts.invalidBuildDirectory(buildDirectory); return ActionResult.failed(); } + + // The `--expand-*` flags enumerate endpoints, models and events in the TOC, + // which only the spec can supply. Without one the generator falls back to the + // collapsed `generate:` directives and the flags are dropped, so honour the + // documented requirement instead of reporting success for a TOC the user did + // not ask for. Checked before the overwrite prompt below so a run that cannot + // succeed asks nothing first. + const specDirectory = buildDirectory.join('spec'); + const specExists = await this.fileService.directoryExists(specDirectory); + const requestedExpansions = [ + expandEndpoints && 'expand-endpoints', + expandModels && 'expand-models', + expandWebhooks && 'expand-webhooks', + expandCallbacks && 'expand-callbacks' + ].filter((flag): flag is string => typeof flag === 'string'); + + if (!specExists && requestedExpansions.length > 0) { + this.prompts.expandFlagsRequireSpec(requestedExpansions, specDirectory); + return ActionResult.failed(); + } + const buildConfig = await buildContext.getBuildFileContents(); const contentDirectory = buildDirectory.join(buildConfig.contentFolder()); @@ -66,11 +87,10 @@ export class PortalNewTocAction { } const tocComponentsResult: Result = await (async () => { - const specDirectory = buildDirectory.join('spec'); - // No spec to extract from is an expected case, not a failure: the default - // TOC is the correct output. A failed extraction is not — see below. - if (!(await this.fileService.directoryExists(specDirectory))) { + // TOC is the correct output. A failed extraction is not — see below. Any + // `--expand-*` flag combined with a missing spec already failed above. + if (!specExists) { this.prompts.fallingBackToDefault(); return ok(TocComponents.empty()); } diff --git a/src/prompts/portal/toc/new-toc.ts b/src/prompts/portal/toc/new-toc.ts index 8a5d1285..b2a7363b 100644 --- a/src/prompts/portal/toc/new-toc.ts +++ b/src/prompts/portal/toc/new-toc.ts @@ -25,6 +25,17 @@ export class PortalNewTocPrompts { log.warn(`Falling back to the default TOC structure.`); } + public expandFlagsRequireSpec(flagNames: string[], specDirectory: DirectoryPath) { + const flags = flagNames.map((name) => f.flag(name)).join(", "); + const plural = flagNames.length > 1; + log.error( + `${flags} ${plural ? "require" : "requires"} an API specification to expand, ` + + `but no spec directory was found at ${f.path(specDirectory)}.\n` + + `Add your API specification there, or re-run without ${plural ? "the flags" : "the flag"} ` + + `to generate the default TOC.` + ); + } + public tocFileAlreadyExists() { log.error(`Please enter a different destination path or delete the existing toc.yml file and try again.`); } From 843262989f389ee090d4c3a4d710a65be8fc13f9 Mon Sep 17 00:00:00 2001 From: MuhammadRafay1 Date: Tue, 4 Aug 2026 17:11:05 +0500 Subject: [PATCH 04/11] fix: require a spec directory for portal toc new MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The spec is what the TOC is built from, and the next step — portal generation — needs it regardless, so a TOC generated without one only defers the failure to a later command. Drop the default-TOC fallback entirely and fail when the spec is unusable, whether or not any --expand-* flag was passed. This replaces the narrower flag-only gate added in c1e826c, which left a plain run still writing a collapsed toc.yml and exiting 0. Missing and empty are reported separately: SpecContext.validate() treats both as invalid, but only one of them is fixed by adding files to a directory that already exists. Auth is deliberately not part of this check. With no spec no request is made, so an unauthorized message here would name a cause that did not occur; a logged-out user is blocked on the spec first and on auth once a spec exists. Removes TocComponents.empty() and the fallingBackToDefault prompt, both now unreachable, and collapses the extraction IIFE now that it has a single path. Co-Authored-By: Claude Opus 5 (1M context) --- src/actions/portal/toc/new-toc.ts | 86 +++++++++++++------------------ src/prompts/portal/toc/new-toc.ts | 19 +++---- src/types/toc/toc-components.ts | 4 -- 3 files changed, 45 insertions(+), 64 deletions(-) diff --git a/src/actions/portal/toc/new-toc.ts b/src/actions/portal/toc/new-toc.ts index a7c47128..eb0d38af 100644 --- a/src/actions/portal/toc/new-toc.ts +++ b/src/actions/portal/toc/new-toc.ts @@ -13,6 +13,7 @@ import { import { withDirPath } from '../../../infrastructure/tmp-extensions.js'; import { TempContext } from '../../../types/temp-context.js'; import { PortalService } from '../../../infrastructure/services/portal-service.js'; +import { SpecContext } from '../../../types/spec-context.js'; import { err, ok, Result } from 'neverthrow'; import { ServiceError } from '../../../infrastructure/service-error.js'; @@ -55,23 +56,19 @@ export class PortalNewTocAction { return ActionResult.failed(); } - // The `--expand-*` flags enumerate endpoints, models and events in the TOC, - // which only the spec can supply. Without one the generator falls back to the - // collapsed `generate:` directives and the flags are dropped, so honour the - // documented requirement instead of reporting success for a TOC the user did - // not ask for. Checked before the overwrite prompt below so a run that cannot - // succeed asks nothing first. + // The spec is required, not optional: it is what the TOC is built from, and + // the next step — portal generation — needs it regardless. Writing a TOC + // without it only defers the failure to a later command, so fail here. + // Missing and empty are reported separately: `SpecContext.validate()` treats + // both as invalid, but only one of them is fixed by adding files to a + // directory that already exists. const specDirectory = buildDirectory.join('spec'); - const specExists = await this.fileService.directoryExists(specDirectory); - const requestedExpansions = [ - expandEndpoints && 'expand-endpoints', - expandModels && 'expand-models', - expandWebhooks && 'expand-webhooks', - expandCallbacks && 'expand-callbacks' - ].filter((flag): flag is string => typeof flag === 'string'); - - if (!specExists && requestedExpansions.length > 0) { - this.prompts.expandFlagsRequireSpec(requestedExpansions, specDirectory); + if (!(await this.fileService.directoryExists(specDirectory))) { + this.prompts.specDirectoryNotFound(specDirectory); + return ActionResult.failed(); + } + if (!(await new SpecContext(specDirectory).validate())) { + this.prompts.specDirectoryEmpty(specDirectory); return ActionResult.failed(); } @@ -86,41 +83,32 @@ export class PortalNewTocAction { return ActionResult.cancelled(); } - const tocComponentsResult: Result = await (async () => { - // No spec to extract from is an expected case, not a failure: the default - // TOC is the correct output. A failed extraction is not — see below. Any - // `--expand-*` flag combined with a missing spec already failed above. - if (!specExists) { - this.prompts.fallingBackToDefault(); - return ok(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); - try { - const result = await this.prompts.extractTocData( - this.portalService.generateTocData(specFileStream, this.configDirectory, this.commandMetadata), - expandEndpoints, - expandModels, - expandWebhooks, - expandCallbacks - ); - if (result.isErr()) { - return err(result.error); - } - - return ok(TocComponents.fromTocData(result.value)); - } finally { - specFileStream.close(); + const tocComponentsResult: Result = 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 + ); + if (result.isErr()) { + return err(result.error); } - }); - })(); - // Falling back here would write a TOC missing its Endpoints, Events and - // Models sections over a possibly valid toc.yml, and report success. Surface - // the reason (auth, network, invalid spec) and leave the file alone. + return ok(TocComponents.fromTocData(result.value)); + } finally { + specFileStream.close(); + } + }); + + // This used to fall back to the default TOC, which wrote a file missing its + // Endpoints, Events and Models sections over a possibly valid toc.yml and + // reported success. Surface the reason (auth, network, invalid spec) and + // leave the file alone. if (tocComponentsResult.isErr()) { this.prompts.tocExtractionFailed(tocComponentsResult.error.errorMessage); return ActionResult.failed(); diff --git a/src/prompts/portal/toc/new-toc.ts b/src/prompts/portal/toc/new-toc.ts index b2a7363b..01da9686 100644 --- a/src/prompts/portal/toc/new-toc.ts +++ b/src/prompts/portal/toc/new-toc.ts @@ -21,21 +21,18 @@ export class PortalNewTocPrompts { return overwrite; } - public fallingBackToDefault() { - log.warn(`Falling back to the default TOC structure.`); - } - - public expandFlagsRequireSpec(flagNames: string[], specDirectory: DirectoryPath) { - const flags = flagNames.map((name) => f.flag(name)).join(", "); - const plural = flagNames.length > 1; + public specDirectoryNotFound(directory: DirectoryPath) { log.error( - `${flags} ${plural ? "require" : "requires"} an API specification to expand, ` + - `but no spec directory was found at ${f.path(specDirectory)}.\n` + - `Add your API specification there, or re-run without ${plural ? "the flags" : "the flag"} ` + - `to generate the default TOC.` + `The ${f.var("spec")} directory was not found at ${f.path(directory)}.\n` + + `Add your API specification there and try again.` ); } + public specDirectoryEmpty(directory: DirectoryPath) { + const message = `The ${f.var("spec")} directory is either empty or invalid: ${f.path(directory)}`; + log.error(message); + } + public tocFileAlreadyExists() { log.error(`Please enter a different destination path or delete the existing toc.yml file and try again.`); } diff --git a/src/types/toc/toc-components.ts b/src/types/toc/toc-components.ts index 3b40ed5d..ff91010f 100644 --- a/src/types/toc/toc-components.ts +++ b/src/types/toc/toc-components.ts @@ -60,10 +60,6 @@ export class TocComponents { this.callbackGroups = callbackGroups; } - static empty(): TocComponents { - return new TocComponents(new Map(), [], [], [], [], [], new Map(), new Map()); - } - static fromTocData(tocData: TocData): TocComponents { return new TocComponents( TocComponents.toEndpointGroups(tocData.endpoints), From 9a5678677939fd495f550a72b30bef4f0fbf67bd Mon Sep 17 00:00:00 2001 From: MuhammadRafay1 Date: Tue, 4 Aug 2026 17:55:15 +0500 Subject: [PATCH 05/11] fix: report the build directory in the toc spec errors Both spec failures named the `spec/` path inside the build directory. Name the build directory instead: that is the directory the user passes with --input and works in, and it is what `sdk generate` already reports for the same failure. Reworded to "was not found in" to match, keeping the reviewed remedy line. Co-Authored-By: Claude Opus 5 (1M context) --- src/actions/portal/toc/new-toc.ts | 7 +++++-- src/prompts/portal/toc/new-toc.ts | 2 +- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/src/actions/portal/toc/new-toc.ts b/src/actions/portal/toc/new-toc.ts index eb0d38af..760daed3 100644 --- a/src/actions/portal/toc/new-toc.ts +++ b/src/actions/portal/toc/new-toc.ts @@ -62,13 +62,16 @@ export class PortalNewTocAction { // Missing and empty are reported separately: `SpecContext.validate()` treats // both as invalid, but only one of them is fixed by adding files to a // directory that already exists. + // Both messages report the build directory rather than the `spec/` path + // inside it: that is the directory the user works in, and it is what the + // sibling messages here and in `sdk generate` already name. const specDirectory = buildDirectory.join('spec'); if (!(await this.fileService.directoryExists(specDirectory))) { - this.prompts.specDirectoryNotFound(specDirectory); + this.prompts.specDirectoryNotFound(buildDirectory); return ActionResult.failed(); } if (!(await new SpecContext(specDirectory).validate())) { - this.prompts.specDirectoryEmpty(specDirectory); + this.prompts.specDirectoryEmpty(buildDirectory); return ActionResult.failed(); } diff --git a/src/prompts/portal/toc/new-toc.ts b/src/prompts/portal/toc/new-toc.ts index 01da9686..3a7f7c08 100644 --- a/src/prompts/portal/toc/new-toc.ts +++ b/src/prompts/portal/toc/new-toc.ts @@ -23,7 +23,7 @@ export class PortalNewTocPrompts { public specDirectoryNotFound(directory: DirectoryPath) { log.error( - `The ${f.var("spec")} directory was not found at ${f.path(directory)}.\n` + + `The ${f.var("spec")} directory was not found in ${f.path(directory)}.\n` + `Add your API specification there and try again.` ); } From d6556c6e160128a399e4f4c9e4aff6f866811f2f Mon Sep 17 00:00:00 2001 From: saeedjamshaid Date: Wed, 5 Aug 2026 13:01:05 +0500 Subject: [PATCH 06/11] refactor: move discardStreamBody to utils and simplify its call sites MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `service-error.ts` maps SDK and axios errors onto `ServiceError`; every other function in it is pure. Releasing a socket is resource lifecycle, not mapping, and its counterpart `parseStreamBodyToJson` already lives in `utils`. Move it there and take the body rather than the `ApiError`, since nothing about it is SDK-specific. Replace the `as { destroy?: () => void }` cast with an `isDestroyable` type guard. The cast was working around `destroy()` being absent from `NodeJS.ReadableStream` — it is declared on `stream.Readable` and `IncomingMessage` — which the original comment never stated. Drop the call in `validation-service.ts`. `validateApiViaFileV2` is a `callAsJson` endpoint with subclass `throwOn`s, so its body is always a string and the probe can never match. Simplify `handleTransformationErrors` to discard-then-switch. The `try/finally` existed so the 401 branch could read the body before it was released, but the two act on disjoint types — `parseApiMessage` only reads strings, `discardStreamBody` only destroys streams — so that ordering constraint does not exist. Nothing in the block throws, so there is no exceptional path to protect either. Also drops the redundant `as ApiError` cast and an `async` with nothing to await. Co-Authored-By: Claude Opus 5 (1M context) --- src/infrastructure/service-error.ts | 22 ++------- .../services/transformation-service.ts | 47 ++++++++----------- .../services/validation-service.ts | 6 +-- src/utils/utils.ts | 24 ++++++++++ 4 files changed, 49 insertions(+), 50 deletions(-) diff --git a/src/infrastructure/service-error.ts b/src/infrastructure/service-error.ts index c7fa310e..14d0ef85 100644 --- a/src/infrastructure/service-error.ts +++ b/src/infrastructure/service-error.ts @@ -1,6 +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", @@ -96,27 +97,12 @@ function mapApiError(error: ApiError): ServiceError { return ServiceError.ServerError; } -// Endpoints the SDK issues via `callAsStream` (TOC data, portal/SDK downloads, -// transformed files) hand back an undrained `IncomingMessage` on `ApiError.body` -// when they fail. Its socket stays open and keeps the Node event loop alive, so -// the CLI hangs after printing its outro — `outro()` only sets -// `process.exitCode` and we never call `process.exit()`. Discard the body once -// we've decided to map the error to a message. -// -// Callers that need the body read it *before* reaching here (see -// `PortalService.generatePortal`, which returns the 422 body as the error -// report). Non-stream bodies are strings or Blobs and have no `destroy`. -export function discardStreamBody(error: ApiError): void { - const body = error.body as { destroy?: () => void } | undefined; - if (typeof body?.destroy === "function") { - body.destroy(); - } -} - export function handleServiceError(error: unknown): ServiceError { if (error instanceof ApiError) { + // Mapped first so it can still read `error.result`, then the body is + // released — a `callAsStream` error body would otherwise hang the CLI. const serviceError = mapApiError(error); - discardStreamBody(error); + discardStreamBody(error.body); return serviceError; } diff --git a/src/infrastructure/services/transformation-service.ts b/src/infrastructure/services/transformation-service.ts index 14785f8d..b6a77d3a 100644 --- a/src/infrastructure/services/transformation-service.ts +++ b/src/infrastructure/services/transformation-service.ts @@ -17,7 +17,8 @@ import { apiClientFactory } from "./api-client-factory.js"; import { FilePath } from "../../types/file/filePath.js"; import { CommandMetadata } from "../../types/common/command-metadata.js"; import { err, ok, Result} from "neverthrow"; -import { discardStreamBody, ServiceError } from "../service-error.js"; +import { ServiceError } from "../service-error.js"; +import { discardStreamBody } from "../../utils/utils.js"; export interface TransformViaFileParams { file: FilePath; @@ -65,7 +66,7 @@ export class TransformationService { apiValidationSummary }); } catch (error) { - return err(await this.handleTransformationErrors(error)); + return err(this.handleTransformationErrors(error)); } } @@ -80,14 +81,6 @@ export class TransformationService { }; }; - /** - * A 401 body is not guaranteed to be JSON: a stream endpoint hands back an - * `IncomingMessage`, an empty body parses to nothing, and an authenticating - * proxy can answer with an HTML error page. Throwing here would escape the - * catch in `transformViaFile` — this runs inside the `err(await ...)` it - * builds — and surface as an unhandled rejection, skipping the outro and the - * failure telemetry. Fall back to the hint's own default message instead. - */ private parseApiMessage(body: unknown): string | null { if (typeof body !== "string") return null; try { @@ -97,24 +90,24 @@ export class TransformationService { } } - private readonly handleTransformationErrors = async (error: unknown): Promise => { - if (error instanceof ApiError) { - const apiError = error as ApiError; - try { - if (apiError.statusCode === 400) { - return "Your API Definition is invalid. Please use the APIMatic VS Code Extension to fix the errors and try again."; - } else if (apiError.statusCode === 401) { - return ServiceError.unauthorizedWithHint(this.parseApiMessage(apiError.body)).errorMessage; - } - return `Error ${apiError.statusCode}: An error occurred during the transformation. Please try again or contact support@apimatic.io for assistance.`; - } finally { - // `downloadTransformedFile` is a stream endpoint: its error body is an - // undrained response that would keep the event loop alive. Released - // after the message is built, so the 401 branch can still read it. - discardStreamBody(apiError); - } - } else { + private readonly handleTransformationErrors = (error: unknown): string => { + if (!(error instanceof ApiError)) { return "An unexpected error occurred while validating your API Definition. Please try again later. If the problem persists, please reach out to our team at support@apimatic.io"; } + + // `downloadTransformedFile` is a stream endpoint, so its error body is an + // undrained response that would keep the event loop alive. Discarding it + // first is safe: a string body (from `transformViaFile`) has no `destroy` + // and is left intact for `parseApiMessage` below. + discardStreamBody(error.body); + + switch (error.statusCode) { + case 400: + return "Your API Definition is invalid. Please use the APIMatic VS Code Extension to fix the errors and try again."; + case 401: + return ServiceError.unauthorizedWithHint(this.parseApiMessage(error.body)).errorMessage; + default: + return `Error ${error.statusCode}: An error occurred during the transformation. Please try again or contact support@apimatic.io for assistance.`; + } }; } diff --git a/src/infrastructure/services/validation-service.ts b/src/infrastructure/services/validation-service.ts index e80dc231..462af9dd 100644 --- a/src/infrastructure/services/validation-service.ts +++ b/src/infrastructure/services/validation-service.ts @@ -20,7 +20,7 @@ import FormData from "form-data"; import { ZipService } from "../zip-service.js"; import { FileService } from "../file-service.js"; import { withDirPath } from "../tmp-extensions.js"; -import { discardStreamBody, handleServiceError, ServiceError } from "../service-error.js"; +import { handleServiceError, ServiceError } from "../service-error.js"; import axios from "axios"; import { envInfo } from "../env-info.js"; import { Buffer } from "node:buffer"; @@ -243,10 +243,6 @@ export class ValidationService { if (error instanceof ApiError) { const apiError = error as ApiError; - // A stream-endpoint error body is an undrained response that would keep - // the event loop alive and hang the CLI after its outro. - discardStreamBody(apiError); - switch (apiError.statusCode) { case 400: return "Your API Definition is invalid. Please fix the issues and try again."; diff --git a/src/utils/utils.ts b/src/utils/utils.ts index 43b605f8..ec29aa4e 100644 --- a/src/utils/utils.ts +++ b/src/utils/utils.ts @@ -19,6 +19,30 @@ export async function parseStreamBodyToJson(body: NodeJS.ReadableStream): Promis return JSON.parse(text); } +// `destroy()` is not on `NodeJS.ReadableStream` — it lives on `stream.Readable` +// and `IncomingMessage` — so narrowing an `ApiError.body` union still won't let +// us call it. Probe for the method instead. +function isDestroyable(value: unknown): value is { destroy: () => void } { + return typeof (value as { destroy?: unknown } | undefined)?.destroy === "function"; +} + +// Counterpart to `parseStreamBodyToJson`: releases a response body we are never +// going to read. An `ApiError.body` from a `callAsStream` endpoint is an +// undrained `IncomingMessage` whose socket keeps the Node event loop alive, so +// the CLI hangs after printing its outro — `outro()` only sets `process.exitCode` +// and we never call `process.exit()`. +// +// The axios adapter yields a stream on Node and a Blob elsewhere, so the probe +// above is a real platform branch. `destroy()` is idempotent, making this safe +// on bodies the SDK has already drained via `loadResult`. Callers that need the +// body must read it *before* calling this (see `PortalService.generatePortal`, +// which returns the 422 body as the error report). +export function discardStreamBody(body: unknown): void { + if (isDestroyable(body)) { + body.destroy(); + } +} + export const toPascalCase = (str: string): string => { return str .split(" ") From 191642fd3c546f83d3cd4a61c4aff57d67138dd5 Mon Sep 17 00:00:00 2001 From: saeedjamshaid Date: Wed, 5 Aug 2026 13:01:11 +0500 Subject: [PATCH 07/11] refactor: reuse BuildContext.getSpecContext in portal toc new MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `BuildContext.getSpecContext()` is already `new SpecContext(buildDirectory.join("spec"))`, and `sdk generate` reaches the spec through it. Constructing one directly here put a second hardcoded `"spec"` literal in the tree while `buildContext` was already in scope. The local `specDirectory` stays — the extraction step still needs the path to zip. Also drops the explanatory comments above the spec checks and the extraction result branch; the reasoning is recorded in the PR description. Co-Authored-By: Claude Opus 5 (1M context) --- src/actions/portal/toc/new-toc.ts | 16 +--------------- 1 file changed, 1 insertion(+), 15 deletions(-) diff --git a/src/actions/portal/toc/new-toc.ts b/src/actions/portal/toc/new-toc.ts index 760daed3..63f8b601 100644 --- a/src/actions/portal/toc/new-toc.ts +++ b/src/actions/portal/toc/new-toc.ts @@ -13,7 +13,6 @@ import { import { withDirPath } from '../../../infrastructure/tmp-extensions.js'; import { TempContext } from '../../../types/temp-context.js'; import { PortalService } from '../../../infrastructure/services/portal-service.js'; -import { SpecContext } from '../../../types/spec-context.js'; import { err, ok, Result } from 'neverthrow'; import { ServiceError } from '../../../infrastructure/service-error.js'; @@ -56,21 +55,12 @@ export class PortalNewTocAction { return ActionResult.failed(); } - // The spec is required, not optional: it is what the TOC is built from, and - // the next step — portal generation — needs it regardless. Writing a TOC - // without it only defers the failure to a later command, so fail here. - // Missing and empty are reported separately: `SpecContext.validate()` treats - // both as invalid, but only one of them is fixed by adding files to a - // directory that already exists. - // Both messages report the build directory rather than the `spec/` path - // inside it: that is the directory the user works in, and it is what the - // sibling messages here and in `sdk generate` already name. const specDirectory = buildDirectory.join('spec'); if (!(await this.fileService.directoryExists(specDirectory))) { this.prompts.specDirectoryNotFound(buildDirectory); return ActionResult.failed(); } - if (!(await new SpecContext(specDirectory).validate())) { + if (!(await buildContext.getSpecContext().validate())) { this.prompts.specDirectoryEmpty(buildDirectory); return ActionResult.failed(); } @@ -108,10 +98,6 @@ export class PortalNewTocAction { } }); - // This used to fall back to the default TOC, which wrote a file missing its - // Endpoints, Events and Models sections over a possibly valid toc.yml and - // reported success. Surface the reason (auth, network, invalid spec) and - // leave the file alone. if (tocComponentsResult.isErr()) { this.prompts.tocExtractionFailed(tocComponentsResult.error.errorMessage); return ActionResult.failed(); From d73c5c37e9c521d5fe069433de4dcad06152ee92 Mon Sep 17 00:00:00 2001 From: saeedjamshaid Date: Wed, 5 Aug 2026 13:01:16 +0500 Subject: [PATCH 08/11] fix: name the build directory correctly in the empty spec error MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The message read "The `spec` directory is either empty or invalid: " but was passed the build directory, so the colon made that path read as the spec directory's own location — telling the user their build directory was the empty spec directory. Reword to "The `spec` directory in is either empty or invalid.", matching how the sibling `specDirectoryNotFound` message names it. Co-Authored-By: Claude Opus 5 (1M context) --- src/prompts/portal/toc/new-toc.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/prompts/portal/toc/new-toc.ts b/src/prompts/portal/toc/new-toc.ts index 3a7f7c08..9d1d7d38 100644 --- a/src/prompts/portal/toc/new-toc.ts +++ b/src/prompts/portal/toc/new-toc.ts @@ -29,7 +29,7 @@ export class PortalNewTocPrompts { } public specDirectoryEmpty(directory: DirectoryPath) { - const message = `The ${f.var("spec")} directory is either empty or invalid: ${f.path(directory)}`; + const message = `The ${f.var("spec")} directory in ${f.path(directory)} is either empty or invalid.`; log.error(message); } From 15b9cc72bc3b852282c52d6cdbad691744b0e5f8 Mon Sep 17 00:00:00 2001 From: saeedjamshaid Date: Wed, 5 Aug 2026 13:02:08 +0500 Subject: [PATCH 09/11] docs: correct the ordering claim in handleServiceError MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The comment said mapping runs first so it can still read `error.result`, implying a constraint that does not exist. `loadResult` is what populates `result`, and it does so by draining the body — so either the SDK drained it and `result` is readable, or it did not and `result` is `undefined` with a live stream on `body`. The two states never coexist, making the order free. Keep the ordering as-is and state why it does not matter, so the next reader does not preserve a dependency that was never real. Co-Authored-By: Claude Opus 5 (1M context) --- src/infrastructure/service-error.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/infrastructure/service-error.ts b/src/infrastructure/service-error.ts index 14d0ef85..d6af9802 100644 --- a/src/infrastructure/service-error.ts +++ b/src/infrastructure/service-error.ts @@ -99,8 +99,10 @@ function mapApiError(error: ApiError): ServiceError { export function handleServiceError(error: unknown): ServiceError { if (error instanceof ApiError) { - // Mapped first so it can still read `error.result`, then the body is - // released — a `callAsStream` error body would otherwise hang the CLI. + // 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; From ad229c8713b340a6b9d766cadee6c42bdd96a1ce Mon Sep 17 00:00:00 2001 From: saeedjamshaid Date: Wed, 5 Aug 2026 13:05:28 +0500 Subject: [PATCH 10/11] test: cover discardStreamBody and the spec directory requirement MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Neither behaviour introduced by this branch had a test. `discardStreamBody` is entirely a duck-type probe over a `string | Blob | NodeJS.ReadableStream` union, so the branches are the contract: it must destroy a stream, leave a string readable for the caller that parses it afterwards, ignore a Blob-like body, and stay quiet on an already-destroyed stream — the SDK drains error bodies itself on the `throwOn` path, so a finished body can reach it. `PortalNewTocAction` now fails instead of writing a default TOC when the spec is unusable. The three rejected cases — missing, empty, and dotfiles-only, which `FileService.directoryEmpty` also treats as empty — return before any request is made, so no service stubbing is needed. A fourth asserts nothing is written to the build directory, which is the regression that mattered: the old fallback overwrote a possibly valid toc.yml and exited 0. Co-Authored-By: Claude Opus 5 (1M context) --- test/actions/portal/toc/new-toc.test.ts | 69 +++++++++++++++++++++++++ test/utils/utils.test.ts | 51 ++++++++++++++++++ 2 files changed, 120 insertions(+) create mode 100644 test/actions/portal/toc/new-toc.test.ts create mode 100644 test/utils/utils.test.ts diff --git a/test/actions/portal/toc/new-toc.test.ts b/test/actions/portal/toc/new-toc.test.ts new file mode 100644 index 00000000..f1dbf932 --- /dev/null +++ b/test/actions/portal/toc/new-toc.test.ts @@ -0,0 +1,69 @@ +import * as path from "path"; +import fsExtra from "fs-extra"; +import { expect } from "chai"; +import { dir as tmpDir, DirectoryResult } from "tmp-promise"; +import { PortalNewTocAction } from "../../../../src/actions/portal/toc/new-toc.js"; +import { DirectoryPath } from "../../../../src/types/file/directoryPath.js"; +import { CommandMetadata } from "../../../../src/types/common/command-metadata.js"; + +const COMMAND_METADATA: CommandMetadata = { commandName: "portal toc new", shell: "test" }; + +// `portal toc new` used to fall back to the default TOC when it had no spec to +// extract from — writing a toc.yml with no Endpoints, Events or Models sections +// over a possibly valid file, and exiting 0. The spec is now required. These +// cases return before any request is made, so no service stubbing is needed. +describe("PortalNewTocAction spec directory requirement", () => { + let tmpDirResult: DirectoryResult; + let buildDirectory: string; + let action: PortalNewTocAction; + + const execute = () => action.execute(new DirectoryPath(buildDirectory)); + + beforeEach(async () => { + tmpDirResult = await tmpDir({ unsafeCleanup: true }); + buildDirectory = path.join(tmpDirResult.path, "build"); + await fsExtra.ensureDir(buildDirectory); + // `BuildContext.validate()` only checks that the build file exists — the + // spec checks run before its contents are ever read. + await fsExtra.writeJson(path.join(buildDirectory, "APIMATIC-BUILD.json"), {}); + + action = new PortalNewTocAction(new DirectoryPath(tmpDirResult.path), COMMAND_METADATA); + }); + + afterEach(async () => { + await tmpDirResult.cleanup(); + }); + + it("fails when the spec directory is missing", async () => { + const result = await execute(); + + expect(result.isFailed()).to.be.true; + }); + + it("fails when the spec directory exists but is empty", async () => { + await fsExtra.ensureDir(path.join(buildDirectory, "spec")); + + const result = await execute(); + + expect(result.isFailed()).to.be.true; + }); + + it("fails when the spec directory holds only dotfiles", async () => { + // `FileService.directoryEmpty` filters dot-prefixed entries, so a spec + // directory kept alive by a .gitkeep still has nothing to extract. + const specDirectory = path.join(buildDirectory, "spec"); + await fsExtra.ensureDir(specDirectory); + await fsExtra.writeFile(path.join(specDirectory, ".gitkeep"), ""); + + const result = await execute(); + + expect(result.isFailed()).to.be.true; + }); + + it("writes no toc.yml when the spec is missing", async () => { + await execute(); + + const written = await fsExtra.readdir(buildDirectory); + expect(written).to.deep.equal(["APIMATIC-BUILD.json"]); + }); +}); diff --git a/test/utils/utils.test.ts b/test/utils/utils.test.ts new file mode 100644 index 00000000..56c15325 --- /dev/null +++ b/test/utils/utils.test.ts @@ -0,0 +1,51 @@ +import { Readable } from "stream"; +import { expect } from "chai"; +import { discardStreamBody } from "../../src/utils/utils.js"; + +// `ApiError.body` is `string | Blob | NodeJS.ReadableStream`, and which one it +// is depends on the endpoint: `callAsStream` yields a stream on Node, a Blob +// elsewhere, and `callAsJson` yields a string. `discardStreamBody` has to +// release the first without disturbing the others, so the probe is the contract. +describe("discardStreamBody", () => { + it("destroys a readable stream body", () => { + const stream = Readable.from(["chunk"]); + expect(stream.destroyed).to.be.false; + + discardStreamBody(stream); + + expect(stream.destroyed).to.be.true; + }); + + it("is idempotent on an already destroyed stream", () => { + // The SDK drains error bodies itself via `loadResult` on the `throwOn` path, + // so a body reaching here may already be finished. + const stream = Readable.from(["chunk"]); + stream.destroy(); + + expect(() => discardStreamBody(stream)).to.not.throw(); + expect(stream.destroyed).to.be.true; + }); + + it("leaves a string body intact for callers that parse it afterwards", () => { + // `transformation-service` discards before reading the 401 message; that is + // only safe because a `callAsJson` string body has no `destroy`. + const body = JSON.stringify({ message: "Authorization has been denied" }); + + discardStreamBody(body); + + expect((JSON.parse(body) as { message: string }).message).to.equal("Authorization has been denied"); + }); + + it("ignores a Blob-like body with no destroy method", () => { + expect(() => discardStreamBody({ size: 12, type: "application/zip" })).to.not.throw(); + }); + + it("ignores undefined and null bodies", () => { + expect(() => discardStreamBody(undefined)).to.not.throw(); + expect(() => discardStreamBody(null)).to.not.throw(); + }); + + it("ignores a destroy property that is not callable", () => { + expect(() => discardStreamBody({ destroy: "not a function" })).to.not.throw(); + }); +}); From 60245b0d7ed8ae3e6ec3989c78bae5f1e33eb32b Mon Sep 17 00:00:00 2001 From: saeedjamshaid Date: Wed, 5 Aug 2026 13:06:42 +0500 Subject: [PATCH 11/11] style: drop a redundant local and an unneeded union in the cast `specDirectoryEmpty` assigned its message to a local before logging it while the sibling `specDirectoryNotFound` inlines the same call; inline it to match. `isDestroyable` cast to `{ destroy?: unknown } | undefined` and then used `?.`, which already short-circuits on null and undefined. The union added nothing and implied the guard was handling a case the optional chain was not. Co-Authored-By: Claude Opus 5 (1M context) --- src/prompts/portal/toc/new-toc.ts | 3 +-- src/utils/utils.ts | 2 +- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/src/prompts/portal/toc/new-toc.ts b/src/prompts/portal/toc/new-toc.ts index 9d1d7d38..0b3aa182 100644 --- a/src/prompts/portal/toc/new-toc.ts +++ b/src/prompts/portal/toc/new-toc.ts @@ -29,8 +29,7 @@ export class PortalNewTocPrompts { } public specDirectoryEmpty(directory: DirectoryPath) { - const message = `The ${f.var("spec")} directory in ${f.path(directory)} is either empty or invalid.`; - log.error(message); + log.error(`The ${f.var("spec")} directory in ${f.path(directory)} is either empty or invalid.`); } public tocFileAlreadyExists() { diff --git a/src/utils/utils.ts b/src/utils/utils.ts index ec29aa4e..b4ae494b 100644 --- a/src/utils/utils.ts +++ b/src/utils/utils.ts @@ -23,7 +23,7 @@ export async function parseStreamBodyToJson(body: NodeJS.ReadableStream): Promis // and `IncomingMessage` — so narrowing an `ApiError.body` union still won't let // us call it. Probe for the method instead. function isDestroyable(value: unknown): value is { destroy: () => void } { - return typeof (value as { destroy?: unknown } | undefined)?.destroy === "function"; + return typeof (value as { destroy?: unknown })?.destroy === "function"; } // Counterpart to `parseStreamBodyToJson`: releases a response body we are never