From cd38dd059a0792cf34963d4724c9caa7f322c386 Mon Sep 17 00:00:00 2001 From: Muhammad Rafay Nadeem Date: Mon, 10 Aug 2026 12:59:43 +0500 Subject: [PATCH 01/12] fix: poll for publish completion in interactive sdk publish Interactive `sdk publish` returned as soon as the publishing API accepted the request, printing a link and exiting 0 regardless of the outcome, while non-interactive polled to a terminal state and exited non-zero on failure. Move the polling, the running notice, and the closing log-URL note into the shared `SdkPublishAction`, so both modes report the same outcome and exit code. `pollPublishingStatus`, `publishingRunningNotice`, and `postPublishingMessage` move to the shared `SdkPublishPrompts`; the interactive-only `sdkPublishingInProgress` note is dropped since publishing is no longer in flight when the command exits. The poller now reports `succeeded | failed | cancelled` instead of a boolean. The spinner prints its own cancel line and removes its signal listeners on Ctrl+C, so the previous loop kept polling invisibly until a second Ctrl+C killed the process; it now aborts the wait, tells the user publishing continues on APIMatic, prints the log URL, and exits 130. Refs #312, #313 Co-Authored-By: Claude Opus 5 (1M context) --- src/actions/sdk/publish.ts | 42 +++++- src/actions/sdk/publish/interactive.ts | 1 - src/actions/sdk/publish/non-interactive.ts | 22 ---- src/prompts/sdk/publish.ts | 146 +++++++++++++++++---- src/prompts/sdk/publish/interactive.ts | 8 +- src/prompts/sdk/publish/non-interactive.ts | 72 +--------- 6 files changed, 163 insertions(+), 128 deletions(-) diff --git a/src/actions/sdk/publish.ts b/src/actions/sdk/publish.ts index c736fe1c..24555410 100644 --- a/src/actions/sdk/publish.ts +++ b/src/actions/sdk/publish.ts @@ -35,8 +35,8 @@ export class SdkPublishAction { publishingProfile: PublishingProfile, dryRun: boolean, onPublishSdkError: (errorMessage: string) => void - ): Promise> => { - return await withDirPath(async (tempDirectory) => { + ): Promise => { + const publishResult = await withDirPath(async (tempDirectory): Promise> => { const packageConfigurationData = publishingProfile.getPackageConfigurationDataForLanguage(language); let packageSettingsDirectory: DirectoryPath | undefined; @@ -100,5 +100,43 @@ export class SdkPublishAction { return ActionResult.success(publishSdkResponse.value); }); + + if (publishResult.isFailed()) { + return ActionResult.failed(); + } + if (publishResult.isCancelled()) { + return ActionResult.cancelled(); + } + + // Since publishing was not initiated, skip the next steps for polling. + if (dryRun) { + return ActionResult.success(); + } + + const publishingInfo = publishResult.getValue(); + this.prompts.publishingRunningNotice(publishingProfile, language, semVersion, publishType); + + const publishingOutcome = await this.prompts.pollPublishingStatus(() => + this.publishingApiService.getSdkPublishingLog( + publishingInfo.publishLogId, + this.configDir, + this.commandMetadata.shell + ) + ); + + if (publishingOutcome === 'cancelled') { + this.prompts.publishingWaitCancelledNotice(); + } + + this.prompts.postPublishingMessage(publishingInfo.publishingLogUrl); + + switch (publishingOutcome) { + case 'succeeded': + return ActionResult.success(); + case 'cancelled': + return ActionResult.cancelled(); + default: + return ActionResult.failed(); + } }; } diff --git a/src/actions/sdk/publish/interactive.ts b/src/actions/sdk/publish/interactive.ts index d170df7f..1344b0d8 100644 --- a/src/actions/sdk/publish/interactive.ts +++ b/src/actions/sdk/publish/interactive.ts @@ -118,7 +118,6 @@ export class SdkPublishInteractiveAction { return ActionResult.cancelled(); } - this.prompts.sdkPublishingInProgress(publishResult.getValue().publishingLogUrl); return ActionResult.success(); }; diff --git a/src/actions/sdk/publish/non-interactive.ts b/src/actions/sdk/publish/non-interactive.ts index e2b030ad..fb6e5ca9 100644 --- a/src/actions/sdk/publish/non-interactive.ts +++ b/src/actions/sdk/publish/non-interactive.ts @@ -122,28 +122,6 @@ export class SdkPublishNonInteractiveAction { return ActionResult.cancelled(); } - // Since publishing was not initiated, skip the next steps for polling. - if (dryRun) { - return ActionResult.success(); - } - - const publishingInfo = publishResult.getValue(); - this.prompts.publishingRunningNotice(publishingProfile, language, semVersion, publishTypes); - - const publishingSucceeded = await this.prompts.pollPublishingStatus(() => - this.publishingApiService.getSdkPublishingLog( - publishingInfo.publishLogId, - this.configDir, - this.commandMetadata.shell - ) - ); - - this.prompts.postPublishingMessage(publishingInfo.publishingLogUrl); - - if (!publishingSucceeded) { - return ActionResult.failed(); - } - return ActionResult.success(); }; } diff --git a/src/prompts/sdk/publish.ts b/src/prompts/sdk/publish.ts index ac785e46..5fee790d 100644 --- a/src/prompts/sdk/publish.ts +++ b/src/prompts/sdk/publish.ts @@ -1,26 +1,120 @@ -import { log } from '@clack/prompts'; -import { Result } from 'neverthrow'; -import { ServiceError } from '../../infrastructure/service-error.js'; -import { PublishingInfo } from '../../types/publish-api/publishing-info.js'; -import { PublishType } from '../../types/publish-api/publishing-profile-item.js'; -import { SemVersion } from '../../types/publish/version.js'; -import { Language } from '../../types/sdk/generate.js'; -import { withSpinner } from '../prompt.js'; -import { PublishingProfile } from '../../types/publish/publishing-profile.js'; - -export class SdkPublishPrompts { - public publishSdk(fn: Promise>) { - return withSpinner('Publishing SDK', 'Publishing initiated.', 'SDK Publishing failed.', fn); - } - - public sdkPublishingServiceError(serviceError: ServiceError) { - log.error(serviceError.errorMessage); - } - - public dryRunNotice(publishingProfile: PublishingProfile, language: Language, version: SemVersion, publishType: PublishType[]): void { - const targets = publishType.map((t) => (t === PublishType.PackagePublishing ? 'Package' : 'Source Code')).join(' + '); - log.info( - `You can publish this SDK by removing the --dry-run flag. It will be published for the following:\n\n Profile: ${publishingProfile}\n Language: ${language}\n Version: ${version}\n Targets: ${targets}` - ); - } -} +import { log, spinner } from '@clack/prompts'; +import { Result } from 'neverthrow'; +import { ServiceError } from '../../infrastructure/service-error.js'; +import { PublishLogItem } from '../../types/publish-api/publish-log.js'; +import { PublishingInfo } from '../../types/publish-api/publishing-info.js'; +import { PublishType } from '../../types/publish-api/publishing-profile-item.js'; +import { SemVersion } from '../../types/publish/version.js'; +import { Language } from '../../types/sdk/generate.js'; +import { noteWrapped, withSpinner } from '../prompt.js'; +import { format as f } from '../format.js'; +import { PublishingProfile } from '../../types/publish/publishing-profile.js'; + +export type PublishingOutcome = 'succeeded' | 'failed' | 'cancelled'; + +export class SdkPublishPrompts { + public publishSdk(fn: Promise>) { + return withSpinner('Publishing SDK', 'Publishing initiated.', 'SDK Publishing failed.', fn); + } + + public sdkPublishingServiceError(serviceError: ServiceError) { + log.error(serviceError.errorMessage); + } + + public dryRunNotice(publishingProfile: PublishingProfile, language: Language, version: SemVersion, publishType: PublishType[]): void { + const targets = publishType.map((t) => (t === PublishType.PackagePublishing ? 'Package' : 'Source Code')).join(' + '); + log.info( + `You can publish this SDK by removing the --dry-run flag. It will be published for the following:\n\n Profile: ${publishingProfile}\n Language: ${language}\n Version: ${version}\n Targets: ${targets}` + ); + } + + public publishingRunningNotice( + profile: PublishingProfile, + language: Language, + version: SemVersion, + publishType: PublishType[] + ): void { + const targets = [...publishType] + .sort((a, b) => (a === PublishType.SourceCodePublishing ? -1 : b === PublishType.SourceCodePublishing ? 1 : 0)) + .map((t) => (t === PublishType.PackagePublishing ? 'Package' : 'Source Code')) + .join(' + '); + log.info( + `Publishing is running for the following:\n\n Profile: ${profile}\n Language: ${language}\n Version: ${version}\n Targets: ${targets}` + ); + } + + public publishingWaitCancelledNotice(): void { + log.info('Publishing is still running on APIMatic and will continue without the CLI.'); + } + + public postPublishingMessage(publishingLogUrl: string) { + const message = `To view publishing logs, please visit: +${f.link(publishingLogUrl)}`; + noteWrapped(message, 'Next Steps'); + } + + public async pollPublishingStatus( + getSdkPublishingLogFn: () => Promise> + ): Promise { + const TERMINAL_STATES = new Set(['Succeeded', 'Failed', 'Exception', 'InternalError']); + const POLL_INTERVAL_MS = 10000; // poll after every 10 seconds. + + let cancelled = false; + let abortWait: (() => void) | undefined; + // The spinner prints its own cancel line and removes its signal listeners on Ctrl+C, so a + // cancelled poll must return without stopping the spinner again. Without this callback the + // loop would keep polling invisibly until a second Ctrl+C killed the process. + const spin = spinner({ + onCancel: () => { + cancelled = true; + abortWait?.(); + } + }); + + spin.start('Waiting for publishing status...'); + + while (!cancelled) { + const publishingLogResult = await getSdkPublishingLogFn(); + if (cancelled) break; + + if (publishingLogResult.isErr()) { + spin.stop('Failed to fetch publishing status.', 1); + return 'failed'; + } + + const { events } = publishingLogResult.value; + const executionCompleted = events.every((event) => TERMINAL_STATES.has(event.eventType)); + const statusMessage = [...events] + .sort((a, b) => (a.publishType === 'SourceCode' ? -1 : b.publishType === 'SourceCode' ? 1 : 0)) + .map((event) => { + const target = event.publishType === 'SourceCode' ? 'Source Code' : 'Package'; + const eventLabels: Record = { + Queued: 'Queued', + InProgress: 'In Progress', + Succeeded: 'Published' + }; + const label = eventLabels[event.eventType] ?? 'Failed'; + return `${target}: [${label}]`; + }) + .join(' | '); + + if (executionCompleted) { + const isExecutionSuccessful = events.every((event) => event.eventType === 'Succeeded'); + spin.stop(statusMessage, isExecutionSuccessful ? 0 : 1); + return isExecutionSuccessful ? 'succeeded' : 'failed'; + } + + spin.message(statusMessage); + await new Promise((resolve) => { + const timer = setTimeout(resolve, POLL_INTERVAL_MS); + abortWait = () => { + clearTimeout(timer); + resolve(); + }; + }); + abortWait = undefined; + } + + return 'cancelled'; + } +} diff --git a/src/prompts/sdk/publish/interactive.ts b/src/prompts/sdk/publish/interactive.ts index a946ac7d..601c4479 100644 --- a/src/prompts/sdk/publish/interactive.ts +++ b/src/prompts/sdk/publish/interactive.ts @@ -1,7 +1,7 @@ import { confirm, isCancel, log, select, text } from '@clack/prompts'; import { Result } from 'neverthrow'; import { format as f } from '../../../prompts/format.js'; -import { noteWrapped, withSpinner } from '../../prompt.js'; +import { withSpinner } from '../../prompt.js'; import { DirectoryPath } from '../../../types/file/directoryPath.js'; import { ServiceError } from '../../../infrastructure/service-error.js'; import { @@ -194,10 +194,4 @@ export class SdkPublishInteractivePrompts { 'Version tags will not be created in your Git repository because you have opted to publish Source Code only.' ); } - - public sdkPublishingInProgress(publishingLogUrl: string) { - const message = `To view the status of publishing, please visit: -${f.link(publishingLogUrl)}`; - noteWrapped(message, 'Next Steps'); - } } diff --git a/src/prompts/sdk/publish/non-interactive.ts b/src/prompts/sdk/publish/non-interactive.ts index 8fe5763e..d25c83c0 100644 --- a/src/prompts/sdk/publish/non-interactive.ts +++ b/src/prompts/sdk/publish/non-interactive.ts @@ -1,15 +1,12 @@ -import { log, spinner } from '@clack/prompts'; +import { log } from '@clack/prompts'; import { Result } from 'neverthrow'; import { PublishType } from '../../../types/publish-api/publishing-profile-item.js'; -import { SemVersion } from '../../../types/publish/version.js'; import { format as f } from '../../../prompts/format.js'; import { DirectoryPath } from '../../../types/file/directoryPath.js'; import { ServiceError } from '../../../infrastructure/service-error.js'; -import { noteWrapped, withSpinner } from '../../prompt.js'; +import { withSpinner } from '../../prompt.js'; import { PublishingProfileItem } from '../../../types/publish-api/publishing-profile-item.js'; -import { PublishLogItem } from '../../../types/publish-api/publish-log.js'; import { ProfileId } from '../../../types/publish/profile-id.js'; -import { PublishingProfile } from '../../../types/publish/publishing-profile.js'; import { Language } from '../../../types/sdk/generate.js'; export class SdkPublishNonInteractivePrompts { @@ -83,69 +80,4 @@ export class SdkPublishNonInteractivePrompts { 'Version tags will not be created in your Git repository because you have opted to publish Source Code only.' ); } - - public publishingRunningNotice( - profile: PublishingProfile, - language: Language, - version: SemVersion, - publishType: PublishType[] - ): void { - const targets = [...publishType] - .sort((a, b) => (a === PublishType.SourceCodePublishing ? -1 : b === PublishType.SourceCodePublishing ? 1 : 0)) - .map((t) => (t === PublishType.PackagePublishing ? 'Package' : 'Source Code')) - .join(' + '); - log.info( - `Publishing is running for the following:\n\n Profile: ${profile}\n Language: ${language}\n Version: ${version}\n Targets: ${targets}` - ); - } - - public postPublishingMessage(publishingLogUrl: string) { - const message = `To view publishing logs, please visit: -${f.link(publishingLogUrl)}`; - noteWrapped(message, 'Next Steps'); - } - - public async pollPublishingStatus( - getSdkPublishingLogFn: () => Promise> - ): Promise { - const TERMINAL_STATES = new Set(['Succeeded', 'Failed', 'Exception', 'InternalError']); - const POLL_INTERVAL_MS = 10000; // poll after every 10 seconds. - const spin = spinner(); - - spin.start('Waiting for publishing status...'); - - while (true) { - const publishingLogResult = await getSdkPublishingLogFn(); - - if (publishingLogResult.isErr()) { - spin.stop('Failed to fetch publishing status.', 1); - return false; - } - - const { events } = publishingLogResult.value; - const executionCompleted = events.every((event) => TERMINAL_STATES.has(event.eventType)); - const statusMessage = [...events] - .sort((a, b) => (a.publishType === 'SourceCode' ? -1 : b.publishType === 'SourceCode' ? 1 : 0)) - .map((event) => { - const target = event.publishType === 'SourceCode' ? 'Source Code' : 'Package'; - const eventLabels: Record = { - Queued: 'Queued', - InProgress: 'In Progress', - Succeeded: 'Published' - }; - const label = eventLabels[event.eventType] ?? 'Failed'; - return `${target}: [${label}]`; - }) - .join(' | '); - - if (executionCompleted) { - const isExecutionSuccessful = events.every((event) => event.eventType === 'Succeeded'); - spin.stop(statusMessage, isExecutionSuccessful ? 0 : 1); - return isExecutionSuccessful; - } - - spin.message(statusMessage); - await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS)); - } - } } From 03c9c402643b9cd660f696414f56f4b428449b5d Mon Sep 17 00:00:00 2001 From: Muhammad Rafay Nadeem Date: Mon, 10 Aug 2026 15:39:37 +0500 Subject: [PATCH 02/12] temp: grilling notes --- .ai/notes/interactive-sdk-publish-polling.md | 82 ++++++++++++++++++++ 1 file changed, 82 insertions(+) create mode 100644 .ai/notes/interactive-sdk-publish-polling.md diff --git a/.ai/notes/interactive-sdk-publish-polling.md b/.ai/notes/interactive-sdk-publish-polling.md new file mode 100644 index 00000000..ac55b0f5 --- /dev/null +++ b/.ai/notes/interactive-sdk-publish-polling.md @@ -0,0 +1,82 @@ +# Interactive `sdk publish` — poll for publish completion + +Design notes for the change on `fix/interactive-sdk-publish-polling` (commit `cd38dd0`, branched from `origin/dev` at `0a2ef4b`). Written 2026-08-10. + +## Problem + +The two modes of `apimatic sdk publish` disagreed about when the command was done. + +- **Interactive** (`actions/sdk/publish/interactive.ts`) returned as soon as the publishing API accepted the POST. It printed `sdkPublishingInProgress` — "To view the **status** of publishing, please visit…" — and returned `success()`, so the command exited `0` whether publishing later succeeded or failed. +- **Non-interactive** (`actions/sdk/publish/non-interactive.ts`) printed `publishingRunningNotice`, polled `getSdkPublishingLog` every 10 s to a terminal state, printed `postPublishingMessage`, and returned `failed()` when publishing did not succeed. + +`pollPublishingStatus` lived only on `SdkPublishNonInteractivePrompts`, even though a shared `SdkPublishPrompts` and a shared `SdkPublishAction` already existed and both modes went through them. + +## Decisions + +Each was decided explicitly; the rationale matters more than the choice. + +1. **Polling lives in the shared `SdkPublishAction`**, not duplicated per mode and not left as a shared prompt method that each mode action drives. Both modes did an identical post-POST sequence, so sharing the orchestration is what actually prevents the two paths drifting again. The poll runs *after* the `withDirPath` block closes, so the temp directory holding the zipped SDK is released before the CLI sits waiting for minutes. + +2. **The running notice prints in both modes.** Interactive already shows `publishingSummary` before `confirmPublishing`, so the four fields (profile / language / version / targets) appear twice — but a full SDK generation scrolls by in between, and suppressing it for interactive would mean threading a mode flag into the shared action. That flag is the first crack that lets the modes diverge. + +3. **One closing note, both outcomes**, reusing non-interactive's `postPublishingMessage` wording verbatim. The log URL is useful on success (package/repo links) and essential on failure. Interactive's `sdkPublishingInProgress` was deleted — publishing is no longer in flight when the command exits, so its wording had become wrong. + +4. **Ctrl+C mid-poll cancels the wait and exits 130**, printing "publishing is still running on APIMatic" plus the log URL. This also fixed a live bug — see below. + +5. **The completion predicate was left exactly as it was.** `events.every(...)` is vacuously true on an empty array, so an empty or partially-populated publish log reports instant false success. No guard was added, deliberately: see [#312](https://github.com/apimatic/apimatic-cli/issues/312). + +6. **Transient status-fetch errors still abort the poll** on the first failure. The message already distinguishes "Failed to fetch publishing status." from a publish failure, the log URL prints regardless, and a non-zero exit is honest because the CLI genuinely does not know the outcome. Retry tolerance is noted on #312. + +7. **The poll loop stays in the prompts layer**, moved verbatim into shared `SdkPublishPrompts`. `.ai/instructions.md` scopes prompts to terminal UI, and `infrastructure/services/portal-service.ts` has a `pollUntilCompleted` precedent for service-owned polling — but relocating the loop means inventing a status-callback seam across a layer boundary, which is a refactor riding along on a parity fix. + +8. **No timeout.** Any ceiling is a guess about the slowest legitimate publish, and guessing low turns a slow success into a reported failure. Ctrl+C is the interactive escape hatch; job timeouts are the CI one. + +9. **No automated tests.** `test/` has no publish coverage and no prompts tests at all. Meaningful coverage here needs either fake timers plus stdout capture against a live clack spinner, or dependency injection into `SdkPublishAction` (which constructs its own `SdkPublishPrompts`, `PublishingApiService`, and `GenerateAction`). Either is larger and riskier than the parity fix. Verified manually instead. + +10. **`SdkPublishAction.execute` returns `Promise`**, not `ActionResult`. Nothing reads the payload now that the action prints its own notes, and the old signature carried a hazard: the dry-run branch returns `ActionResult.success()` with no value, so `getValue()` on it throws. That was safe only because interactive hardcoded `dryRun: false`. + +11. **`pollPublishingStatus` returns `'succeeded' | 'failed' | 'cancelled'`**, exported from the prompts module (following `QuickstartFlow` in `prompts/quickstart.ts`). A boolean cannot express cancel-vs-fail, and keeping them distinct matters for planned work: a future PR will write a `plugin-config.json` recording successfully published packages, and its cancel path will need to warn that abandoning the wait breaks context-plugin generation. `ActionResult` stays out of the prompts layer. + +12. **The poller returns the outcome only**, not the terminal `PublishLogEventItem[]`. That data (`packageUrl`, `sourceUrl`, `languageVersion`) is what `plugin-config.json` will be built from, but that PR knows which fields it needs and where the write belongs; widening one internal method with two call sites is cheap later. + +13. **The cancel message stays generic** — publishing continues server-side, here is the log URL. Naming `plugin-config.json` before the CLI writes it would point users at nothing. + +Two smaller calls, made without discussion: `sourceCodeOnlyPublishingNotice` stays duplicated in both mode prompt classes (both mode *actions* call it before the shared action runs, so consolidating it is unrelated cleanup), and the methods moved to `SdkPublishPrompts` were removed from `SdkPublishNonInteractivePrompts` rather than left as forwarding wrappers. + +## The cancellation bug + +Worth recording because it is not obvious from the clack API. + +`@clack/prompts@1.0.0-alpha.1`'s spinner contains **no `process.exit`**. On SIGINT it stops the spinner (printing its cancel line), calls an optional `onCancel`, sets `isCancelled`, and *removes its own signal listeners*. Because a SIGINT listener was registered, Node's default termination is suppressed while the spinner runs. + +So the old `while (true)` loop, on Ctrl+C: printed the cancel line, then **kept polling invisibly with a dead spinner**, and only a second Ctrl+C killed the process (by then clack had removed its listener, so the default handler applied). Non-interactive is mostly CI, where nobody presses Ctrl+C; making interactive wait promoted this to a likely path. + +The fix passes `onCancel` to `spinner()`, which both flags cancellation and clears the pending 10 s `setTimeout`, so the wait aborts immediately instead of up to 10 s later. A cancelled poll returns **without** calling `spin.stop()` again — clack has already printed its line and torn down its listeners. + +## Files touched + +| File | Change | +| --- | --- | +| `src/prompts/sdk/publish.ts` | Shared prompts gain `publishingRunningNotice`, `postPublishingMessage`, `publishingWaitCancelledNotice`, `pollPublishingStatus`, and `export type PublishingOutcome` | +| `src/actions/sdk/publish.ts` | Polls after `withDirPath` closes; maps outcome to `ActionResult`; return type narrowed to `Promise` | +| `src/actions/sdk/publish/interactive.ts` | Dropped the fire-and-forget note; now only inspects the result | +| `src/actions/sdk/publish/non-interactive.ts` | Poll/notice/note block removed (moved into the shared action); behaviour unchanged | +| `src/prompts/sdk/publish/interactive.ts` | `sdkPublishingInProgress` deleted, unused imports pruned | +| `src/prompts/sdk/publish/non-interactive.ts` | Moved methods deleted, unused imports pruned | + +## Deferred + +- [#312](https://github.com/apimatic/apimatic-cli/issues/312) — missing array-length check makes an empty publish log report a false success; also carries the no-retry-tolerance and unbounded-wait notes. +- [#313](https://github.com/apimatic/apimatic-cli/issues/313) — no telemetry for publishes that fail *after* being accepted. `SdkPublishValidationFailedEvent` models a rejected publish request; folding remote failures into it would corrupt that metric, so it wants its own event. + +## Verification + +Automated: `pnpm build` and `pnpm lint` clean; `pnpm apimatic sdk publish --help` loads from the built output. + +Manual matrix (requires a real publishing profile): + +1. Interactive, Package + Source Code → polls to `[Published] | [Published]`, exit `0`. +2. Interactive, package-only → one target in the status line. +3. Interactive, a publish that fails → exit `1`, log URL printed. +4. Interactive, Ctrl+C mid-poll → immediate clean exit `130`, "still running" notice, log URL. +5. Non-interactive regression → output unchanged from before this change. From 221cb63e609c1254135606db8f09f0c2b5d29569 Mon Sep 17 00:00:00 2001 From: Muhammad Rafay Nadeem Date: Tue, 11 Aug 2026 11:14:55 +0500 Subject: [PATCH 03/12] fix: reach the cancel notice when Ctrl+C stops the publish wait MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cancelling the publishing wait printed no "still running" notice and no log URL, and exited 0 for an abandoned publish. `spin.start()` calls `block()` from `@clack/core`, which puts stdin in raw mode and registers a keypress listener that calls `process.exit(0)` on Ctrl+C. Raw mode also stops the terminal raising SIGINT, so the spinner's `onCancel` never fired: the process died mid-await and the spinner's `exit` listener printed its cancel line with the green submit symbol on the way out. clack exposes no way to opt out of `block()` — `spinner()` takes `output` and `signal` but never `input`, and `updateSettings` can only add key aliases — so `startCancellableSpinner` takes the key back. It removes the listener `block()` added, found by diffing stdin's keypress listeners against a snapshot taken before `start()`, and installs one that flags cancellation and aborts the pending poll timer. `pollPublishingStatus` now stops its own spinner on cancel, guarded by `isCancelled` so the SIGTERM path clack does handle does not print twice. Co-Authored-By: Claude Opus 5 (1M context) --- .ai/notes/interactive-sdk-publish-polling.md | 28 +++-- src/prompts/prompt.ts | 41 +++++++- src/prompts/sdk/publish.ts | 105 ++++++++++--------- 3 files changed, 116 insertions(+), 58 deletions(-) diff --git a/.ai/notes/interactive-sdk-publish-polling.md b/.ai/notes/interactive-sdk-publish-polling.md index ac55b0f5..c82a06aa 100644 --- a/.ai/notes/interactive-sdk-publish-polling.md +++ b/.ai/notes/interactive-sdk-publish-polling.md @@ -45,18 +45,32 @@ Two smaller calls, made without discussion: `sourceCodeOnlyPublishingNotice` sta ## The cancellation bug -Worth recording because it is not obvious from the clack API. +Worth recording because it is not obvious from the clack API, and because the first attempt at it was wrong. -`@clack/prompts@1.0.0-alpha.1`'s spinner contains **no `process.exit`**. On SIGINT it stops the spinner (printing its cancel line), calls an optional `onCancel`, sets `isCancelled`, and *removes its own signal listeners*. Because a SIGINT listener was registered, Node's default termination is suppressed while the spinner runs. +`@clack/prompts@1.0.0-alpha.1`'s **spinner** contains no `process.exit`. On SIGINT it stops the spinner (printing its cancel line), calls an optional `onCancel`, sets `isCancelled`, and removes its own signal listeners. That reading is correct as far as it goes, and it is what the first fix (commit `cd38dd0`) was built on: pass `onCancel` to `spinner()` to flag cancellation and clear the pending 10 s `setTimeout`, then return **without** calling `spin.stop()` again. -So the old `while (true)` loop, on Ctrl+C: printed the cancel line, then **kept polling invisibly with a dead spinner**, and only a second Ctrl+C killed the process (by then clack had removed its listener, so the default handler applied). Non-interactive is mostly CI, where nobody presses Ctrl+C; making interactive wait promoted this to a likely path. +That fix never ran. `spin.start()` calls `block()` from `@clack/core`, which puts stdin in raw mode and registers a **keypress** listener that calls **`process.exit(0)`** as soon as it sees a key aliased to `cancel` — `\x03` (Ctrl+C) or `escape`. Raw mode also stops the terminal from raising SIGINT at all, so on Ctrl+C: -The fix passes `onCancel` to `spinner()`, which both flags cancellation and clears the pending 10 s `setTimeout`, so the wait aborts immediately instead of up to 10 s later. A cancelled poll returns **without** calling `spin.stop()` again — clack has already printed its line and torn down its listeners. +1. `block()`'s keypress listener calls `process.exit(0)` synchronously. +2. The spinner's `process.on('exit')` listener fires with code `0`, so it prints its cancel line with the **green submit symbol** (`◇ Canceled`, not red `■`) and, because that path sets `isCancelled = false`, **never calls `onCancel`**. +3. The process is gone. `pollPublishingStatus` never returns, the running notice and log URL never print, and the CLI reports **exit code 0** for an abandoned publish. + +The `◇` rather than `■` in a bug report is the tell that this path, not SIGINT, was taken. + +Because clack offers no way to opt out of `block()` (`spinner()` takes `output` and `signal`, never `input`) and `updateSettings` can only *add* key aliases, never remove them, the only seam left is to take the key back. `startCancellableSpinner` in `src/prompts/prompt.ts` starts the spinner, diffs stdin's `keypress` listeners against a snapshot taken before `start()` to remove exactly the one `block()` added, and installs its own Ctrl+C listener that flags cancellation and aborts the pending timer. `dispose()` in a `finally` removes it again. + +Consequences worth knowing: + +- **The poll now stops its own spinner.** With clack's hard exit gone, nothing prints a cancel line, so the cancelled branch calls `spin.stop(...)` itself — guarded by `if (!spin.isCancelled)`, because SIGTERM still reaches clack's own handler, which prints and tears down first. Both paths therefore produce exactly one cancel line. +- **`spin.stop()` still performs teardown** (cursor restore, raw mode off, readline close) via the unblock closure `block()` returned, which is untouched. +- **Escape no longer aborts the wait.** It used to, by falling into the same `process.exit(0)`; only Ctrl+C is honoured now, which is the documented escape hatch anyway. +- **Keystrokes during the wait are no longer erased.** `block()`'s listener also redrew over stray input; the spinner's own 80 ms repaint (`\x1b[999D\x1b[J`) already covers same-line garbage, so the only visible artifact is one stale spinner line per Enter press. ## Files touched | File | Change | | --- | --- | +| `src/prompts/prompt.ts` | New `startCancellableSpinner` — a spinner whose Ctrl+C is observable rather than fatal (see the cancellation bug) | | `src/prompts/sdk/publish.ts` | Shared prompts gain `publishingRunningNotice`, `postPublishingMessage`, `publishingWaitCancelledNotice`, `pollPublishingStatus`, and `export type PublishingOutcome` | | `src/actions/sdk/publish.ts` | Polls after `withDirPath` closes; maps outcome to `ActionResult`; return type narrowed to `Promise` | | `src/actions/sdk/publish/interactive.ts` | Dropped the fire-and-forget note; now only inspects the result | @@ -68,6 +82,7 @@ The fix passes `onCancel` to `spinner()`, which both flags cancellation and clea - [#312](https://github.com/apimatic/apimatic-cli/issues/312) — missing array-length check makes an empty publish log report a false success; also carries the no-retry-tolerance and unbounded-wait notes. - [#313](https://github.com/apimatic/apimatic-cli/issues/313) — no telemetry for publishes that fail *after* being accepted. `SdkPublishValidationFailedEvent` models a rejected publish request; folding remote failures into it would corrupt that metric, so it wants its own event. +- Every other long wait behind `withSpinner` still has clack's `process.exit(0)` on Ctrl+C: portal generation (`pollUntilCompleted` in `infrastructure/services/portal-service.ts`) and the device-code login loop (`actions/auth/login.ts`). They exit `0` with a green `◇ cancelled` and no notice. `startCancellableSpinner` is the seam to fix them with; not done here because each needs its own decision about what to print and return on cancel. ## Verification @@ -78,5 +93,6 @@ Manual matrix (requires a real publishing profile): 1. Interactive, Package + Source Code → polls to `[Published] | [Published]`, exit `0`. 2. Interactive, package-only → one target in the status line. 3. Interactive, a publish that fails → exit `1`, log URL printed. -4. Interactive, Ctrl+C mid-poll → immediate clean exit `130`, "still running" notice, log URL. -5. Non-interactive regression → output unchanged from before this change. +4. Interactive, Ctrl+C mid-poll → immediate `■ Cancelled waiting for publishing status.`, "still running" notice, log URL, exit `130`. Verified by driving `pollPublishingStatus` from the built output in a child process and writing `\x03` to its stdin: it returns `'cancelled'`, the caller's notices print, and the process exits `130`. Before the fix the same harness died at `process.exit(0)` with nothing after the cancel line. +5. Interactive, Ctrl+C pressed repeatedly → one cancel line, one notice, exit `130`; the replacement listener is idempotent and nothing hard-exits. Verified in the same harness with three `\x03` writes. +6. Non-interactive regression → output unchanged from before this change. diff --git a/src/prompts/prompt.ts b/src/prompts/prompt.ts index 4a2dbcab..3a4e2ca5 100644 --- a/src/prompts/prompt.ts +++ b/src/prompts/prompt.ts @@ -1,10 +1,49 @@ import type { Writable } from "node:stream"; import pc from "picocolors"; import { getColumns } from "@clack/core"; -import { log, note, NoteOptions, S_BAR_H, S_CONNECT_LEFT, spinner } from "@clack/prompts"; +import { log, note, NoteOptions, S_BAR_H, S_CONNECT_LEFT, spinner, SpinnerResult } from "@clack/prompts"; import { Result } from "neverthrow"; import { stripAnsi } from "../utils/string-utils.js"; +export interface CancellableSpinner { + spin: SpinnerResult; + dispose: () => void; +} + +// `@clack/core`'s `block()`, which every spinner starts, owns the Ctrl+C key: its stdin +// keypress listener calls `process.exit(0)`. Raw mode also stops the terminal raising SIGINT, +// so a spinner the user may sit in front of for minutes has no reachable cancel seam — the +// process dies mid-await, reporting success, and the spinner's `exit` listener prints its +// cancel line on the way out. Owning that key for the spinner's lifetime is the only fix +// clack leaves room for; `onCancel` still covers SIGTERM, which clack does handle. +export function startCancellableSpinner(message: string, onCancel: () => void): CancellableSpinner { + const input = process.stdin; + const listenersBeforeStart = new Set(input.rawListeners('keypress')); + + const spin = spinner({ onCancel }); + spin.start(message); + + for (const listener of input.rawListeners('keypress')) { + if (!listenersBeforeStart.has(listener)) { + input.off('keypress', listener as (...args: unknown[]) => void); + } + } + + const onKeypress = (_char: string, key?: { name?: string; ctrl?: boolean }) => { + if (key?.ctrl && key.name === 'c') { + onCancel(); + } + }; + input.on('keypress', onKeypress); + + return { + spin, + dispose: () => { + input.off('keypress', onKeypress); + } + }; +} + export async function withSpinner(intro: string, success: string, failure: string, fn: Promise>) { const s = spinner({ cancelMessage: "cancelled", diff --git a/src/prompts/sdk/publish.ts b/src/prompts/sdk/publish.ts index 5fee790d..8870f698 100644 --- a/src/prompts/sdk/publish.ts +++ b/src/prompts/sdk/publish.ts @@ -1,4 +1,4 @@ -import { log, spinner } from '@clack/prompts'; +import { log } from '@clack/prompts'; import { Result } from 'neverthrow'; import { ServiceError } from '../../infrastructure/service-error.js'; import { PublishLogItem } from '../../types/publish-api/publish-log.js'; @@ -6,7 +6,7 @@ import { PublishingInfo } from '../../types/publish-api/publishing-info.js'; import { PublishType } from '../../types/publish-api/publishing-profile-item.js'; import { SemVersion } from '../../types/publish/version.js'; import { Language } from '../../types/sdk/generate.js'; -import { noteWrapped, withSpinner } from '../prompt.js'; +import { noteWrapped, startCancellableSpinner, withSpinner } from '../prompt.js'; import { format as f } from '../format.js'; import { PublishingProfile } from '../../types/publish/publishing-profile.js'; @@ -61,60 +61,63 @@ ${f.link(publishingLogUrl)}`; let cancelled = false; let abortWait: (() => void) | undefined; - // The spinner prints its own cancel line and removes its signal listeners on Ctrl+C, so a - // cancelled poll must return without stopping the spinner again. Without this callback the - // loop would keep polling invisibly until a second Ctrl+C killed the process. - const spin = spinner({ - onCancel: () => { - cancelled = true; - abortWait?.(); - } + const { spin, dispose } = startCancellableSpinner('Waiting for publishing status...', () => { + cancelled = true; + // Aborting the pending timer keeps Ctrl+C immediate instead of up to 10 s late. + abortWait?.(); }); - spin.start('Waiting for publishing status...'); - - while (!cancelled) { - const publishingLogResult = await getSdkPublishingLogFn(); - if (cancelled) break; - - if (publishingLogResult.isErr()) { - spin.stop('Failed to fetch publishing status.', 1); - return 'failed'; - } - - const { events } = publishingLogResult.value; - const executionCompleted = events.every((event) => TERMINAL_STATES.has(event.eventType)); - const statusMessage = [...events] - .sort((a, b) => (a.publishType === 'SourceCode' ? -1 : b.publishType === 'SourceCode' ? 1 : 0)) - .map((event) => { - const target = event.publishType === 'SourceCode' ? 'Source Code' : 'Package'; - const eventLabels: Record = { - Queued: 'Queued', - InProgress: 'In Progress', - Succeeded: 'Published' + try { + while (!cancelled) { + const publishingLogResult = await getSdkPublishingLogFn(); + if (cancelled) break; + + if (publishingLogResult.isErr()) { + spin.stop('Failed to fetch publishing status.', 1); + return 'failed'; + } + + const { events } = publishingLogResult.value; + const executionCompleted = events.every((event) => TERMINAL_STATES.has(event.eventType)); + const statusMessage = [...events] + .sort((a, b) => (a.publishType === 'SourceCode' ? -1 : b.publishType === 'SourceCode' ? 1 : 0)) + .map((event) => { + const target = event.publishType === 'SourceCode' ? 'Source Code' : 'Package'; + const eventLabels: Record = { + Queued: 'Queued', + InProgress: 'In Progress', + Succeeded: 'Published' + }; + const label = eventLabels[event.eventType] ?? 'Failed'; + return `${target}: [${label}]`; + }) + .join(' | '); + + if (executionCompleted) { + const isExecutionSuccessful = events.every((event) => event.eventType === 'Succeeded'); + spin.stop(statusMessage, isExecutionSuccessful ? 0 : 1); + return isExecutionSuccessful ? 'succeeded' : 'failed'; + } + + spin.message(statusMessage); + await new Promise((resolve) => { + const timer = setTimeout(resolve, POLL_INTERVAL_MS); + abortWait = () => { + clearTimeout(timer); + resolve(); }; - const label = eventLabels[event.eventType] ?? 'Failed'; - return `${target}: [${label}]`; - }) - .join(' | '); - - if (executionCompleted) { - const isExecutionSuccessful = events.every((event) => event.eventType === 'Succeeded'); - spin.stop(statusMessage, isExecutionSuccessful ? 0 : 1); - return isExecutionSuccessful ? 'succeeded' : 'failed'; + }); + abortWait = undefined; } - spin.message(statusMessage); - await new Promise((resolve) => { - const timer = setTimeout(resolve, POLL_INTERVAL_MS); - abortWait = () => { - clearTimeout(timer); - resolve(); - }; - }); - abortWait = undefined; + // A SIGTERM cancel goes through clack's own handling, which has already printed its + // cancel line and torn the spinner down. + if (!spin.isCancelled) { + spin.stop('Cancelled waiting for publishing status.', 1); + } + return 'cancelled'; + } finally { + dispose(); } - - return 'cancelled'; } } From d296a326ce9ff14d2a155c7b18afbfe69371b53e Mon Sep 17 00:00:00 2001 From: Muhammad Rafay Nadeem Date: Tue, 11 Aug 2026 11:59:36 +0500 Subject: [PATCH 04/12] Revert "fix: reach the cancel notice when Ctrl+C stops the publish wait" This reverts commit 221cb63e609c1254135606db8f09f0c2b5d29569. --- .ai/notes/interactive-sdk-publish-polling.md | 28 ++--- src/prompts/prompt.ts | 41 +------- src/prompts/sdk/publish.ts | 105 +++++++++---------- 3 files changed, 58 insertions(+), 116 deletions(-) diff --git a/.ai/notes/interactive-sdk-publish-polling.md b/.ai/notes/interactive-sdk-publish-polling.md index c82a06aa..ac55b0f5 100644 --- a/.ai/notes/interactive-sdk-publish-polling.md +++ b/.ai/notes/interactive-sdk-publish-polling.md @@ -45,32 +45,18 @@ Two smaller calls, made without discussion: `sourceCodeOnlyPublishingNotice` sta ## The cancellation bug -Worth recording because it is not obvious from the clack API, and because the first attempt at it was wrong. +Worth recording because it is not obvious from the clack API. -`@clack/prompts@1.0.0-alpha.1`'s **spinner** contains no `process.exit`. On SIGINT it stops the spinner (printing its cancel line), calls an optional `onCancel`, sets `isCancelled`, and removes its own signal listeners. That reading is correct as far as it goes, and it is what the first fix (commit `cd38dd0`) was built on: pass `onCancel` to `spinner()` to flag cancellation and clear the pending 10 s `setTimeout`, then return **without** calling `spin.stop()` again. +`@clack/prompts@1.0.0-alpha.1`'s spinner contains **no `process.exit`**. On SIGINT it stops the spinner (printing its cancel line), calls an optional `onCancel`, sets `isCancelled`, and *removes its own signal listeners*. Because a SIGINT listener was registered, Node's default termination is suppressed while the spinner runs. -That fix never ran. `spin.start()` calls `block()` from `@clack/core`, which puts stdin in raw mode and registers a **keypress** listener that calls **`process.exit(0)`** as soon as it sees a key aliased to `cancel` — `\x03` (Ctrl+C) or `escape`. Raw mode also stops the terminal from raising SIGINT at all, so on Ctrl+C: +So the old `while (true)` loop, on Ctrl+C: printed the cancel line, then **kept polling invisibly with a dead spinner**, and only a second Ctrl+C killed the process (by then clack had removed its listener, so the default handler applied). Non-interactive is mostly CI, where nobody presses Ctrl+C; making interactive wait promoted this to a likely path. -1. `block()`'s keypress listener calls `process.exit(0)` synchronously. -2. The spinner's `process.on('exit')` listener fires with code `0`, so it prints its cancel line with the **green submit symbol** (`◇ Canceled`, not red `■`) and, because that path sets `isCancelled = false`, **never calls `onCancel`**. -3. The process is gone. `pollPublishingStatus` never returns, the running notice and log URL never print, and the CLI reports **exit code 0** for an abandoned publish. - -The `◇` rather than `■` in a bug report is the tell that this path, not SIGINT, was taken. - -Because clack offers no way to opt out of `block()` (`spinner()` takes `output` and `signal`, never `input`) and `updateSettings` can only *add* key aliases, never remove them, the only seam left is to take the key back. `startCancellableSpinner` in `src/prompts/prompt.ts` starts the spinner, diffs stdin's `keypress` listeners against a snapshot taken before `start()` to remove exactly the one `block()` added, and installs its own Ctrl+C listener that flags cancellation and aborts the pending timer. `dispose()` in a `finally` removes it again. - -Consequences worth knowing: - -- **The poll now stops its own spinner.** With clack's hard exit gone, nothing prints a cancel line, so the cancelled branch calls `spin.stop(...)` itself — guarded by `if (!spin.isCancelled)`, because SIGTERM still reaches clack's own handler, which prints and tears down first. Both paths therefore produce exactly one cancel line. -- **`spin.stop()` still performs teardown** (cursor restore, raw mode off, readline close) via the unblock closure `block()` returned, which is untouched. -- **Escape no longer aborts the wait.** It used to, by falling into the same `process.exit(0)`; only Ctrl+C is honoured now, which is the documented escape hatch anyway. -- **Keystrokes during the wait are no longer erased.** `block()`'s listener also redrew over stray input; the spinner's own 80 ms repaint (`\x1b[999D\x1b[J`) already covers same-line garbage, so the only visible artifact is one stale spinner line per Enter press. +The fix passes `onCancel` to `spinner()`, which both flags cancellation and clears the pending 10 s `setTimeout`, so the wait aborts immediately instead of up to 10 s later. A cancelled poll returns **without** calling `spin.stop()` again — clack has already printed its line and torn down its listeners. ## Files touched | File | Change | | --- | --- | -| `src/prompts/prompt.ts` | New `startCancellableSpinner` — a spinner whose Ctrl+C is observable rather than fatal (see the cancellation bug) | | `src/prompts/sdk/publish.ts` | Shared prompts gain `publishingRunningNotice`, `postPublishingMessage`, `publishingWaitCancelledNotice`, `pollPublishingStatus`, and `export type PublishingOutcome` | | `src/actions/sdk/publish.ts` | Polls after `withDirPath` closes; maps outcome to `ActionResult`; return type narrowed to `Promise` | | `src/actions/sdk/publish/interactive.ts` | Dropped the fire-and-forget note; now only inspects the result | @@ -82,7 +68,6 @@ Consequences worth knowing: - [#312](https://github.com/apimatic/apimatic-cli/issues/312) — missing array-length check makes an empty publish log report a false success; also carries the no-retry-tolerance and unbounded-wait notes. - [#313](https://github.com/apimatic/apimatic-cli/issues/313) — no telemetry for publishes that fail *after* being accepted. `SdkPublishValidationFailedEvent` models a rejected publish request; folding remote failures into it would corrupt that metric, so it wants its own event. -- Every other long wait behind `withSpinner` still has clack's `process.exit(0)` on Ctrl+C: portal generation (`pollUntilCompleted` in `infrastructure/services/portal-service.ts`) and the device-code login loop (`actions/auth/login.ts`). They exit `0` with a green `◇ cancelled` and no notice. `startCancellableSpinner` is the seam to fix them with; not done here because each needs its own decision about what to print and return on cancel. ## Verification @@ -93,6 +78,5 @@ Manual matrix (requires a real publishing profile): 1. Interactive, Package + Source Code → polls to `[Published] | [Published]`, exit `0`. 2. Interactive, package-only → one target in the status line. 3. Interactive, a publish that fails → exit `1`, log URL printed. -4. Interactive, Ctrl+C mid-poll → immediate `■ Cancelled waiting for publishing status.`, "still running" notice, log URL, exit `130`. Verified by driving `pollPublishingStatus` from the built output in a child process and writing `\x03` to its stdin: it returns `'cancelled'`, the caller's notices print, and the process exits `130`. Before the fix the same harness died at `process.exit(0)` with nothing after the cancel line. -5. Interactive, Ctrl+C pressed repeatedly → one cancel line, one notice, exit `130`; the replacement listener is idempotent and nothing hard-exits. Verified in the same harness with three `\x03` writes. -6. Non-interactive regression → output unchanged from before this change. +4. Interactive, Ctrl+C mid-poll → immediate clean exit `130`, "still running" notice, log URL. +5. Non-interactive regression → output unchanged from before this change. diff --git a/src/prompts/prompt.ts b/src/prompts/prompt.ts index 3a4e2ca5..4a2dbcab 100644 --- a/src/prompts/prompt.ts +++ b/src/prompts/prompt.ts @@ -1,49 +1,10 @@ import type { Writable } from "node:stream"; import pc from "picocolors"; import { getColumns } from "@clack/core"; -import { log, note, NoteOptions, S_BAR_H, S_CONNECT_LEFT, spinner, SpinnerResult } from "@clack/prompts"; +import { log, note, NoteOptions, S_BAR_H, S_CONNECT_LEFT, spinner } from "@clack/prompts"; import { Result } from "neverthrow"; import { stripAnsi } from "../utils/string-utils.js"; -export interface CancellableSpinner { - spin: SpinnerResult; - dispose: () => void; -} - -// `@clack/core`'s `block()`, which every spinner starts, owns the Ctrl+C key: its stdin -// keypress listener calls `process.exit(0)`. Raw mode also stops the terminal raising SIGINT, -// so a spinner the user may sit in front of for minutes has no reachable cancel seam — the -// process dies mid-await, reporting success, and the spinner's `exit` listener prints its -// cancel line on the way out. Owning that key for the spinner's lifetime is the only fix -// clack leaves room for; `onCancel` still covers SIGTERM, which clack does handle. -export function startCancellableSpinner(message: string, onCancel: () => void): CancellableSpinner { - const input = process.stdin; - const listenersBeforeStart = new Set(input.rawListeners('keypress')); - - const spin = spinner({ onCancel }); - spin.start(message); - - for (const listener of input.rawListeners('keypress')) { - if (!listenersBeforeStart.has(listener)) { - input.off('keypress', listener as (...args: unknown[]) => void); - } - } - - const onKeypress = (_char: string, key?: { name?: string; ctrl?: boolean }) => { - if (key?.ctrl && key.name === 'c') { - onCancel(); - } - }; - input.on('keypress', onKeypress); - - return { - spin, - dispose: () => { - input.off('keypress', onKeypress); - } - }; -} - export async function withSpinner(intro: string, success: string, failure: string, fn: Promise>) { const s = spinner({ cancelMessage: "cancelled", diff --git a/src/prompts/sdk/publish.ts b/src/prompts/sdk/publish.ts index 8870f698..5fee790d 100644 --- a/src/prompts/sdk/publish.ts +++ b/src/prompts/sdk/publish.ts @@ -1,4 +1,4 @@ -import { log } from '@clack/prompts'; +import { log, spinner } from '@clack/prompts'; import { Result } from 'neverthrow'; import { ServiceError } from '../../infrastructure/service-error.js'; import { PublishLogItem } from '../../types/publish-api/publish-log.js'; @@ -6,7 +6,7 @@ import { PublishingInfo } from '../../types/publish-api/publishing-info.js'; import { PublishType } from '../../types/publish-api/publishing-profile-item.js'; import { SemVersion } from '../../types/publish/version.js'; import { Language } from '../../types/sdk/generate.js'; -import { noteWrapped, startCancellableSpinner, withSpinner } from '../prompt.js'; +import { noteWrapped, withSpinner } from '../prompt.js'; import { format as f } from '../format.js'; import { PublishingProfile } from '../../types/publish/publishing-profile.js'; @@ -61,63 +61,60 @@ ${f.link(publishingLogUrl)}`; let cancelled = false; let abortWait: (() => void) | undefined; - const { spin, dispose } = startCancellableSpinner('Waiting for publishing status...', () => { - cancelled = true; - // Aborting the pending timer keeps Ctrl+C immediate instead of up to 10 s late. - abortWait?.(); + // The spinner prints its own cancel line and removes its signal listeners on Ctrl+C, so a + // cancelled poll must return without stopping the spinner again. Without this callback the + // loop would keep polling invisibly until a second Ctrl+C killed the process. + const spin = spinner({ + onCancel: () => { + cancelled = true; + abortWait?.(); + } }); - try { - while (!cancelled) { - const publishingLogResult = await getSdkPublishingLogFn(); - if (cancelled) break; - - if (publishingLogResult.isErr()) { - spin.stop('Failed to fetch publishing status.', 1); - return 'failed'; - } - - const { events } = publishingLogResult.value; - const executionCompleted = events.every((event) => TERMINAL_STATES.has(event.eventType)); - const statusMessage = [...events] - .sort((a, b) => (a.publishType === 'SourceCode' ? -1 : b.publishType === 'SourceCode' ? 1 : 0)) - .map((event) => { - const target = event.publishType === 'SourceCode' ? 'Source Code' : 'Package'; - const eventLabels: Record = { - Queued: 'Queued', - InProgress: 'In Progress', - Succeeded: 'Published' - }; - const label = eventLabels[event.eventType] ?? 'Failed'; - return `${target}: [${label}]`; - }) - .join(' | '); - - if (executionCompleted) { - const isExecutionSuccessful = events.every((event) => event.eventType === 'Succeeded'); - spin.stop(statusMessage, isExecutionSuccessful ? 0 : 1); - return isExecutionSuccessful ? 'succeeded' : 'failed'; - } - - spin.message(statusMessage); - await new Promise((resolve) => { - const timer = setTimeout(resolve, POLL_INTERVAL_MS); - abortWait = () => { - clearTimeout(timer); - resolve(); - }; - }); - abortWait = undefined; + spin.start('Waiting for publishing status...'); + + while (!cancelled) { + const publishingLogResult = await getSdkPublishingLogFn(); + if (cancelled) break; + + if (publishingLogResult.isErr()) { + spin.stop('Failed to fetch publishing status.', 1); + return 'failed'; } - // A SIGTERM cancel goes through clack's own handling, which has already printed its - // cancel line and torn the spinner down. - if (!spin.isCancelled) { - spin.stop('Cancelled waiting for publishing status.', 1); + const { events } = publishingLogResult.value; + const executionCompleted = events.every((event) => TERMINAL_STATES.has(event.eventType)); + const statusMessage = [...events] + .sort((a, b) => (a.publishType === 'SourceCode' ? -1 : b.publishType === 'SourceCode' ? 1 : 0)) + .map((event) => { + const target = event.publishType === 'SourceCode' ? 'Source Code' : 'Package'; + const eventLabels: Record = { + Queued: 'Queued', + InProgress: 'In Progress', + Succeeded: 'Published' + }; + const label = eventLabels[event.eventType] ?? 'Failed'; + return `${target}: [${label}]`; + }) + .join(' | '); + + if (executionCompleted) { + const isExecutionSuccessful = events.every((event) => event.eventType === 'Succeeded'); + spin.stop(statusMessage, isExecutionSuccessful ? 0 : 1); + return isExecutionSuccessful ? 'succeeded' : 'failed'; } - return 'cancelled'; - } finally { - dispose(); + + spin.message(statusMessage); + await new Promise((resolve) => { + const timer = setTimeout(resolve, POLL_INTERVAL_MS); + abortWait = () => { + clearTimeout(timer); + resolve(); + }; + }); + abortWait = undefined; } + + return 'cancelled'; } } From 27b657efa3df8d2a72c21263796d947846a673d5 Mon Sep 17 00:00:00 2001 From: Muhammad Rafay Nadeem Date: Tue, 11 Aug 2026 12:12:32 +0500 Subject: [PATCH 05/12] fix: change logs link print order --- src/actions/sdk/publish.ts | 3 +-- src/prompts/sdk/publish.ts | 6 +++--- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/src/actions/sdk/publish.ts b/src/actions/sdk/publish.ts index 24555410..b1355a23 100644 --- a/src/actions/sdk/publish.ts +++ b/src/actions/sdk/publish.ts @@ -115,6 +115,7 @@ export class SdkPublishAction { const publishingInfo = publishResult.getValue(); this.prompts.publishingRunningNotice(publishingProfile, language, semVersion, publishType); + this.prompts.publishingLogsMessage(publishingInfo.publishingLogUrl); const publishingOutcome = await this.prompts.pollPublishingStatus(() => this.publishingApiService.getSdkPublishingLog( @@ -128,8 +129,6 @@ export class SdkPublishAction { this.prompts.publishingWaitCancelledNotice(); } - this.prompts.postPublishingMessage(publishingInfo.publishingLogUrl); - switch (publishingOutcome) { case 'succeeded': return ActionResult.success(); diff --git a/src/prompts/sdk/publish.ts b/src/prompts/sdk/publish.ts index 5fee790d..379365e4 100644 --- a/src/prompts/sdk/publish.ts +++ b/src/prompts/sdk/publish.ts @@ -47,10 +47,10 @@ export class SdkPublishPrompts { log.info('Publishing is still running on APIMatic and will continue without the CLI.'); } - public postPublishingMessage(publishingLogUrl: string) { - const message = `To view publishing logs, please visit: + public publishingLogsMessage(publishingLogUrl: string) { + const message = `To track progress and view publishing logs, please visit: ${f.link(publishingLogUrl)}`; - noteWrapped(message, 'Next Steps'); + noteWrapped(message, 'Publishing Logs'); } public async pollPublishingStatus( From defa0c1ab1d93805488e06d8fe6102c3eb671d99 Mon Sep 17 00:00:00 2001 From: Muhammad Rafay Nadeem Date: Tue, 11 Aug 2026 12:42:43 +0500 Subject: [PATCH 06/12] refactor: use existing prop instead of bool --- src/prompts/sdk/publish.ts | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/src/prompts/sdk/publish.ts b/src/prompts/sdk/publish.ts index 379365e4..eebf1e9c 100644 --- a/src/prompts/sdk/publish.ts +++ b/src/prompts/sdk/publish.ts @@ -59,23 +59,21 @@ ${f.link(publishingLogUrl)}`; const TERMINAL_STATES = new Set(['Succeeded', 'Failed', 'Exception', 'InternalError']); const POLL_INTERVAL_MS = 10000; // poll after every 10 seconds. - let cancelled = false; let abortWait: (() => void) | undefined; // The spinner prints its own cancel line and removes its signal listeners on Ctrl+C, so a // cancelled poll must return without stopping the spinner again. Without this callback the // loop would keep polling invisibly until a second Ctrl+C killed the process. const spin = spinner({ onCancel: () => { - cancelled = true; abortWait?.(); } }); spin.start('Waiting for publishing status...'); - while (!cancelled) { + while (!spin.isCancelled) { const publishingLogResult = await getSdkPublishingLogFn(); - if (cancelled) break; + if (spin.isCancelled) break; if (publishingLogResult.isErr()) { spin.stop('Failed to fetch publishing status.', 1); From 0c0d743ebf9b8988c77749c0a99247dc592ffd12 Mon Sep 17 00:00:00 2001 From: Muhammad Rafay Nadeem Date: Tue, 11 Aug 2026 12:50:03 +0500 Subject: [PATCH 07/12] fix: don't recheck after getting sdk publishing log --- src/prompts/sdk/publish.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/prompts/sdk/publish.ts b/src/prompts/sdk/publish.ts index eebf1e9c..75248d01 100644 --- a/src/prompts/sdk/publish.ts +++ b/src/prompts/sdk/publish.ts @@ -73,7 +73,6 @@ ${f.link(publishingLogUrl)}`; while (!spin.isCancelled) { const publishingLogResult = await getSdkPublishingLogFn(); - if (spin.isCancelled) break; if (publishingLogResult.isErr()) { spin.stop('Failed to fetch publishing status.', 1); From 49ce01644eb971244b77c06cd4eb2407e716cad2 Mon Sep 17 00:00:00 2001 From: Muhammad Rafay Nadeem Date: Tue, 11 Aug 2026 12:52:12 +0500 Subject: [PATCH 08/12] refactor: remove abort handling --- src/prompts/sdk/publish.ts | 19 ++----------------- 1 file changed, 2 insertions(+), 17 deletions(-) diff --git a/src/prompts/sdk/publish.ts b/src/prompts/sdk/publish.ts index 75248d01..f2782392 100644 --- a/src/prompts/sdk/publish.ts +++ b/src/prompts/sdk/publish.ts @@ -59,15 +59,7 @@ ${f.link(publishingLogUrl)}`; const TERMINAL_STATES = new Set(['Succeeded', 'Failed', 'Exception', 'InternalError']); const POLL_INTERVAL_MS = 10000; // poll after every 10 seconds. - let abortWait: (() => void) | undefined; - // The spinner prints its own cancel line and removes its signal listeners on Ctrl+C, so a - // cancelled poll must return without stopping the spinner again. Without this callback the - // loop would keep polling invisibly until a second Ctrl+C killed the process. - const spin = spinner({ - onCancel: () => { - abortWait?.(); - } - }); + const spin = spinner(); spin.start('Waiting for publishing status...'); @@ -102,14 +94,7 @@ ${f.link(publishingLogUrl)}`; } spin.message(statusMessage); - await new Promise((resolve) => { - const timer = setTimeout(resolve, POLL_INTERVAL_MS); - abortWait = () => { - clearTimeout(timer); - resolve(); - }; - }); - abortWait = undefined; + await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS)); } return 'cancelled'; From 7e561630822b0b0cf3b7495ef2d87232a9ed362a Mon Sep 17 00:00:00 2001 From: Muhammad Rafay Nadeem Date: Tue, 11 Aug 2026 12:54:08 +0500 Subject: [PATCH 09/12] Revert "refactor: remove abort handling" This reverts commit 49ce01644eb971244b77c06cd4eb2407e716cad2. --- src/prompts/sdk/publish.ts | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/src/prompts/sdk/publish.ts b/src/prompts/sdk/publish.ts index f2782392..75248d01 100644 --- a/src/prompts/sdk/publish.ts +++ b/src/prompts/sdk/publish.ts @@ -59,7 +59,15 @@ ${f.link(publishingLogUrl)}`; const TERMINAL_STATES = new Set(['Succeeded', 'Failed', 'Exception', 'InternalError']); const POLL_INTERVAL_MS = 10000; // poll after every 10 seconds. - const spin = spinner(); + let abortWait: (() => void) | undefined; + // The spinner prints its own cancel line and removes its signal listeners on Ctrl+C, so a + // cancelled poll must return without stopping the spinner again. Without this callback the + // loop would keep polling invisibly until a second Ctrl+C killed the process. + const spin = spinner({ + onCancel: () => { + abortWait?.(); + } + }); spin.start('Waiting for publishing status...'); @@ -94,7 +102,14 @@ ${f.link(publishingLogUrl)}`; } spin.message(statusMessage); - await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS)); + await new Promise((resolve) => { + const timer = setTimeout(resolve, POLL_INTERVAL_MS); + abortWait = () => { + clearTimeout(timer); + resolve(); + }; + }); + abortWait = undefined; } return 'cancelled'; From 51ec92a0af434c5a17f10cc106fcf5e3497dcb11 Mon Sep 17 00:00:00 2001 From: Muhammad Rafay Nadeem Date: Tue, 11 Aug 2026 13:58:43 +0500 Subject: [PATCH 10/12] fix: update cancel message --- src/actions/sdk/publish.ts | 4 ---- src/prompts/sdk/publish.ts | 7 ++----- 2 files changed, 2 insertions(+), 9 deletions(-) diff --git a/src/actions/sdk/publish.ts b/src/actions/sdk/publish.ts index b1355a23..086301cf 100644 --- a/src/actions/sdk/publish.ts +++ b/src/actions/sdk/publish.ts @@ -125,10 +125,6 @@ export class SdkPublishAction { ) ); - if (publishingOutcome === 'cancelled') { - this.prompts.publishingWaitCancelledNotice(); - } - switch (publishingOutcome) { case 'succeeded': return ActionResult.success(); diff --git a/src/prompts/sdk/publish.ts b/src/prompts/sdk/publish.ts index 75248d01..a91152a2 100644 --- a/src/prompts/sdk/publish.ts +++ b/src/prompts/sdk/publish.ts @@ -43,10 +43,6 @@ export class SdkPublishPrompts { ); } - public publishingWaitCancelledNotice(): void { - log.info('Publishing is still running on APIMatic and will continue without the CLI.'); - } - public publishingLogsMessage(publishingLogUrl: string) { const message = `To track progress and view publishing logs, please visit: ${f.link(publishingLogUrl)}`; @@ -66,7 +62,8 @@ ${f.link(publishingLogUrl)}`; const spin = spinner({ onCancel: () => { abortWait?.(); - } + }, + cancelMessage: 'Publishing is still running on APIMatic and will continue without the CLI.' }); spin.start('Waiting for publishing status...'); From 9471af4760ffdc8648d2baf74398cb9184e1e36e Mon Sep 17 00:00:00 2001 From: Muhammad Rafay Nadeem Date: Tue, 11 Aug 2026 14:00:18 +0500 Subject: [PATCH 11/12] doc: remove comment --- src/prompts/sdk/publish.ts | 3 --- 1 file changed, 3 deletions(-) diff --git a/src/prompts/sdk/publish.ts b/src/prompts/sdk/publish.ts index a91152a2..99d0bd4f 100644 --- a/src/prompts/sdk/publish.ts +++ b/src/prompts/sdk/publish.ts @@ -56,9 +56,6 @@ ${f.link(publishingLogUrl)}`; const POLL_INTERVAL_MS = 10000; // poll after every 10 seconds. let abortWait: (() => void) | undefined; - // The spinner prints its own cancel line and removes its signal listeners on Ctrl+C, so a - // cancelled poll must return without stopping the spinner again. Without this callback the - // loop would keep polling invisibly until a second Ctrl+C killed the process. const spin = spinner({ onCancel: () => { abortWait?.(); From a7dfa76bf4484224dd0360cfd903b2409f3dbc07 Mon Sep 17 00:00:00 2001 From: Muhammad Rafay Nadeem Date: Tue, 11 Aug 2026 14:03:17 +0500 Subject: [PATCH 12/12] doc: remove design notes Co-Authored-By: Claude Opus 5 (1M context) --- .ai/notes/interactive-sdk-publish-polling.md | 82 -------------------- 1 file changed, 82 deletions(-) delete mode 100644 .ai/notes/interactive-sdk-publish-polling.md diff --git a/.ai/notes/interactive-sdk-publish-polling.md b/.ai/notes/interactive-sdk-publish-polling.md deleted file mode 100644 index ac55b0f5..00000000 --- a/.ai/notes/interactive-sdk-publish-polling.md +++ /dev/null @@ -1,82 +0,0 @@ -# Interactive `sdk publish` — poll for publish completion - -Design notes for the change on `fix/interactive-sdk-publish-polling` (commit `cd38dd0`, branched from `origin/dev` at `0a2ef4b`). Written 2026-08-10. - -## Problem - -The two modes of `apimatic sdk publish` disagreed about when the command was done. - -- **Interactive** (`actions/sdk/publish/interactive.ts`) returned as soon as the publishing API accepted the POST. It printed `sdkPublishingInProgress` — "To view the **status** of publishing, please visit…" — and returned `success()`, so the command exited `0` whether publishing later succeeded or failed. -- **Non-interactive** (`actions/sdk/publish/non-interactive.ts`) printed `publishingRunningNotice`, polled `getSdkPublishingLog` every 10 s to a terminal state, printed `postPublishingMessage`, and returned `failed()` when publishing did not succeed. - -`pollPublishingStatus` lived only on `SdkPublishNonInteractivePrompts`, even though a shared `SdkPublishPrompts` and a shared `SdkPublishAction` already existed and both modes went through them. - -## Decisions - -Each was decided explicitly; the rationale matters more than the choice. - -1. **Polling lives in the shared `SdkPublishAction`**, not duplicated per mode and not left as a shared prompt method that each mode action drives. Both modes did an identical post-POST sequence, so sharing the orchestration is what actually prevents the two paths drifting again. The poll runs *after* the `withDirPath` block closes, so the temp directory holding the zipped SDK is released before the CLI sits waiting for minutes. - -2. **The running notice prints in both modes.** Interactive already shows `publishingSummary` before `confirmPublishing`, so the four fields (profile / language / version / targets) appear twice — but a full SDK generation scrolls by in between, and suppressing it for interactive would mean threading a mode flag into the shared action. That flag is the first crack that lets the modes diverge. - -3. **One closing note, both outcomes**, reusing non-interactive's `postPublishingMessage` wording verbatim. The log URL is useful on success (package/repo links) and essential on failure. Interactive's `sdkPublishingInProgress` was deleted — publishing is no longer in flight when the command exits, so its wording had become wrong. - -4. **Ctrl+C mid-poll cancels the wait and exits 130**, printing "publishing is still running on APIMatic" plus the log URL. This also fixed a live bug — see below. - -5. **The completion predicate was left exactly as it was.** `events.every(...)` is vacuously true on an empty array, so an empty or partially-populated publish log reports instant false success. No guard was added, deliberately: see [#312](https://github.com/apimatic/apimatic-cli/issues/312). - -6. **Transient status-fetch errors still abort the poll** on the first failure. The message already distinguishes "Failed to fetch publishing status." from a publish failure, the log URL prints regardless, and a non-zero exit is honest because the CLI genuinely does not know the outcome. Retry tolerance is noted on #312. - -7. **The poll loop stays in the prompts layer**, moved verbatim into shared `SdkPublishPrompts`. `.ai/instructions.md` scopes prompts to terminal UI, and `infrastructure/services/portal-service.ts` has a `pollUntilCompleted` precedent for service-owned polling — but relocating the loop means inventing a status-callback seam across a layer boundary, which is a refactor riding along on a parity fix. - -8. **No timeout.** Any ceiling is a guess about the slowest legitimate publish, and guessing low turns a slow success into a reported failure. Ctrl+C is the interactive escape hatch; job timeouts are the CI one. - -9. **No automated tests.** `test/` has no publish coverage and no prompts tests at all. Meaningful coverage here needs either fake timers plus stdout capture against a live clack spinner, or dependency injection into `SdkPublishAction` (which constructs its own `SdkPublishPrompts`, `PublishingApiService`, and `GenerateAction`). Either is larger and riskier than the parity fix. Verified manually instead. - -10. **`SdkPublishAction.execute` returns `Promise`**, not `ActionResult`. Nothing reads the payload now that the action prints its own notes, and the old signature carried a hazard: the dry-run branch returns `ActionResult.success()` with no value, so `getValue()` on it throws. That was safe only because interactive hardcoded `dryRun: false`. - -11. **`pollPublishingStatus` returns `'succeeded' | 'failed' | 'cancelled'`**, exported from the prompts module (following `QuickstartFlow` in `prompts/quickstart.ts`). A boolean cannot express cancel-vs-fail, and keeping them distinct matters for planned work: a future PR will write a `plugin-config.json` recording successfully published packages, and its cancel path will need to warn that abandoning the wait breaks context-plugin generation. `ActionResult` stays out of the prompts layer. - -12. **The poller returns the outcome only**, not the terminal `PublishLogEventItem[]`. That data (`packageUrl`, `sourceUrl`, `languageVersion`) is what `plugin-config.json` will be built from, but that PR knows which fields it needs and where the write belongs; widening one internal method with two call sites is cheap later. - -13. **The cancel message stays generic** — publishing continues server-side, here is the log URL. Naming `plugin-config.json` before the CLI writes it would point users at nothing. - -Two smaller calls, made without discussion: `sourceCodeOnlyPublishingNotice` stays duplicated in both mode prompt classes (both mode *actions* call it before the shared action runs, so consolidating it is unrelated cleanup), and the methods moved to `SdkPublishPrompts` were removed from `SdkPublishNonInteractivePrompts` rather than left as forwarding wrappers. - -## The cancellation bug - -Worth recording because it is not obvious from the clack API. - -`@clack/prompts@1.0.0-alpha.1`'s spinner contains **no `process.exit`**. On SIGINT it stops the spinner (printing its cancel line), calls an optional `onCancel`, sets `isCancelled`, and *removes its own signal listeners*. Because a SIGINT listener was registered, Node's default termination is suppressed while the spinner runs. - -So the old `while (true)` loop, on Ctrl+C: printed the cancel line, then **kept polling invisibly with a dead spinner**, and only a second Ctrl+C killed the process (by then clack had removed its listener, so the default handler applied). Non-interactive is mostly CI, where nobody presses Ctrl+C; making interactive wait promoted this to a likely path. - -The fix passes `onCancel` to `spinner()`, which both flags cancellation and clears the pending 10 s `setTimeout`, so the wait aborts immediately instead of up to 10 s later. A cancelled poll returns **without** calling `spin.stop()` again — clack has already printed its line and torn down its listeners. - -## Files touched - -| File | Change | -| --- | --- | -| `src/prompts/sdk/publish.ts` | Shared prompts gain `publishingRunningNotice`, `postPublishingMessage`, `publishingWaitCancelledNotice`, `pollPublishingStatus`, and `export type PublishingOutcome` | -| `src/actions/sdk/publish.ts` | Polls after `withDirPath` closes; maps outcome to `ActionResult`; return type narrowed to `Promise` | -| `src/actions/sdk/publish/interactive.ts` | Dropped the fire-and-forget note; now only inspects the result | -| `src/actions/sdk/publish/non-interactive.ts` | Poll/notice/note block removed (moved into the shared action); behaviour unchanged | -| `src/prompts/sdk/publish/interactive.ts` | `sdkPublishingInProgress` deleted, unused imports pruned | -| `src/prompts/sdk/publish/non-interactive.ts` | Moved methods deleted, unused imports pruned | - -## Deferred - -- [#312](https://github.com/apimatic/apimatic-cli/issues/312) — missing array-length check makes an empty publish log report a false success; also carries the no-retry-tolerance and unbounded-wait notes. -- [#313](https://github.com/apimatic/apimatic-cli/issues/313) — no telemetry for publishes that fail *after* being accepted. `SdkPublishValidationFailedEvent` models a rejected publish request; folding remote failures into it would corrupt that metric, so it wants its own event. - -## Verification - -Automated: `pnpm build` and `pnpm lint` clean; `pnpm apimatic sdk publish --help` loads from the built output. - -Manual matrix (requires a real publishing profile): - -1. Interactive, Package + Source Code → polls to `[Published] | [Published]`, exit `0`. -2. Interactive, package-only → one target in the status line. -3. Interactive, a publish that fails → exit `1`, log URL printed. -4. Interactive, Ctrl+C mid-poll → immediate clean exit `130`, "still running" notice, log URL. -5. Non-interactive regression → output unchanged from before this change.