feat: AsyncIterable streaming support for library SDK#586
Conversation
Semver Impact of This PR🟡 Minor (new features) 📋 Changelog PreviewThis is how your changes will appear in the changelog. New Features ✨
Bug Fixes 🐛
Internal Changes 🔧
🤖 This preview updates automatically when you update the PR. |
|
Codecov Results 📊✅ 126 passed | Total: 126 | Pass Rate: 100% | Execution Time: 0ms 📊 Comparison with Base Branch
✨ No test changes detected All tests are passing successfully. ✅ Patch coverage is 100.00%. Project has 1267 uncovered lines. Coverage diff@@ Coverage Diff @@
## main #PR +/-##
==========================================
+ Coverage 95.60% 95.65% +0.05%
==========================================
Files 201 202 +1
Lines 29003 29143 +140
Branches 0 0 —
==========================================
+ Hits 27727 27876 +149
- Misses 1276 1267 -9
- Partials 0 0 —Generated by Codecov Action |
ff7decc to
5cfff1e
Compare
5cfff1e to
801921b
Compare
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 2 potential issues.
Autofix Details
Bugbot Autofix prepared fixes for both issues found in the latest run.
- ✅ Fixed: Streaming catch block missing process exit code fallback
- Added captureCtx?.context.process.exitCode fallback between extractExitCode(thrown) and 1 to match executeWithCapture's three-level fallback pattern.
- ✅ Fixed: Already-aborted signal ignored due to missing guard check
- Added .aborted guard checks before addEventListener in both log/list.ts and dashboard/view.ts to handle pre-aborted signals correctly.
Or push these changes by commenting:
@cursor push 34726e0dad
Preview (34726e0dad)
diff --git a/AGENTS.md b/AGENTS.md
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -893,81 +893,59 @@
### Architecture
-<!-- lore:019ce2be-39f1-7ad9-a4c5-4506b62f689c -->
-* **api-client.ts split into domain modules under src/lib/api/**: The original monolithic \`src/lib/api-client.ts\` (1,977 lines) was split into 12 focused domain modules under \`src/lib/api/\`: infrastructure.ts (shared helpers, types, raw requests), organizations.ts, projects.ts, teams.ts, repositories.ts, issues.ts, events.ts, traces.ts, logs.ts, seer.ts, trials.ts, users.ts. The original \`api-client.ts\` was converted to a ~100-line barrel re-export file preserving all existing import paths. The \`biome.jsonc\` override for \`noBarrelFile\` already includes \`api-client.ts\`. When adding new API functions, place them in the appropriate domain module under \`src/lib/api/\`, not in the barrel file.
+<!-- lore:019d2d10-671c-77d8-9dbc-c32d1604dcf7 -->
+* **AsyncIterable streaming for SDK blocked by four structural concerns**: AsyncIterable streaming for SDK implemented via AsyncChannel push/pull pattern. \`src/lib/async-channel.ts\` provides a dual-queue channel: producer calls \`push()\`/\`close()\`/\`error()\`, consumer iterates via \`for await...of\`. \`break\` triggers \`onReturn\` callback for cleanup. \`executeWithStream()\` in \`sdk-invoke.ts\` runs the command in background, pipes \`captureObject\` calls to the channel, and returns the channel immediately. Streaming detection: \`hasStreamingFlag()\` checks for \`--refresh\`/\`--follow\`/\`-f\`. \`buildInvoker\` accepts \`meta.streaming\` flag; \`buildRunner\` auto-detects from args. Abort wiring: \`AbortController\` created per stream, signal placed on fake \`process.abortSignal\`, \`channel.onReturn\` calls \`controller.abort()\`. Both \`log/list.ts\` and \`dashboard/view.ts\` check \`this.process?.abortSignal\` alongside SIGINT. Codegen generates callable interface overloads for streaming commands.
-<!-- lore:019d0b69-1430-74f0-8e9a-426a5c7b321d -->
-* **Bun compiled binary sourcemap options and size impact**: Binary build (\`script/build.ts\`) uses two steps: (1) \`Bun.build()\` produces \`dist-bin/bin.js\` + \`.map\` with \`sourcemap: "linked"\` and minification. (2) \`Bun.build()\` with \`compile: true\` produces native binary — no sourcemap embedded. Bun's compiled binaries use \`/$bunfs/root/bin.js\` as the virtual path in stack traces. Sourcemap upload must use \`--url-prefix '/$bunfs/root/'\` so Sentry can match frames. The upload runs \`sentry-cli sourcemaps inject dist-bin/\` first (adds debug IDs), then uploads both JS and map. Bun's compile step strips comments (including \`//# debugId=\`), but debug ID matching still works via the injected runtime snippet + URL prefix matching. Size: +0.04 MB gzipped vs +2.30 MB for inline sourcemaps. Without \`SENTRY\_AUTH\_TOKEN\`, upload is skipped gracefully.
+<!-- lore:019d2690-4df2-7ac8-82c4-54656d987339 -->
+* **Bundle uses esbuild with bun:sqlite polyfill plugin for Node.js compatibility**: \`script/bundle.ts\` uses esbuild to produce \`dist/index.cjs\` from \`src/index.ts\`. A \`bunSqlitePlugin\` replaces \`bun:sqlite\` imports with a polyfill. Build defines \`SENTRY\_CLI\_VERSION\` and \`SENTRY\_CLIENT\_ID\_BUILD\`, externalizes \`node:\*\` builtins. \`sentrySourcemapPlugin\` handles debug ID injection and sourcemap upload. After the main build, writes: (1) \`dist/bin.cjs\` — CLI wrapper with shebang/Node version check/warning suppression, (2) \`dist/index.d.cts\` — type declarations read from pre-built \`src/sdk.generated.d.cts\`. Both \`sdk.generated.\*\` files are gitignored and regenerated via \`generate:sdk\` script chained before \`bundle\` in \`package.json\`. Debug IDs solve sourcemap deduplication between npm bundle and bun compile builds.
-<!-- lore:019cb8ea-c6f0-75d8-bda7-e32b4e217f92 -->
-* **CLI telemetry DSN is public write-only — safe to embed in install script**: The CLI's Sentry DSN (\`SENTRY\_CLI\_DSN\` in \`src/lib/constants.ts\`) is a public write-only ingest key already baked into every binary. Safe to hardcode in install scripts. Opt-out: \`SENTRY\_CLI\_NO\_TELEMETRY=1\`.
+<!-- lore:019d2a93-55dd-7060-ba70-4cf2ae22ecfe -->
+* **CLI logic extracted from bin.ts into cli.ts for shared entry points**: \`src/cli.ts\` contains the full CLI runner extracted from \`bin.ts\`: \`runCompletion()\` (shell completion fast path), \`runCli()\` (full CLI with middleware — auto-auth, seer trial, unknown command telemetry), and \`startCli()\` (top-level dispatch). All functions are exported, no top-level execution. \`src/bin.ts\` is a thin ~30-line wrapper for bun compile that registers EPIPE/EIO stream error handlers and calls \`startCli()\`. The npm bin wrapper (\`dist/bin.cjs\`) is a ~300-byte generated script that \`require('./index.cjs').\_cli()\`. Both entry points share the same CLI logic via \`cli.ts\`.
-<!-- lore:019c978a-18b5-7a0d-a55f-b72f7789bdac -->
-* **cli.sentry.dev is served from gh-pages branch via GitHub Pages**: \`cli.sentry.dev\` is served from gh-pages branch via GitHub Pages. Craft's gh-pages target runs \`git rm -r -f .\` before extracting docs — persist extra files via \`postReleaseCommand\` in \`.craft.yml\`. Install script supports \`--channel nightly\`, downloading from the \`nightly\` release tag directly. version.json is only used by upgrade/version-check flow.
+<!-- lore:019d2690-4de7-75ed-b6c6-fb3deaf8871e -->
+* **getConfigDir and getAuthToken read global process.env directly**: \`src/lib/env.ts\` provides a module-level env registry: \`getEnv()\` defaults to \`process.env\`, \`setEnv(env)\` swaps it. Library entry (\`src/index.ts\`) calls \`setEnv({ ...process.env, ...overrides })\` before running commands, restores in \`finally\`. All ~14 files that previously read \`process.env.SENTRY\_\*\` directly now use \`getEnv().SENTRY\_\*\`. Key files ported: \`db/auth.ts\`, \`db/index.ts\`, \`db/schema.ts\`, \`constants.ts\`, \`resolve-target.ts\`, \`telemetry.ts\`, \`formatters/plain-detect.ts\`, \`sentry-url-parser.ts\` (which also WRITES to env), \`logger.ts\`, \`response-cache.ts\`, \`api/infrastructure.ts\`, \`dsn/env.ts\`, \`version-check.ts\`, \`oauth.ts\`. CLI mode never calls \`setEnv()\` so behavior is unchanged. Tests using \`useTestConfigDir()\` continue to work since \`getEnv()\` defaults to \`process.env\`.
-<!-- lore:019cbe93-19b8-7776-9705-20bbde226599 -->
-* **Nightly delta upgrade buildNightlyPatchGraph fetches ALL patch tags — O(N) HTTP calls**: Delta upgrade in \`src/lib/delta-upgrade.ts\` supports stable (GitHub Releases) and nightly (GHCR) channels. \`filterAndSortChainTags\` filters \`patch-\*\` tags by version range using \`Bun.semver.order()\`. GHCR uses \`fetchWithRetry\` (10s timeout + 1 retry; blobs 30s) with optional \`signal?: AbortSignal\` combined via \`AbortSignal.any()\`. \`isExternalAbort(error, signal)\` skips retries for external aborts — critical for background prefetch. Patches cached to \`~/.sentry/patch-cache/\` (file-based, 7-day TTY). \`loadCachedChain\` stitches patches for multi-hop offline upgrades.
+<!-- lore:019d26a2-8d2f-7a31-81aa-e665b4dde197 -->
+* **Library API: variadic sentry() function with last-arg options detection**: \`createSentrySDK(options?)\` in \`src/index.ts\` is the sole public API. Returns a typed SDK object with methods for every CLI command plus \`run()\` escape hatch. \`SentryOptions\` in \`src/lib/sdk-types.ts\`: \`token?\`, \`text?\` (run-only), \`cwd?\`, \`url?\` (self-hosted base URL → \`SENTRY\_HOST\`), \`org?\` (default org → \`SENTRY\_ORG\`), \`project?\` (default project → \`SENTRY\_PROJECT\`). Env isolation via \`buildIsolatedEnv(options)\` helper in \`sdk-invoke.ts\` — shared by both \`buildInvoker\` and \`buildRunner\`, maps each option to its env var. Zero-copy \`captureObject\` return, \`OutputError\` → data recovery. Default JSON output via \`SENTRY\_OUTPUT\_FORMAT=json\`. Non-zero exit throws \`SentryError\` with \`.exitCode\` and \`.stderr\`.
-<!-- lore:2c3eb7ab-1341-4392-89fd-d81095cfe9c4 -->
-* **npm bundle requires Node.js >= 22 due to node:sqlite polyfill**: The npm package (dist/bin.cjs) requires Node.js >= 22 because the bun:sqlite polyfill uses \`node:sqlite\`. A runtime version guard in the esbuild banner catches this early. When writing esbuild banner strings in TS template literals, double-escape: \`\\\\\\\n\` in TS → \`\\\n\` in output → newline at runtime. Single \`\\\n\` produces a literal newline inside a JS string, causing SyntaxError.
+<!-- lore:019d26a2-8d34-76ef-a855-c28a7e7796d1 -->
+* **Library mode telemetry strips all global-polluting Sentry integrations**: When \`initSentry(enabled, { libraryMode: true })\` is called, the Sentry SDK initializes without integrations that pollute the host process. \`LIBRARY\_EXCLUDED\_INTEGRATIONS\` extends the base set with: \`OnUncaughtException\`, \`OnUnhandledRejection\`, \`ProcessSession\` (process listeners), \`Http\`/\`NodeFetch\` (trace header injection), \`FunctionToString\` (wraps \`Function.prototype.toString\`), \`ChildProcess\`/\`NodeContext\`. Also disables \`enableLogs\` and \`sendClientReports\` (both use timers/\`beforeExit\`), and skips \`process.on('beforeExit')\` handler registration. Keeps pure integrations: \`eventFiltersIntegration\`, \`linkedErrorsIntegration\`. Library entry manually calls \`client.flush(3000)\` after command completion (both success and error paths via \`flushTelemetry()\` helper). Only unavoidable global: \`globalThis.\_\_SENTRY\_\_\[SDK\_VERSION]\`.
-<!-- lore:019c972c-9f0f-75cd-9e24-9bdbb1ac03d6 -->
-* **Numeric issue ID resolution returns org:undefined despite API success**: Numeric issue ID resolution in \`resolveNumericIssue()\`: (1) try DSN/env/config for org, (2) if found use \`getIssueInOrg(org, id)\` with region routing, (3) else fall back to unscoped \`getIssue(id)\`, (4) extract org from \`issue.permalink\` via \`parseSentryUrl\` as final fallback. \`parseSentryUrl\` handles path-based (\`/organizations/{org}/...\`) and subdomain-style URLs. \`matchSubdomainOrg()\` filters region subdomains by requiring slug length > 2. Self-hosted uses path-based only.
+<!-- lore:019d2a93-55d9-7bc9-add2-c14a9a5fdd69 -->
+* **Typed SDK uses direct Command.loader() invocation bypassing Stricli dispatch**: \`createSentrySDK(options?)\` in \`src/index.ts\` builds a typed namespace API (\`sdk.org.list()\`, \`sdk.issue.view()\`) generated by \`script/generate-sdk.ts\`. At runtime, \`src/lib/sdk-invoke.ts\` resolves commands via Stricli route tree, caches \`Command\` objects, and calls \`command.loader()\` directly — bypassing string dispatch and flag parsing. The standalone variadic \`sentry()\` function has been removed. Typed SDK methods are the primary path, with \`sdk.run()\` as an escape hatch for arbitrary CLI strings (interactive commands like \`auth login\`, raw \`api\` passthrough). The codegen auto-discovers ALL commands from the route tree with zero config, using CLI route names as-is (\`org.list\`, \`dashboard.widget.add\`). Return types are derived from \`\_\_jsonSchema\` when present, otherwise \`unknown\`. Positional patterns are derived from introspection placeholder strings. Hidden routes (plural aliases) are skipped.
-<!-- lore:019ce0bb-f35d-7380-b661-8dc56f9938cf -->
-* **Seer trial prompt uses middleware layering in bin.ts error handling chain**: Error recovery middlewares in \`bin.ts\` are layered: \`main() → executeWithAutoAuth() → executeWithSeerTrialPrompt() → runCommand()\`. Seer trial prompts (for \`no\_budget\`/\`not\_enabled\`) caught by inner wrapper; auth errors bubble to outer. Auth retry goes through full chain. Trial API: \`GET /api/0/customers/{org}/\` → \`productTrials\[]\` (prefer \`seerUsers\`, fallback \`seerAutofix\`). Start: \`PUT /api/0/customers/{org}/product-trial/\`. SaaS-only; self-hosted 404s gracefully. \`ai\_disabled\` excluded. \`startSeerTrial\` accepts \`category\` from trial object — don't hardcode.
-
-<!-- lore:019d1f97-563d-72f0-80a9-accaa6d9b282 -->
-* **SQLite DB functions are synchronous — async signatures are historical artifacts**: All \`src/lib/db/\` functions do synchronous SQLite operations (both \`bun:sqlite\` and the \`node:sqlite\` polyfill's \`DatabaseSync\` are sync). Many functions still have \`async\` signatures — this is a historical artifact from PR #89 which migrated config storage from JSON files (using async \`Bun.file().text()\` / \`Bun.write()\`) to SQLite. The function signatures were preserved to minimize diff size and never cleaned up. These can safely be converted to synchronous. Exceptions that ARE legitimately async: \`clearAuth()\` (cache dir cleanup), \`getCachedDetection()\`/\`getCachedProjectRoot()\`/\`setCachedProjectRoot()\` (stat for mtime), \`refreshToken()\`/\`performTokenRefresh()\` (HTTP calls).
-
### Decision
-<!-- lore:019c99d5-69f2-74eb-8c86-411f8512801d -->
-* **Raw markdown output for non-interactive terminals, rendered for TTY**: Markdown-first output pipeline: custom renderer in \`src/lib/formatters/markdown.ts\` walks \`marked\` tokens to produce ANSI-styled output. Commands build CommonMark using helpers (\`mdKvTable()\`, \`mdRow()\`, \`colorTag()\`, \`escapeMarkdownCell()\`, \`safeCodeSpan()\`) and pass through \`renderMarkdown()\`. \`isPlainOutput()\` precedence: \`SENTRY\_PLAIN\_OUTPUT\` > \`NO\_COLOR\` > \`FORCE\_COLOR\` > \`!isTTY\`. \`--json\` always outputs JSON. Colors defined in \`COLORS\` object in \`colors.ts\`. Tests run non-TTY so assertions match raw CommonMark; use \`stripAnsi()\` helper for rendered-mode assertions.
+<!-- lore:019d26a2-8d3b-770a-866c-10ca04a374e7 -->
+* **OutputError propagates via throw instead of process.exit()**: The \`process.exit()\` call in \`command.ts\` (OutputError handler) is replaced with \`throw err\` to support library mode. \`OutputError\` is re-thrown through Stricli via \`exceptionWhileRunningCommand\` in \`app.ts\` (added before the \`AuthError\` check), so Stricli never writes an error message for it. In CLI mode (\`cli.ts\`), OutputError is caught and \`process.exitCode\` is set silently without writing to stderr (data was already rendered). In library mode (\`index.ts\`), the catch block checks if \`capturedResult\` has data (the OutputError's payload was rendered to stdout via \`captureObject\` before the throw) and returns it instead of throwing \`SentryError\`. This eliminates the only \`process.exit()\` outside of \`bin.ts\`.
-<!-- lore:00166785-609d-4ab5-911e-ee205d17b90c -->
-* **whoami should be separate from auth status command**: The \`sentry auth whoami\` command should be a dedicated command separate from \`sentry auth status\`. They serve different purposes: \`status\` shows everything about auth state (token, expiry, defaults, org verification), while \`whoami\` just shows user identity (name, email, username, ID) by fetching live from \`/auth/\` endpoint. \`sentry whoami\` should be a top-level alias (like \`sentry issues\` → \`sentry issue list\`). \`whoami\` should support \`--json\` for machine consumption and be lightweight — no credential verification, no defaults listing.
+<!-- lore:019d2bd4-65c2-7b4c-99f7-cab41ee1ed71 -->
+* **SDK codegen moving to auto-generate all commands from route tree**: \`script/generate-sdk.ts\` walks the Stricli route tree via \`discoverCommands()\`, skipping hidden routes. For each command: extracts flags, derives positional params from placeholder strings, checks \`\_\_jsonSchema\` for typed return types. Naming uses CLI route path as-is: \`\["org", "list"]\` → \`sdk.org.list()\`. Generates TWO gitignored files: (1) \`src/sdk.generated.ts\` — runtime, (2) \`src/sdk.generated.d.cts\` — npm type declarations. \`generate:sdk\` is chained before \`typecheck\`, \`dev\`, \`build\`, \`build:all\`, \`bundle\`. \`INTERNAL\_FLAGS\` set excludes \`json\`, \`fields\`, \`refresh\`, \`follow\` from generated parameter types — streaming flags are library-incompatible. CI check \`bun run check:skill\` validates SKILL.md stays in sync.
### Gotcha
-<!-- lore:019c8ab6-d119-7365-9359-98ecf464b704 -->
-* **@sentry/api SDK passes Request object to custom fetch — headers lost on Node.js**: @sentry/api SDK calls \`\_fetch(request)\` with no init object. In \`authenticatedFetch\`, \`init\` is undefined so \`prepareHeaders\` creates empty headers — on Node.js this strips Content-Type (HTTP 415). Fix: fall back to \`input.headers\` when \`init\` is undefined. Use \`unwrapPaginatedResult\` (not \`unwrapResult\`) to access the Response's Link header for pagination. \`per\_page\` is not in SDK types; cast query to pass it at runtime.
+<!-- lore:019d2d39-3c7b-7bf6-ab73-778d2c51af15 -->
+* **Test mocks lack process property — use optional chaining on this.process**: Command \`func()\` methods access \`this: SentryContext\` which has \`this.process\`. But test mocks created via \`createMockContext()\` only provide \`stdout\`/\`stderr\`/\`cwd\` — no \`process\` property. Accessing \`this.process.abortSignal\` crashes with \`undefined is not an object\`. Fix: always use optional chaining \`(this.process as T)?.abortSignal\` or check \`this.process\` exists first. This applies to any new property added to the process-like object in \`sdk-invoke.ts\` that commands read via \`this.process\`.
-<!-- lore:019c9e98-7af4-7e25-95f4-fc06f7abf564 -->
-* **Bun binary build requires SENTRY\_CLIENT\_ID env var**: The build script (\`script/bundle.ts\`) requires \`SENTRY\_CLIENT\_ID\` environment variable and exits with code 1 if missing. When building locally, use \`bun run --env-file=.env.local build\` or set the env var explicitly. The binary build (\`bun run build\`) also needs it. Without it you get: \`Error: SENTRY\_CLIENT\_ID environment variable is required.\`
+### Pattern
-<!-- lore:019c9776-e3dd-7632-88b8-358a19506218 -->
-* **GitHub immutable releases prevent rolling nightly tag pattern**: getsentry/cli has immutable GitHub releases — assets can't be modified and tags can NEVER be reused. Nightly builds publish to GHCR with versioned tags like \`nightly-0.14.0-dev.1772661724\`, not GitHub Releases or npm. \`fetchManifest()\` throws \`UpgradeError("network\_error")\` for both network failures and non-200 — callers must check message for HTTP 404/403. Craft with no \`preReleaseCommand\` silently skips \`bump-version.sh\` if only target is \`github\`.
+<!-- lore:019d2c9b-8e26-7b87-b141-98db9fa943f1 -->
+* **buildIsolatedEnv helper centralizes SDK env setup**: \`buildIsolatedEnv(options?)\` in \`src/lib/sdk-invoke.ts\` maps \`SentryOptions\` fields to env vars (\`token\` → \`SENTRY\_AUTH\_TOKEN\`, \`url\` → \`SENTRY\_HOST\`, etc.) plus \`SENTRY\_OUTPUT\_FORMAT=json\` (unless \`text: true\`). The core dedup is \`executeWithCapture\<T>()\` which centralizes the env isolation → capture context → telemetry → error wrapping → output parsing pipeline. Both \`buildInvoker\` (typed methods) and \`buildRunner\` (\`run()\` escape hatch) are thin ~15-line wrappers providing only the executor callback. \`STREAMING\_FLAGS\` set (\`--refresh\`, \`--follow\`, \`-f\`) is checked in \`buildRunner\` before execution — throws \`SentryError\` immediately since streaming output is unsuitable for library mode. Same flags are in \`INTERNAL\_FLAGS\` in codegen so typed SDK methods can't trigger streaming.
-<!-- lore:019cb8c2-d7b5-780c-8a9f-d20001bc198f -->
-* **Install script: BSD sed and awk JSON parsing breaks OCI digest extraction**: The install script parses OCI manifests with awk (no jq). Key trap: BSD sed \`\n\` is literal, not newline. Fix: single awk pass tracking last-seen \`"digest"\`, printing when \`"org.opencontainers.image.title"\` matches target. The config digest (\`sha256:44136fa...\`) is a 2-byte \`{}\` blob — downloading it instead of the real binary causes \`gunzip: unexpected end of file\`.
+<!-- lore:019d2d39-3c7f-757d-b484-ac997581c36e -->
+* **SDK codegen callable interface pattern for streaming overloads**: In \`script/generate-sdk.ts\`, streaming-capable commands (those with flags in \`STREAMING\_FLAGS\` set) use a callable interface pattern instead of a simple method signature. This produces TypeScript overloaded signatures: \`(params?: T): Promise\<R>\` for non-streaming and \`(params: T & { follow: string }): AsyncIterable\<unknown>\` for streaming. At runtime, \`generateStreamingMethodBody()\` emits code that checks if any streaming flag is present in params, then passes \`{ streaming: true }\` meta to the invoker which branches to \`executeWithStream\` vs \`executeWithCapture\`. The \`STREAMING\_FLAGS\` set (\`refresh\`, \`follow\`) is separate from \`INTERNAL\_FLAGS\` — streaming flags ARE included in generated params but excluded from \`INTERNAL\_FLAGS\`.
-<!-- lore:019d0b04-ccec-7bd2-a5ca-732e7064cc1a -->
-* **Multi-region fan-out: distinguish all-403 from empty orgs with hasSuccessfulRegion flag**: In \`listOrganizationsUncached\` (\`src/lib/api/organizations.ts\`), \`Promise.allSettled\` collects multi-region results. Don't use \`flatResults.length === 0\` to detect all-regions-failed — a region returning 200 OK with zero orgs pushes nothing into \`flatResults\`. Track a \`hasSuccessfulRegion\` boolean on any \`"fulfilled"\` settlement. Only re-throw 403 \`ApiError\` when \`!hasSuccessfulRegion && lastScopeError\`.
+<!-- lore:019d26a2-8d37-72ea-b7bd-14fef8556fea -->
+* **SENTRY\_OUTPUT\_FORMAT env var enables JSON mode from env instead of --json flag**: In \`src/lib/command.ts\`, the \`wrappedFunc\` checks \`this.env?.SENTRY\_OUTPUT\_FORMAT === "json"\` to force JSON output mode without passing \`--json\` on the command line. This is how the library entry point (\`src/index.ts\`) gets JSON by default — it sets this env var in the isolated env. The check runs after \`cleanRawFlags\` and only when the command has an \`output\` config (supports JSON). Commands without JSON support (help, version) are unaffected. ~5-line addition to \`command.ts\`.
-<!-- lore:019c969a-1c90-7041-88a8-4e4d9a51ebed -->
-* **Multiple mockFetch calls replace each other — use unified mocks for multi-endpoint tests**: Bun test mocking gotchas: (1) \`mockFetch()\` replaces \`globalThis.fetch\` — calling it twice replaces the first mock. Use a single unified fetch mock dispatching by URL pattern. (2) \`mock.module()\` pollutes the module registry for ALL subsequent test files. Tests using it must live in \`test/isolated/\` and run via \`test:isolated\`. This also causes \`delta-upgrade.test.ts\` to fail when run alongside \`test/isolated/delta-upgrade.test.ts\` — the isolated test's \`mock.module()\` replaces \`CLI\_VERSION\` for all subsequent files. (3) For \`Bun.spawn\`, use direct property assignment in \`beforeEach\`/\`afterEach\`.
+<!-- lore:019d2495-54da-7506-b6ba-fee422164bca -->
+* **Target argument 4-mode parsing convention (project-search-first)**: \`parseOrgProjectArg()\` in \`src/lib/arg-parsing.ts\` returns a 4-mode discriminated union: \`auto-detect\` (empty), \`explicit\` (\`org/project\`), \`org-all\` (\`org/\` trailing slash), \`project-search\` (bare slug). Bare slugs are ALWAYS \`project-search\` first. The "is this an org?" check is secondary: list commands with \`orgSlugMatchBehavior\` pre-check cached orgs (\`redirect\` or \`error\` mode), and \`handleProjectSearch()\` has a safety net checking orgs after project search fails. Non-list commands (init, view) treat bare slugs purely as project search with no org pre-check. For \`init\`, unmatched bare slugs become new project names. Key files: \`src/lib/arg-parsing.ts\` (parsing), \`src/lib/org-list.ts\` (dispatch + org pre-check), \`src/lib/resolve-target.ts\` (resolution cascade).
-<!-- lore:019c9741-d78e-73b1-87c2-e360ef6c7475 -->
-* **useTestConfigDir without isolateProjectRoot causes DSN scanning of repo tree**: \`useTestConfigDir()\` creates temp dirs under \`.test-tmp/\` in the repo tree. Without \`{ isolateProjectRoot: true }\`, \`findProjectRoot\` walks up and finds the repo's \`.git\`, causing DSN detection to scan real source code and trigger network calls against test mocks (timeouts). Always pass \`isolateProjectRoot: true\` when tests exercise \`resolveOrg\`, \`detectDsn\`, or \`findProjectRoot\`.
+<!-- lore:019d2690-4df5-7b2d-8194-00771eb3a9ce -->
+* **Writer type is the minimal output interface for streams and mocks**: The \`Writer\` type in \`src/types/index.ts\` is \`{ write(data: string): void; captureObject?: (obj: unknown) => void }\`. The optional \`captureObject\` property replaces the previous duck-typing pattern (\`hasCaptureObject()\` with \`typeof\` check and \`Record\<string, unknown>\` cast). In library mode, the writer sets \`captureObject\` to capture the fully-transformed JSON object directly without serialization. In CLI mode, \`process.stdout\` lacks this property so it's \`undefined\` → falsy, and \`emitJsonObject()\` falls through to \`JSON.stringify\`. The check is now a simple truthiness test: \`if (stdout.captureObject)\`. Since \`captureObject\` is part of the \`Writer\` type, \`sdk-invoke.ts\` no longer needs \`Writer & { captureObject?: ... }\` intersection types — plain \`Writer\` suffices.
-### Pattern
+### Preference
-<!-- lore:019d0b36-5da2-750c-b26f-630a2927bd79 -->
-* **findProjectsByPattern as fuzzy fallback for exact slug misses**: When \`findProjectsBySlug\` returns empty (no exact match), use \`findProjectsByPattern\` as a fallback to suggest similar projects. \`findProjectsByPattern\` does bidirectional word-boundary matching (\`matchesWordBoundary\`) against all projects in all orgs — the same logic used for directory name inference. In the \`project-search\` handler, call it after the exact miss, format matches as \`\<org>/\<slug>\` suggestions in the \`ResolutionError\`. This avoids a dead-end error for typos like 'patagonai' when 'patagon-ai' exists. Note: \`findProjectsByPattern\` makes additional API calls (lists all projects per org), so only call it on the failure path.
-
-<!-- lore:019c972c-9f11-7c0d-96ce-3f8cc2641175 -->
-* **Org-scoped SDK calls follow getOrgSdkConfig + unwrapResult pattern**: All org-scoped API calls in src/lib/api-client.ts: (1) call \`getOrgSdkConfig(orgSlug)\` for regional URL + SDK config, (2) spread into SDK function: \`{ ...config, path: { organization\_id\_or\_slug: orgSlug, ... } }\`, (3) pass to \`unwrapResult(result, errorContext)\`. Shared helpers \`resolveAllTargets\`/\`resolveOrgAndProject\` must NOT call \`fetchProjectId\` — commands that need it enrich targets themselves.
-
-<!-- lore:5ac4e219-ea1f-41cb-8e97-7e946f5848c0 -->
-* **PR workflow: wait for Seer and Cursor BugBot before resolving**: CI includes Seer Code Review and Cursor Bugbot as advisory checks (~2-3 min, only on ready-for-review PRs). Workflow: push → wait for all CI (including npm build) → check inline review comments from Seer/BugBot → fix valid findings → repeat. Bugbot sometimes catches real logic bugs, not just style — always review before merging. Use \`gh pr checks \<PR> --watch\` to monitor. Fetch comments via \`gh api repos/OWNER/REPO/pulls/NUM/comments\`.
-
-<!-- lore:019cb162-d3ad-7b05-ab4f-f87892d517a6 -->
-* **Shared pagination infrastructure: buildPaginationContextKey and parseCursorFlag**: Schema v12 replaced \`pagination\_cursors.cursor TEXT\` with \`cursor\_stack TEXT\` (JSON array) + \`page\_index INTEGER\`. Stack-based API in \`src/lib/db/pagination.ts\`: \`resolveCursor(flag, key, contextKey)\` maps keywords (next/prev/previous/first/last) to \`{cursor, direction}\`. \`advancePaginationState(key, contextKey, direction, nextCursor)\` pushes/pops the stack — back-then-forward truncates stale entries. \`hasPreviousPage(key, contextKey)\` checks \`page\_index > 0\`. \`clearPaginationState(key)\` removes state. \`parseCursorFlag\` in \`list-command.ts\` accepts next/prev/previous/first/last keywords. \`paginationHint()\` in \`org-list.ts\` builds bidirectional hints (\`-c prev | -c next\`). JSON envelope includes \`hasPrev\` boolean. All 7 list commands (trace, span, issue, project, team, repo, dashboard) use this stack API. \`resolveCursor()\` must be called inside \`org-all\` override closures.
-
-<!-- lore:019cbd5f-ec35-7e2d-8386-6d3a67adf0cf -->
-* **Telemetry instrumentation pattern: withTracingSpan + captureException for handled errors**: For graceful-fallback operations, use \`withTracingSpan\` from \`src/lib/telemetry.ts\` for child spans and \`captureException\` from \`@sentry/bun\` (named import — Biome forbids namespace imports) with \`level: 'warning'\` for non-fatal errors. \`withTracingSpan\` uses \`onlyIfParent: true\` — no-op without active transaction. User-visible fallbacks use \`log.warn()\` not \`log.debug()\`. Several commands bypass telemetry by importing \`buildCommand\` from \`@stricli/core\` directly instead of \`../../lib/command.js\` (trace/list, trace/view, log/view, api.ts, help.ts).
-
-<!-- lore:019cc43d-e651-7154-a88e-1309c4a2a2b6 -->
-* **Testing Stricli command func() bodies via spyOn mocking**: To unit-test a Stricli command's \`func()\` body: (1) \`const func = await cmd.loader()\`, (2) \`func.call(mockContext, flags, ...args)\` with mock \`stdout\`, \`stderr\`, \`cwd\`, \`setContext\`. (3) \`spyOn\` namespace imports to mock dependencies (e.g., \`spyOn(apiClient, 'getLogs')\`). The \`loader()\` return type union causes \`.call()\` LSP errors — these are false positives that pass \`tsc --noEmit\`. When API functions are renamed (e.g., \`getLog\` → \`getLogs\`), update both spy target name AND mock return shape (single → array). Slug normalization (\`normalizeSlug\`) replaces underscores with dashes but does NOT lowercase — test assertions must match original casing (e.g., \`'CAM-82X'\` not \`'cam-82x'\`).
+<!-- lore:019d26db-3ed0-7773-85ff-72226b404b98 -->
+* **Library features require README and docs site updates**: When adding new features like the library API, documentation must be updated in both places: the root \`README.md\` (library usage section between Configuration and Development, before the \`---\` divider) and the docs website at \`docs/src/content/docs/\`. The docs site uses Astro + Starlight with sidebar defined in \`docs/astro.config.mjs\`. New pages outside \`commands/\` must be manually added to the sidebar config. \`library-usage.md\` was added to the "Getting Started" sidebar section after "Configuration". Note: \`features.md\` and \`agent-guidance.md\` exist but are NOT in the sidebar.
<!-- End lore-managed section -->
diff --git a/README.md b/README.md
--- a/README.md
+++ b/README.md
@@ -112,7 +112,10 @@
- `project` — Default project slug.
- `text` — Return human-readable string instead of parsed JSON (affects `run()` only).
- `cwd` — Working directory for DSN auto-detection. Defaults to `process.cwd()`.
+- `signal` — `AbortSignal` to cancel streaming commands (`--follow`, `--refresh`).
+Streaming commands return `AsyncIterable` — use `for await...of` and `break` to stop.
+
Errors are thrown as `SentryError` with `.exitCode` and `.stderr`.
---
diff --git a/docs/src/content/docs/library-usage.md b/docs/src/content/docs/library-usage.md
--- a/docs/src/content/docs/library-usage.md
+++ b/docs/src/content/docs/library-usage.md
@@ -151,6 +151,7 @@
| `project` | `string` | Auto-detected | Default project slug |
| `text` | `boolean` | `false` | Return human-readable text instead of parsed JSON (`run()` only) |
| `cwd` | `string` | `process.cwd()` | Working directory for DSN auto-detection |
+| `signal` | `AbortSignal` | — | Abort signal for cancelling streaming commands |
## Return Values
@@ -217,7 +218,49 @@
- **Node.js >= 22** (required for `node:sqlite`)
- Or **Bun** (any recent version)
-:::caution
-Streaming flags (`--refresh`, `--follow`) are not supported in library mode
-and will throw a `SentryError`. Use the CLI binary directly for live-streaming commands.
+## Streaming Commands
+
+Two commands support real-time streaming: `log list --follow` and `dashboard view --refresh`.
+When using streaming flags, methods return an `AsyncIterable` instead of a `Promise`:
+
+```typescript
+const sdk = createSentrySDK({ token: "sntrys_..." });
+
+// Stream logs as they arrive (polls every 5 seconds)
+for await (const log of sdk.log.list({ follow: "5", orgProject: "acme/backend" })) {
+ console.log(log);
+}
+
+// Auto-refresh dashboard (polls every 30 seconds)
+for await (const snapshot of sdk.run("dashboard", "view", "123", "--refresh", "30")) {
+ console.log(snapshot);
+}
+
+// Stop streaming by breaking out of the loop
+for await (const log of sdk.log.list({ follow: "2" })) {
+ if (someCondition) break; // Streaming stops immediately
+}
+```
+
+### Cancellation
+
+`break` in a `for await...of` loop immediately signals the streaming command to stop.
+You can also pass an `AbortSignal` via `SentryOptions` for programmatic cancellation:
+
+```typescript
+const controller = new AbortController();
+const sdk = createSentrySDK({ token: "...", signal: controller.signal });
+
+// Cancel after 30 seconds
+setTimeout(() => controller.abort(), 30_000);
+
+for await (const log of sdk.log.list({ follow: "5" })) {
+ console.log(log);
+}
+// Loop exits when signal fires
+```
+
+:::note
+Concurrent streaming calls are not supported. Each streaming invocation
+uses an isolated environment — only one can be active at a time.
:::
diff --git a/script/bundle.ts b/script/bundle.ts
--- a/script/bundle.ts
+++ b/script/bundle.ts
@@ -226,8 +226,16 @@
text?: boolean;
/** Working directory (affects DSN detection, project root). Defaults to process.cwd(). */
cwd?: string;
+ /** AbortSignal to cancel streaming commands (e.g. log list --follow). */
+ signal?: AbortSignal;
};
+export type AsyncChannel<T> = AsyncIterable<T> & {
+ push(value: T): void;
+ close(): void;
+ error(err: Error): void;
+};
+
export declare class SentryError extends Error {
readonly exitCode: number;
readonly stderr: string;
diff --git a/script/generate-sdk.ts b/script/generate-sdk.ts
--- a/script/generate-sdk.ts
+++ b/script/generate-sdk.ts
@@ -34,11 +34,11 @@
"log-level",
"verbose",
"fields",
- // Streaming flags produce infinite output — not supported in library mode
- "refresh",
- "follow",
]);
+/** Flags that trigger streaming mode — included in params but change return type */
+const STREAMING_FLAGS = new Set(["refresh", "follow"]);
+
/** Regex for stripping angle-bracket/ellipsis decorators from placeholder names */
const PLACEHOLDER_CLEAN_RE = /[<>.]/g;
@@ -341,13 +341,12 @@
return { name: interfaceName, code };
}
-/** Generate the method body (invoke call) for a command. */
-function generateMethodBody(
+/** Build the flag object expression and positional expression for an invoke call. */
+function buildInvokeArgs(
path: string[],
positional: PositionalInfo,
- flags: SdkFlagInfo[],
- returnType: string
-): string {
+ flags: SdkFlagInfo[]
+): { flagObj: string; positionalExpr: string; pathStr: string } {
const flagEntries = flags.map((f) => {
const camel = camelCase(f.name);
if (f.name !== camel) {
@@ -368,10 +367,67 @@
const flagObj =
flagEntries.length > 0 ? `{ ${flagEntries.join(", ")} }` : "{}";
+ const pathStr = JSON.stringify(path);
- return `invoke<${returnType}>(${JSON.stringify(path)}, ${flagObj}, ${positionalExpr})`;
+ return { flagObj, positionalExpr, pathStr };
}
+/**
+ * Generate the method body (invoke call) for a non-streaming command.
+ * Uses `as Promise<T>` to narrow the invoke union return type, since
+ * non-streaming calls never pass `meta.streaming` and always return a Promise.
+ */
+function generateMethodBody(
+ path: string[],
+ positional: PositionalInfo,
+ flags: SdkFlagInfo[],
+ returnType: string
+): string {
+ const { flagObj, positionalExpr, pathStr } = buildInvokeArgs(
+ path,
+ positional,
+ flags
+ );
+ return `invoke<${returnType}>(${pathStr}, ${flagObj}, ${positionalExpr}) as Promise<${returnType}>`;
+}
+
+/** Options for generating a streaming method body. */
+type StreamingMethodOpts = {
+ path: string[];
+ positional: PositionalInfo;
+ flags: SdkFlagInfo[];
+ returnType: string;
+ streamingFlagNames: string[];
+ indent: string;
+};
+
+/**
+ * Generate the method body for a streaming-capable command.
+ *
+ * Detects at runtime whether any streaming flag is present and passes
+ * `{ streaming: true }` to the invoker when it is.
+ */
+function generateStreamingMethodBody(opts: StreamingMethodOpts): string {
+ const { flagObj, positionalExpr, pathStr } = buildInvokeArgs(
+ opts.path,
+ opts.positional,
+ opts.flags
+ );
+
+ // Build the streaming condition: params?.follow !== undefined || params?.refresh !== undefined
+ const conditions = opts.streamingFlagNames
+ .map((name) => `params?.${camelCase(name)} !== undefined`)
+ .join(" || ");
+
+ const lines = [
+ "{",
+ `${opts.indent} const streaming = ${conditions};`,
+ `${opts.indent} return invoke<${opts.returnType}>(${pathStr}, ${flagObj}, ${positionalExpr}, { streaming });`,
+ `${opts.indent} }`,
+ ];
+ return lines.join("\n");
+}
+
// ---------------------------------------------------------------------------
// Namespace Tree Building
// ---------------------------------------------------------------------------
@@ -472,6 +528,12 @@
const flags = extractSdkFlags(command);
const positional = derivePositional(command);
+ // Detect streaming-capable commands
+ const streamingFlagNames = flags
+ .filter((f) => STREAMING_FLAGS.has(f.name))
+ .map((f) => f.name);
+ const isStreaming = streamingFlagNames.length > 0;
+
// Generate return type from schema
const schemaTypeName = `${buildTypeName(path)}Result`;
const returnTypeInfo = generateReturnType(command, schemaTypeName);
@@ -493,6 +555,10 @@
let paramsArg: string;
let body: string;
+ const brief = command.brief || path.join(" ");
+ const methodName = path.at(-1) ?? path[0];
+ const indent = " ".repeat(path.length - 1);
+
if (hasVariadicPositional) {
// Variadic: (params: XParams, ...positional: string[]) or (params?: XParams, ...positional: string[])
// Required flags make params required even with variadic positionals
@@ -500,34 +566,77 @@
paramsArg = params
? `params${paramsOpt}: ${params.name}, ...positional: string[]`
: "...positional: string[]";
- body = generateMethodBody(path, positional, flags, returnType);
+ body = isStreaming
+ ? generateStreamingMethodBody({
+ path,
+ positional,
+ flags,
+ returnType,
+ streamingFlagNames,
+ indent,
+ })
+ : generateMethodBody(path, positional, flags, returnType);
} else if (params) {
const paramsRequired = hasRequiredFlags;
paramsArg = paramsRequired
? `params: ${params.name}`
: `params?: ${params.name}`;
- body = generateMethodBody(path, positional, flags, returnType);
+ body = isStreaming
+ ? generateStreamingMethodBody({
+ path,
+ positional,
+ flags,
+ returnType,
+ streamingFlagNames,
+ indent,
+ })
+ : generateMethodBody(path, positional, flags, returnType);
} else {
body = generateMethodBody(path, positional, flags, returnType);
paramsArg = "";
}
- const brief = command.brief || path.join(" ");
- const methodName = path.at(-1) ?? path[0];
- const indent = " ".repeat(path.length - 1);
const sig = paramsArg ? `(${paramsArg})` : "()";
- const methodCode = [
- `${indent} /** ${brief} */`,
- `${indent} ${methodName}: ${sig}: Promise<${returnType}> =>`,
- `${indent} ${body},`,
- ].join("\n");
- // Type declaration: method signature without implementation
- const typeDecl = [
- `${indent} /** ${brief} */`,
- `${indent} ${methodName}${sig}: Promise<${returnType}>;`,
- ].join("\n");
+ let methodCode: string;
+ let typeDecl: string;
+ if (isStreaming) {
+ // Streaming commands use a function body (not arrow expression)
+ // because they need runtime streaming detection
+ methodCode = [
+ `${indent} /** ${brief} */`,
+ `${indent} ${methodName}: ${sig} => ${body},`,
+ ].join("\n");
+
+ // Type declaration: callable interface with overloaded signatures
+ const streamingFlagTypes = streamingFlagNames.map((name) => {
+ const flag = flags.find((f) => f.name === name);
+ return `${camelCase(name)}: ${flag?.tsType ?? "string"}`;
+ });
+ const streamingConstraint = streamingFlagTypes.join("; ");
+
+ typeDecl = [
+ `${indent} /** ${brief} */`,
+ `${indent} ${methodName}: {`,
+ `${indent} (params: ${params?.name ?? "Record<string, never>"} & { ${streamingConstraint} }): AsyncIterable<unknown>;`,
+ `${indent} ${sig}: Promise<${returnType}>;`,
+ `${indent} };`,
+ ].join("\n");
+ } else {
+ methodCode = [
+ `${indent} /** ${brief} */`,
+ `${indent} ${methodName}: ${sig}: Promise<${returnType}> =>`,
+ `${indent} ${body},`,
+ ].join("\n");
+
+ // Type declaration: method signature without implementation
+ typeDecl = [
+ `${indent} /** ${brief} */`,
+ `${indent} ${methodName}${sig}: Promise<${returnType}>;`,
+ ].join("\n");
+ }
+
insertMethod(root, path, methodCode, typeDecl);
}
diff --git a/src/commands/dashboard/view.ts b/src/commands/dashboard/view.ts
--- a/src/commands/dashboard/view.ts
+++ b/src/commands/dashboard/view.ts
@@ -219,6 +219,17 @@
const stop = () => controller.abort();
process.once("SIGINT", stop);
+ // Library mode: honor external abort signal (e.g., consumer break)
+ const externalSignal = (this.process as { abortSignal?: AbortSignal })
+ ?.abortSignal;
+ if (externalSignal) {
+ if (externalSignal.aborted) {
+ controller.abort();
+ } else {
+ externalSignal.addEventListener("abort", stop, { once: true });
+ }
+ }
+
let isFirstRender = true;
try {
diff --git a/src/commands/log/list.ts b/src/commands/log/list.ts
--- a/src/commands/log/list.ts
+++ b/src/commands/log/list.ts
@@ -256,6 +256,11 @@
* Use this to seed dedup state (e.g., tracking seen log IDs).
*/
onInitialLogs?: (logs: T[]) => void;
+ /**
+ * External abort signal (library mode). When aborted, the follow
+ * generator stops on the next poll cycle. Complements SIGINT handling.
+ */
+ abortSignal?: AbortSignal;
};
/** Find the highest timestamp_precise in a batch, or undefined if none have it. */
@@ -343,11 +348,20 @@
// timestamp_precise is nanoseconds; Date.now() is milliseconds → convert
let lastTimestamp = Date.now() * 1_000_000;
- // AbortController for clean SIGINT handling
+ // AbortController for clean SIGINT handling + library mode abort
const controller = new AbortController();
const stop = () => controller.abort();
process.once("SIGINT", stop);
+ // Library mode: honor external abort signal (e.g., consumer break)
+ if (config.abortSignal) {
+ if (config.abortSignal.aborted) {
+ controller.abort();
+ } else {
+ config.abortSignal.addEventListener("abort", stop, { once: true });
+ }
+ }
+
try {
// Initial fetch
const initialLogs = await config.fetch("1m");
@@ -695,6 +709,8 @@
const generator = generateFollowLogs({
flags,
onDiagnostic: (msg) => logger.warn(msg),
+ abortSignal: (this.process as { abortSignal?: AbortSignal })
+ ?.abortSignal,
fetch: (statsPeriod) =>
listTraceLogs(org, traceId, {
query: flags.query,
@@ -757,6 +773,8 @@
const generator = generateFollowLogs({
flags,
onDiagnostic: (msg) => logger.warn(msg),
+ abortSignal: (this.process as { abortSignal?: AbortSignal })
+ ?.abortSignal,
fetch: (statsPeriod, afterTimestamp) =>
listLogs(org, project, {
query: flags.query,
diff --git a/src/index.ts b/src/index.ts
--- a/src/index.ts
+++ b/src/index.ts
@@ -25,6 +25,7 @@
import { buildInvoker, buildRunner } from "./lib/sdk-invoke.js";
import { createSDKMethods } from "./sdk.generated.js";
+export type { AsyncChannel } from "./lib/async-channel.js";
// Re-export public types and error class from the shared module.
// These re-exports exist to break a circular dependency between
// index.ts ↔ sdk-invoke.ts. SentryError and SentryOptions live
diff --git a/src/lib/async-channel.ts b/src/lib/async-channel.ts
new file mode 100644
--- /dev/null
+++ b/src/lib/async-channel.ts
@@ -1,0 +1,135 @@
+/**
+ * Minimal push/pull async channel for streaming SDK results.
+ *
+ * Producer calls `push(value)` for each item, `close()` when done,
+ * or `error(err)` on failure. Consumer iterates with `for await...of`.
+ *
+ * Backpressure is not implemented — producers push freely. This is
+ * acceptable for streaming commands (bounded by poll interval).
+ *
+ * @module
+ */
+
+/** Options for creating an async channel. */
+export type AsyncChannelOptions = {
+ /**
+ * Called when the consumer calls `return()` on the iterator
+ * (e.g., `break` in a `for await...of` loop). Use this to signal
+ * the producer to stop.
+ */
+ onReturn?: () => void;
+};
+
+/**
+ * A push/pull async channel that implements `AsyncIterable<T>`.
+ *
+ * The producer side pushes values, the consumer side iterates.
+ * When the producer is done, it calls `close()`. On error, `error(err)`.
+ * Push after close is a silent no-op.
+ */
+export type AsyncChannel<T> = AsyncIterable<T> & {
+ /** Push a value to the consumer. Buffers if no one is waiting. No-op after close/error. */
+ push(value: T): void;
+ /** Signal normal completion. Consumer's next() returns { done: true }. */
+ close(): void;
+ /** Signal an error. Consumer's next() rejects with this error. */
+ error(err: Error): void;
+};
+
+/**
+ * Create a new async channel.
+ *
+ * Uses a dual-queue pattern: a buffer for values pushed before `next()`
+ * is called, and a pending resolver for when `next()` is called first.
+ */
+export function createAsyncChannel<T>(
+ options?: AsyncChannelOptions
+): AsyncChannel<T> {
+ const buffer: T[] = [];
+ let pending:
+ | {
+ resolve: (result: IteratorResult<T>) => void;
+ reject: (err: Error) => void;
+ }
+ | undefined;
+ let closed = false;
+ let errorValue: Error | undefined;
+
+ function push(value: T): void {
+ if (closed || errorValue) {
+ return;
+ }
+ if (pending) {
+ const p = pending;
+ pending = undefined;
+ p.resolve({ value, done: false });
+ } else {
+ buffer.push(value);
+ }
+ }
+
+ function close(): void {
+ if (closed) {
+ return;
+ }
+ closed = true;
+ if (pending) {
+ const p = pending;
+ pending = undefined;
+ p.resolve({ value: undefined as T, done: true });
+ }
+ }
+
+ function error(err: Error): void {
+ if (closed || errorValue) {
+ return;
+ }
+ errorValue = err;
+ closed = true;
+ if (pending) {
+ const p = pending;
+ pending = undefined;
+ p.reject(err);
+ }
+ }
+
+ function next(): Promise<IteratorResult<T>> {
+ if (buffer.length > 0) {
+ const value = buffer.shift() as T;
+ return Promise.resolve({ value, done: false });
+ }
+ if (errorValue) {
+ return Promise.reject(errorValue);
+ }
+ if (closed) {
+ return Promise.resolve({ value: undefined as T, done: true });
+ }
+ return new Promise<IteratorResult<T>>((resolve, reject) => {
+ pending = { resolve, reject };
+ });
+ }
+
+ const iterator: AsyncIterator<T> = {
+ next,
+ return(): Promise<IteratorResult<T>> {
+ closed = true;
+ buffer.length = 0;
+ if (pending) {
+ const p = pending;
+ pending = undefined;
+ p.resolve({ value: undefined as T, done: true });
+ }
+ options?.onReturn?.();
+ return Promise.resolve({ value: undefined as T, done: true });
+ },
+ };
+
+ return {
+ push,
+ close,
+ error,
+ [Symbol.asyncIterator](): AsyncIterator<T> {
+ return iterator;
+ },
+ };
+}
diff --git a/src/lib/sdk-invoke.ts b/src/lib/sdk-invoke.ts
--- a/src/lib/sdk-invoke.ts
+++ b/src/lib/sdk-invoke.ts
@@ -17,12 +17,18 @@
import { homedir } from "node:os";
import type { Span } from "@sentry/core";
import type { Writer } from "../types/index.js";
+import { type AsyncChannel, createAsyncChannel } from "./async-channel.js";
import { setEnv } from "./env.js";
import { SentryError, type SentryOptions } from "./sdk-types.js";
-/** Flags that trigger infinite streaming — not supported in library mode. */
-const STREAMING_FLAGS = new Set(["--refresh", "--follow", "-f"]);
+/** CLI flag names/aliases that trigger infinite streaming output. */
+const STREAMING_FLAG_NAMES = new Set(["--refresh", "--follow", "-f"]);
+/** Check if CLI args contain any streaming flag. */
+function hasStreamingFlag(args: string[]): boolean {
+ return args.some((arg) => STREAMING_FLAG_NAMES.has(arg));
+}
+
/**
* Build an isolated env from options, inheriting the consumer's process.env.
* Sets auth token, host URL, default org/project, and output format.
@@ -166,22 +172,37 @@
getCapturedResult: () => unknown;
};
+/** Options for building a capture context. */
+type CaptureOptions = {
+ /** When set, captureObject pushes to this channel instead of accumulating. */
+ channel?: AsyncChannel<unknown>;
+ /** When set, placed on the fake process for streaming commands to honor. */
+ abortSignal?: AbortSignal;
+};
+
/** Build output capture writers and a SentryContext-compatible context object. */
async function buildCaptureContext(
env: NodeJS.ProcessEnv,
- cwd: string
+ cwd: string,
+ opts?: CaptureOptionsThis Bugbot Autofix run was free. To enable autofix for future PRs, go to the Cursor dashboard.
d3240b6 to
89d55be
Compare
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 1 potential issue.
Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.
Streaming commands (log list --follow, dashboard view --refresh) now return AsyncIterable instead of throwing. Consumer break and AbortSignal both wire through to command abort for clean shutdown. Closes #585
89d55be to
0ddb128
Compare


Summary
log list --follow,dashboard view --refresh) now returnAsyncIterableinstead of throwing in library modebreakandAbortSignalboth wire through to command abort for clean shutdownAsyncChannel<T>primitive bridges command generators with consumer iterationConsumer API
Changes
src/lib/async-channel.ts(new) — Push/pull async channel with dual-queue patternsrc/lib/sdk-invoke.ts— NewexecuteWithStream<T>()parallel toexecuteWithCapture<T>();buildRunner/buildInvokerbranch on streaming flag detectionsrc/lib/sdk-types.ts— Addedsignal?: AbortSignaltoSentryOptionssrc/commands/log/list.ts— ThreadabortSignalthroughFollowGeneratorConfigfor library mode abortsrc/commands/dashboard/view.ts— Honor externalabortSignalin refresh loopscript/generate-sdk.ts— Streaming flags moved fromINTERNAL_FLAGStoSTREAMING_FLAGS; generates overloaded type signatures (Promise<T>vsAsyncIterable<unknown>)script/bundle.ts— Addedsignalto npm type declarations +AsyncChanneltype exportsignaloption in READMETests
Closes #585