Skip to content

feat: add createSpinner factory#5

Merged
scottlovegrove merged 4 commits into
mainfrom
scottl/extract-spinner
May 6, 2026
Merged

feat: add createSpinner factory#5
scottlovegrove merged 4 commits into
mainfrom
scottl/extract-spinner

Conversation

@scottlovegrove
Copy link
Copy Markdown
Collaborator

Summary

Extracts the spinner module that all three Doist CLIs duplicate (~120 LoC of nearly-identical code each). Ships as a createSpinner({ isDisabled }) factory so per-CLI opt-out signals (env vars like TD_SPINNER / TW_SPINNER / OL_SPINNER, and per-CLI flag parsing) stay where they belong.

import { createSpinner } from '@doist/cli-core'
import { shouldDisableSpinner } from './global-args.js'

export const {
    LoadingSpinner,
    withSpinner,
    startEarlySpinner,
    stopEarlySpinner,
    resetEarlySpinner,
} = createSpinner({ isDisabled: shouldDisableSpinner })

Each kit has its own closure-scoped early-spinner state, so two kits in the same process don't step on each other (covered by a regression test).

What's in the module

  • LoadingSpinner class with start / succeed / fail / stop. Honours isStdoutTTY(), isDisabled(), and options.noSpinner.
  • withSpinner(options, op) — runs an async operation flanked by a spinner.
  • startEarlySpinner(text?) / stopEarlySpinner / resetEarlySpinner — long-running spinner shown before command modules load. Intercepts process.stdout.write so the spinner auto-clears the moment any command output is produced.
  • LoadingSpinner.start adopts an existing early spinner if one's running rather than stacking a second one. stop and succeed release the adopted instance back to the pool so the next API call can re-adopt; fail always terminates.

CLI-specific bits left in each CLI

  • The isDisabled predicate (well-known signals + opt-out env var name + flag parsing) — passed in via config.
  • Default colour and early-spinner text — overridable via config (defaultColor, earlySpinnerText).

Deps

Adds chalk@5.6.2 and yocto-spinner@1.1.0 to cli-core's runtime dependencies — first non-builtin runtime deps for the package, but both are already pinned at these versions across all three consuming CLIs.

Test plan

  • npm run build
  • npm run check
  • npm test — 66 / 66 pass (21 new spinner tests across withSpinner, LoadingSpinner, and early-spinner behaviour, plus a kit-isolation regression)

🤖 Generated with Claude Code

Extracts the spinner module shared across todoist-cli, twist-cli, and
outline-cli — yocto-spinner + chalk wrapper, optional early-spinner
singleton with stdout interception, adopt/release semantics for
chained API calls, and a withSpinner helper.

Each consumer's `~120 LoC of duplicated spinner code can now reduce to
a thin wrapper:

    import { createSpinner } from '@doist/cli-core'
    import { shouldDisableSpinner } from './global-args.js'

    export const {
        LoadingSpinner,
        withSpinner,
        startEarlySpinner,
        stopEarlySpinner,
        resetEarlySpinner,
    } = createSpinner({ isDisabled: shouldDisableSpinner })

The factory returns a kit with its own closure-scoped early-spinner
state, so two consumers in the same process don't step on each other.

CLI-specific opt-out logic (TD_SPINNER vs TW_SPINNER vs OL_SPINNER, plus
each CLI's `--json` / `--ndjson` / `--no-spinner` parsing) deliberately
stays per-CLI — passed in via `isDisabled`.

Adds chalk + yocto-spinner as cli-core runtime deps (pinned to the same
versions all three CLIs already use).
Auto-generated by semantic-release on every release using the
conventional-commits preset (uses '*' for list items). oxfmt prefers
'-' and would flip them every release cycle; the file isn't
hand-edited so reformatting it adds churn without value.
Copy link
Copy Markdown
Member

@doistbot doistbot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This PR thoughtfully extracts the duplicated spinner logic across the CLIs into a centralized createSpinner factory with support for early and adopted spinners. Consolidating this module is a great step for consistency and maintainability across the command-line tools. A few adjustments are noted regarding early spinner state management, safe monkey-patching of standard output, duplicate success icons, and minor testing and API export refinements.

Share FeedbackReview Logs

Comment thread src/spinner.ts Outdated
Comment thread src/spinner.ts
Comment thread src/spinner.ts Outdated
Comment thread src/spinner.ts
Comment thread src/spinner.ts Outdated
Comment thread src/spinner.ts Outdated
Comment thread src/spinner.ts
Comment thread src/spinner.test.ts
Comment thread src/index.ts
Comment thread src/spinner.test.ts Outdated
Codifies a code-style preference in AGENTS.md and converts every
existing `interface` declaration in src/ to `type` (5 in src + 1 in
config.test.ts). `class implements TypeAlias` works fine for the one
spot that needed it (LoadingSpinnerImpl), so the change is purely
stylistic.

`LoadingSpinner.start()`'s return type changes from `this` to the
concrete `LoadingSpinner` because `this` types only work cleanly in
interface declarations / class methods, not in `type` aliases. No
caller chains beyond a single `.start()` so the precision loss is
academic.

Also adds an initial AGENTS.md covering the project's basic rules
(build/run scripts, code style, module layout, release flow).
P1 bugs:
- Adoption now restores stdout.write so the early-spinner interceptor
  doesn't keep firing against a now-null earlyInstance while the
  adopted spinner runs unattended. Tested.
- succeed(text) on an adopted spinner now terminates with the success
  line instead of silently releasing back to the pool. The release-
  silently chained-call pattern is preserved when succeed() is called
  with no text. Both paths covered.
- Drop the manual `✓ ` / `✗ ` prefixes — yocto-spinner.success/error
  prepend their own ✔ / ✖ glyphs. Tests now expect the bare text.

P2 cleanup:
- restoreStdoutWrite helper deduplicates the restore logic between
  stopEarlySpinner and resetEarlySpinner, and identity-checks the
  current process.stdout.write so it doesn't clobber a foreign hook
  layered on top.
- releaseAdopted helper deduplicates the release-back block between
  succeed and stop.
- Capture process.stdout.write in a local closure variable instead of
  .bind() so repeat startEarlySpinner calls don't nest binds.
- resetEarlySpinner annotated @internal — exposed for test isolation
  only; not part of the supported runtime API.

Tests:
- yocto-spinner mock now returns a fresh instance per call (and uses
  the input text), so isolation/adoption assertions verify real per-
  instance state instead of mutations on a shared object.
- Spy + swallow process.stdout.write so the auto-stop test no longer
  prints to the test runner output.
- index.test.ts pins createSpinner + the spinner type re-exports
  alongside the existing public-API anchors.
- New regressions: stdout-restore on adoption; succeed(text) on
  adopted; foreign stdout.write hook stays intact when restoring.
@scottlovegrove scottlovegrove self-assigned this May 6, 2026
@scottlovegrove scottlovegrove added the 👀 Show PR PR must be reviewed before or after merging label May 6, 2026
@scottlovegrove scottlovegrove merged commit 7092427 into main May 6, 2026
3 checks passed
@scottlovegrove scottlovegrove deleted the scottl/extract-spinner branch May 6, 2026 20:25
doist-release-bot Bot added a commit that referenced this pull request May 6, 2026
## [0.3.0](v0.2.0...v0.3.0) (2026-05-06)

### Features

* add createSpinner factory ([#5](#5)) ([7092427](7092427))
@doist-release-bot
Copy link
Copy Markdown
Contributor

🎉 This PR is included in version 0.3.0 🎉

The release is available on:

Your semantic-release bot 📦🚀

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

released 👀 Show PR PR must be reviewed before or after merging

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants