Skip to content

Commit 9dab485

Browse files
committed
refactor: auto-generate SDK from route tree, single entry point
Rework the typed SDK to auto-discover ALL commands from the Stricli route tree with zero manual config: - generate-sdk.ts walks the entire route tree recursively, discovers 44 commands, generates typed methods using CLI route names as-is (sdk.org.list, sdk.dashboard.widget.add) - Return types derived from __jsonSchema (PR #582) via extractSchemaFields() — commands with schemas get typed returns, others default to unknown - Positional params derived from introspection placeholder strings - createSentrySDK() is now the single public API (default export) - sdk.run() escape hatch replaces the standalone sentry() function - SentryError/SentryOptions extracted to sdk-types.ts to break circular deps between index.ts and sdk-invoke.ts
1 parent 149b859 commit 9dab485

File tree

17 files changed

+878
-1041
lines changed

17 files changed

+878
-1041
lines changed

.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,8 @@ docs/.astro
5454

5555
# Generated files (rebuilt at build time)
5656
src/generated/
57+
src/sdk.generated.ts
58+
src/sdk.generated.d.cts
5759

5860
# OpenCode
5961
.opencode/

AGENTS.md

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -894,7 +894,7 @@ mock.module("./some-module", () => ({
894894
### Architecture
895895
896896
<!-- lore:019d2690-4df2-7ac8-82c4-54656d987339 -->
897-
* **Bundle uses esbuild with bun:sqlite polyfill plugin for Node.js compatibility**: \`script/bundle.ts\` uses esbuild to produce \`dist/index.cjs\` (the main bundle — library + CLI internals) 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. A \`sentrySourcemapPlugin\` handles debug ID injection and sourcemap upload. After the main build, the script writes: (1) \`dist/bin.cjs\`tiny ~300-byte CLI wrapper with shebang, Node version check, warning suppression, and \`require('./index.cjs').\_cli()\`, (2) \`dist/index.d.cts\` — type declarations auto-derived from \`src/sdk.generated.ts\` via \`extractSdkTypes()\`. The warning suppression banner lives only in the bin wrapper, not the library bundle — library consumers' \`process.emit\` is never patched. Debug IDs solve sourcemap deduplication between the npm bundle and bun compile builds.
897+
* **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.
898898
899899
<!-- lore:019d2a93-55dd-7060-ba70-4cf2ae22ecfe -->
900900
* **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\`.
@@ -903,19 +903,22 @@ mock.module("./some-module", () => ({
903903
* **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\`.
904904
905905
<!-- lore:019d26a2-8d2f-7a31-81aa-e665b4dde197 -->
906-
* **Library API: variadic sentry() function with last-arg options detection**: The \`sentry()\` library export in \`src/index.ts\` uses variadic args with optional trailing options object: \`sentry("issue", "list", { token: "..." })\`. Last-arg detection: if final arg is an object (not string), it's \`SentryOptions\`. Options: \`token?\` (auth override, auto-fills from \`SENTRY\_AUTH\_TOKEN\`/\`SENTRY\_TOKEN\` env vars), \`text?\` (return human-readable string instead of parsed JSON), \`cwd?\` (working directory for DSN detection). Default behavior: JSON output via \`SENTRY\_OUTPUT\_FORMAT=json\` env var. Zero-copy return via \`captureObject\` duck-type on Writer — skips JSON.stringify/parse round-trip. Non-zero exit throws \`SentryError\` with \`.exitCode\` and \`.stderr\`. If \`capturedResult\` exists when an error is thrown (OutputError pattern), returns the data instead of throwing. Env isolation via \`setEnv({ ...process.env })\` — consumer's \`process.env\` never mutated. \`createSentrySDK(options?)\` provides typed namespace API that invokes commands directly via \`Command.loader()\`, bypassing Stricli's string dispatch.
906+
* **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(...args)\` escape hatch. \`SentryError\` and \`SentryOptions\` live in \`src/lib/sdk-types.ts\` (shared module avoiding circular deps between \`index.ts\`\`sdk-invoke.ts\`), re-exported from \`index.ts\`. Env isolation via \`setEnv()\`, zero-copy \`captureObject\` return, \`OutputError\` → data recovery. \`buildInvoker()\` (typed methods) and \`buildRunner()\` (run escape hatch) in \`sdk-invoke.ts\`. Options: \`token?\`, \`text?\` (run-only), \`cwd?\`. Default JSON output via \`SENTRY\_OUTPUT\_FORMAT=json\` env var. Non-zero exit throws \`SentryError\` with \`.exitCode\` and \`.stderr\`.
907907
908908
<!-- lore:019d26a2-8d34-76ef-a855-c28a7e7796d1 -->
909909
* **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]\`.
910910
911911
<!-- lore:019d2a93-55d9-7bc9-add2-c14a9a5fdd69 -->
912-
* **Typed SDK uses direct Command.loader() invocation bypassing Stricli dispatch**: \`createSentrySDK(options?)\` in \`src/index.ts\` builds a typed namespace API (\`sdk.organizations.list()\`, \`sdk.issues.get()\`) generated by \`script/generate-sdk.ts\`. At runtime, \`src/lib/sdk-invoke.ts\` resolves commands by walking the Stricli route tree via \`routes.getRoutingTargetForInput()\`, caches the \`Command\` objects, then calls \`command.loader()\` to get the wrapped func and invokes it with \`func.call(context, flags, ...args)\`. This bypasses Stricli's string scanning, route resolution, and flag parsing entirely — flags are passed as typed objects. The codegen script introspects flag definitions (kind, default, optional, variadic) at build time and generates TypeScript parameter interfaces. Return types use a manual type map (~20 entries) until schema registration on OutputConfig is implemented (#566).
912+
* **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.
913913
914914
### Decision
915915
916916
<!-- lore:019d26a2-8d3b-770a-866c-10ca04a374e7 -->
917917
* **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\`.
918918
919+
<!-- lore:019d2bd4-65c2-7b4c-99f7-cab41ee1ed71 -->
920+
* **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 via introspection, 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 files: (1) \`src/sdk.generated.ts\` — runtime implementations, (2) \`src/sdk.generated.d.cts\` — type declarations for npm package. Both are gitignored and regenerated at build time. The \`generate:sdk\` script is chained before \`typecheck\`, \`dev\`, \`build\`, \`build:all\`, and \`bundle\` in \`package.json\`. CI check \`bun run check:skill\` validates SKILL.md stays in sync with command changes.
921+
919922
### Pattern
920923
921924
<!-- lore:019d26a2-8d37-72ea-b7bd-14fef8556fea -->
@@ -925,7 +928,7 @@ mock.module("./some-module", () => ({
925928
* **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).
926929
927930
<!-- lore:019d2690-4df5-7b2d-8194-00771eb3a9ce -->
928-
* **Writer type is the minimal output interface for streams and mocks**: The \`Writer\` type in \`src/types/index.ts\` is \`{ write(data: string): void }\`. In library mode, the Writer is extended with a duck-typed \`captureObject(obj: unknown)\` method that \`emitJsonObject()\` in \`output.ts\` checks for via \`hasCaptureObject()\`. When present, the fully-transformed JSON object (post-\`jsonTransform\`, post-\`filterFields\`) is passed directly to the capture callback, skipping \`JSON.stringify\`. CLI mode is unaffected — \`process.stdout\` doesn't have \`captureObject\`, so \`emitJsonObject\` falls through to \`writeJson\`. The SDK invoke layer also uses this pattern via \`buildCaptureContext()\`.
931+
* **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.
929932
930933
### Preference
931934

README.md

Lines changed: 16 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -88,43 +88,32 @@ Credentials are stored in `~/.sentry/` with restricted permissions (mode 600).
8888
Use Sentry CLI programmatically in Node.js (≥22) or Bun without spawning a subprocess:
8989

9090
```typescript
91-
import sentry from "sentry";
91+
import createSentrySDK from "sentry";
9292

93-
// Returns parsed JSON — same as `sentry issue list --json`
94-
const issues = await sentry("issue", "list", "-l", "5");
93+
const sdk = createSentrySDK({ token: "sntrys_..." });
9594

96-
// Explicit auth token (auto-fills from SENTRY_AUTH_TOKEN env var)
97-
const orgs = await sentry("org", "list", { token: "sntrys_..." });
95+
// Typed methods for every CLI command
96+
const orgs = await sdk.org.list();
97+
const issues = await sdk.issue.list({ orgProject: "acme/frontend", limit: 5 });
98+
const issue = await sdk.issue.view({ issue: "ACME-123" });
9899

99-
// Human-readable text output
100-
const text = await sentry("issue", "list", { text: true });
100+
// Nested commands
101+
await sdk.dashboard.widget.add({ display: "line", query: "count" }, "my-org/my-dashboard");
101102

102-
// Errors are thrown as SentryError with .exitCode and .stderr
103-
try {
104-
await sentry("issue", "view", "NONEXISTENT-1");
105-
} catch (err) {
106-
console.error(err.exitCode, err.stderr);
107-
}
103+
// Escape hatch for any CLI command
104+
const version = await sdk.run("--version");
105+
const text = await sdk.run("issue", "list", "-l", "5");
108106
```
109107

110108
Options (all optional):
111109
- `token` — Auth token. Falls back to `SENTRY_AUTH_TOKEN` / `SENTRY_TOKEN` env vars.
112-
- `text` — Return human-readable string instead of parsed JSON.
110+
- `url` — Sentry instance URL for self-hosted (e.g., `"sentry.example.com"`).
111+
- `org` — Default organization slug (avoids passing it on every call).
112+
- `project` — Default project slug.
113+
- `text` — Return human-readable string instead of parsed JSON (affects `run()` only).
113114
- `cwd` — Working directory for DSN auto-detection. Defaults to `process.cwd()`.
114115

115-
### Typed SDK
116-
117-
For a more structured API with named parameters and typed returns:
118-
119-
```typescript
120-
import { createSentrySDK } from "sentry";
121-
122-
const sdk = createSentrySDK({ token: "sntrys_..." });
123-
124-
const orgs = await sdk.organizations.list();
125-
const issues = await sdk.issues.list({ org: "acme", project: "frontend", limit: 5 });
126-
const issue = await sdk.issues.get({ issueId: "ACME-123" });
127-
```
116+
Errors are thrown as `SentryError` with `.exitCode` and `.stderr`.
128117

129118
---
130119

biome.jsonc

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,12 +67,14 @@
6767
}
6868
},
6969
{
70+
// src/index.ts is the library entry point — re-exports SentryError, SentryOptions, SentrySDK
7071
// db/index.ts exports db connection utilities - not a barrel file but triggers the rule
7172
// api-client.ts is a barrel that re-exports from src/lib/api/ domain modules
7273
// to preserve the existing import path for all consumers
7374
// markdown.ts re-exports isPlainOutput from plain-detect.ts for backward compat
7475
// script/debug-id.ts re-exports from src/lib/sourcemap/debug-id.ts for build scripts
7576
"includes": [
77+
"src/index.ts",
7678
"src/lib/db/index.ts",
7779
"src/lib/api-client.ts",
7880
"src/lib/formatters/markdown.ts",

0 commit comments

Comments
 (0)