Skip to content

Commit 7fee31f

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 915d772 commit 7fee31f

File tree

13 files changed

+1365
-753
lines changed

13 files changed

+1365
-753
lines changed

AGENTS.md

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -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: createSentrySDK() is the single entry point**: \`createSentrySDK(options?)\` in \`src/index.ts\` is the sole public API. It returns a typed SDK object with methods for every CLI command plus a \`run(...args)\` escape hatch. The standalone variadic \`sentry()\` function has been removed. \`SentryError\` and \`SentryOptions\` live in \`src/lib/sdk-types.ts\` (shared module to avoid circular deps between \`index.ts\`\`sdk-invoke.ts\`) and are re-exported from \`index.ts\`. Env isolation via \`setEnv()\`, zero-copy \`captureObject\` return, and \`OutputError\` → data recovery patterns are handled by \`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 auto-generates all commands from route tree**: \`script/generate-sdk.ts\` walks the entire Stricli route tree via \`discoverCommands()\`, skipping hidden routes (plural aliases). For each visible command: extracts flags via introspection, derives positional params from placeholder strings, checks \`__jsonSchema\` for typed return types (via \`extractSchemaFields()\`), and generates TypeScript param interfaces + method implementations. Naming uses CLI route path as-is: \`["org", "list"]\`\`sdk.org.list()\`, \`["dashboard", "widget", "add"]\`\`sdk.dashboard.widget.add()\`. Slash-separated positional names (e.g. \`org/project\`) are converted to camelCase (\`orgProject\`). Dotted field names in schemas are quoted (\`"span.op"\`). No manual config table — all 44+ commands are auto-discovered. Return types default to \`unknown\` when no schema is registered.
921+
919922
### Pattern
920923
921924
<!-- lore:019d26a2-8d37-72ea-b7bd-14fef8556fea -->

README.md

Lines changed: 13 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -88,43 +88,29 @@ 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+
- `text` — Return human-readable string instead of parsed JSON (affects `run()` only).
113111
- `cwd` — Working directory for DSN auto-detection. Defaults to `process.cwd()`.
114112

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-
```
113+
Errors are thrown as `SentryError` with `.exitCode` and `.stderr`.
128114

129115
---
130116

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",

docs/src/content/docs/library-usage.md

Lines changed: 76 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -16,71 +16,106 @@ npm install sentry
1616
## Quick Start
1717

1818
```typescript
19-
import sentry from "sentry";
19+
import createSentrySDK from "sentry";
2020

21-
// Run any CLI command — returns parsed JSON by default
22-
const issues = await sentry("issue", "list", "-l", "5");
23-
console.log(issues.data); // Array of issue objects
21+
const sdk = createSentrySDK({ token: "sntrys_..." });
22+
23+
// Typed methods for every CLI command
24+
const orgs = await sdk.org.list();
25+
const issues = await sdk.issue.list({ orgProject: "acme/frontend", limit: 5 });
2426
```
2527

2628
## Typed SDK
2729

28-
For structured access with named parameters and TypeScript types, use `createSentrySDK`:
30+
`createSentrySDK()` returns an object with typed methods for **every** CLI command,
31+
organized by the CLI route hierarchy:
2932

3033
```typescript
31-
import { createSentrySDK } from "sentry";
34+
import createSentrySDK from "sentry";
3235

3336
const sdk = createSentrySDK({ token: "sntrys_..." });
3437
```
3538

3639
### Organizations
3740

3841
```typescript
39-
const orgs = await sdk.organizations.list();
40-
const org = await sdk.organizations.get("acme");
42+
const orgs = await sdk.org.list();
43+
const org = await sdk.org.view({ org: "acme" });
4144
```
4245

4346
### Projects
4447

4548
```typescript
46-
const projects = await sdk.projects.list({ target: "acme/" });
47-
const project = await sdk.projects.get({ target: "acme/frontend" });
49+
const projects = await sdk.project.list({ orgProject: "acme/" });
50+
const project = await sdk.project.view({ orgProject: "acme/frontend" });
4851
```
4952

5053
### Issues
5154

5255
```typescript
53-
const issues = await sdk.issues.list({
54-
org: "acme",
55-
project: "frontend",
56+
const issues = await sdk.issue.list({
57+
orgProject: "acme/frontend",
5658
limit: 10,
5759
query: "is:unresolved",
5860
sort: "date",
5961
});
6062

61-
const issue = await sdk.issues.get({ issueId: "ACME-123" });
63+
const issue = await sdk.issue.view({ issue: "ACME-123" });
6264
```
6365

6466
### Events, Traces, Spans
6567

6668
```typescript
67-
const event = await sdk.events.get({ eventId: "abc123..." });
69+
const event = await sdk.event.view({}, "abc123...");
6870

69-
const traces = await sdk.traces.list({ target: "acme/frontend" });
70-
const trace = await sdk.traces.get({ traceId: "abc123..." });
71+
const traces = await sdk.trace.list({ orgProject: "acme/frontend" });
72+
const trace = await sdk.trace.view({}, "abc123...");
7173

72-
const spans = await sdk.spans.list({ target: "acme/frontend" });
74+
const spans = await sdk.span.list({}, "acme/frontend");
75+
```
76+
77+
### Dashboards
78+
79+
```typescript
80+
const dashboards = await sdk.dashboard.list({}, "acme/");
81+
const dashboard = await sdk.dashboard.view({}, "acme/", "my-dashboard");
82+
83+
// Nested widget commands
84+
await sdk.dashboard.widget.add(
85+
{ display: "line", query: "count" },
86+
"acme/", "my-dashboard"
87+
);
7388
```
7489

7590
### Teams
7691

7792
```typescript
78-
const teams = await sdk.teams.list({ target: "acme/" });
93+
const teams = await sdk.team.list({ orgProject: "acme/" });
94+
```
95+
96+
### Authentication
97+
98+
```typescript
99+
await sdk.auth.login();
100+
await sdk.auth.status();
101+
const whoami = await sdk.auth.whoami();
79102
```
80103

81104
The typed SDK invokes command handlers directly — bypassing CLI string parsing
82105
for zero overhead beyond the command's own logic.
83106

107+
## Escape Hatch: `run()`
108+
109+
For commands not easily expressed through the typed API, or when you want to
110+
pass raw CLI flags, use `sdk.run()`:
111+
112+
```typescript
113+
// Run any CLI command — returns parsed JSON by default
114+
const version = await sdk.run("--version");
115+
const issues = await sdk.run("issue", "list", "-l", "5");
116+
const help = await sdk.run("help", "issue");
117+
```
118+
84119
## Authentication
85120

86121
The `token` option provides an auth token for the current invocation. When
@@ -93,65 +128,54 @@ omitted, it falls back to environment variables and stored credentials:
93128

94129
```typescript
95130
// Explicit token
96-
const orgs = await sentry("org", "list", { token: "sntrys_..." });
131+
const sdk = createSentrySDK({ token: "sntrys_..." });
97132

98133
// Or set the env var — it's picked up automatically
99134
process.env.SENTRY_AUTH_TOKEN = "sntrys_...";
100-
const orgs = await sentry("org", "list");
135+
const sdk = createSentrySDK();
101136
```
102137

103138
## Options
104139

105-
All options are optional. Pass them as the last argument:
140+
All options are optional. Pass them when creating the SDK:
106141

107142
```typescript
108-
await sentry("issue", "list", { token: "...", text: true, cwd: "/my/project" });
143+
const sdk = createSentrySDK({ token: "...", text: true, cwd: "/my/project" });
109144
```
110145

111146
| Option | Type | Default | Description |
112147
|--------|------|---------|-------------|
113148
| `token` | `string` | Auto-detected | Auth token for this invocation |
114-
| `text` | `boolean` | `false` | Return human-readable text instead of parsed JSON |
149+
| `text` | `boolean` | `false` | Return human-readable text instead of parsed JSON (`run()` only) |
115150
| `cwd` | `string` | `process.cwd()` | Working directory for DSN auto-detection |
116151

117152
## Return Values
118153

119-
By default, commands that support JSON output return a **parsed JavaScript object**
120-
— no serialization overhead. Commands without JSON support (like `help` or `--version`)
121-
return a trimmed string.
154+
Typed SDK methods return **parsed JavaScript objects** with zero serialization
155+
overhead (via zero-copy capture). The `run()` escape hatch returns parsed JSON
156+
by default, or a trimmed string for commands without JSON support.
122157

123158
```typescript
124-
// Data commands → parsed object
125-
const issues = await sentry("issue", "list");
126-
// { data: [...], hasMore: true, nextCursor: "..." }
127-
128-
// Single-entity commands → parsed object
129-
const issue = await sentry("issue", "view", "PROJ-123");
130-
// { id: "123", title: "Bug", status: "unresolved", ... }
159+
// Typed methods → typed return
160+
const issues = await sdk.issue.list({ orgProject: "acme/frontend" });
161+
// IssueListResult type with known fields
131162

132-
// Text commands → string
133-
const version = await sentry("--version");
163+
// run() → parsed JSON or string
164+
const version = await sdk.run("--version");
134165
// "sentry 0.21.0"
135166
```
136167

137-
### Text Mode
138-
139-
Pass `{ text: true }` to get the human-readable output as a string:
140-
141-
```typescript
142-
const text = await sentry("issue", "list", { text: true });
143-
// "ID TITLE STATUS\n..."
144-
```
145-
146168
## Error Handling
147169

148-
Commands that exit with a non-zero code throw a `SentryError`:
170+
Commands that fail throw a `SentryError`:
149171

150172
```typescript
151-
import sentry, { SentryError } from "sentry";
173+
import createSentrySDK, { SentryError } from "sentry";
174+
175+
const sdk = createSentrySDK();
152176

153177
try {
154-
await sentry("issue", "view", "NONEXISTENT-1");
178+
await sdk.issue.view({ issue: "NONEXISTENT-1" });
155179
} catch (err) {
156180
if (err instanceof SentryError) {
157181
console.error(err.message); // Clean error message (no ANSI codes)
@@ -171,21 +195,21 @@ copy of the environment. This means:
171195
- Auth tokens passed via `token` don't leak to subsequent calls
172196

173197
:::note
174-
Concurrent calls to `sentry()` are not supported in the current version.
198+
Concurrent calls are not supported in the current version.
175199
Calls should be sequential (awaited one at a time).
176200
:::
177201

178202
## Comparison with Subprocess
179203

180-
| | Library (`sentry()`) | Subprocess (`child_process`) |
204+
| | Library (`createSentrySDK()`) | Subprocess (`child_process`) |
181205
|---|---|---|
182206
| **Startup** | ~0ms (in-process) | ~200ms (process spawn + init) |
183207
| **Output** | Parsed object (zero-copy) | String (needs JSON.parse) |
184208
| **Errors** | `SentryError` with typed fields | Exit code + stderr string |
185209
| **Auth** | `token` option or env vars | Env vars only |
186-
| **Node.js** | 22 required | Any version |
210+
| **Node.js** | >=22 required | Any version |
187211

188212
## Requirements
189213

190-
- **Node.js 22** (required for `node:sqlite`)
214+
- **Node.js >= 22** (required for `node:sqlite`)
191215
- Or **Bun** (any recent version)

script/bundle.ts

Lines changed: 7 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -23,8 +23,8 @@ const BUN_SQLITE_FILTER = /^bun:sqlite$/;
2323
const ANY_FILTER = /.*/;
2424

2525
// Regex patterns for SDK type extraction
26-
/** Matches `export type FooParams = { ... };` blocks (multiline via dotAll) */
27-
const EXPORTED_TYPE_BLOCK_RE = /^export type \w+Params = \{[^}]*\};/gms;
26+
/** Matches `export type Foo = { ... };` blocks (Params + Result, multiline via dotAll) */
27+
const EXPORTED_TYPE_BLOCK_RE = /^export type \w+ = \{[^}]*\};/gms;
2828

2929
/** Matches method lines: `name: (params): Promise<T> =>` */
3030
const SDK_METHOD_RE = /^(\s+)(\w+): \(([^)]*)\): (Promise<.+>)\s*=>$/;
@@ -340,13 +340,12 @@ export declare class SentryError extends Error {
340340
constructor(message: string, exitCode: number, stderr: string);
341341
}
342342
343-
export declare function sentry(...args: string[]): Promise<unknown>;
344-
export declare function sentry(...args: [...string[], SentryOptions]): Promise<unknown>;
345-
346-
export { sentry };
347-
export default sentry;
343+
export declare function createSentrySDK(options?: SentryOptions): SentrySDK & {
344+
/** Run an arbitrary CLI command (escape hatch). */
345+
run(...args: string[]): Promise<unknown>;
346+
};
348347
349-
export declare function createSentrySDK(options?: SentryOptions): SentrySDK;
348+
export default createSentrySDK;
350349
`;
351350

352351
// Extract parameter types and SentrySDK type from sdk.generated.ts.

0 commit comments

Comments
 (0)