Skip to content

Commit 7d5d0d3

Browse files
committed
feat: expose CLI as a programmatic library
Add a variadic `sentry()` function that runs CLI commands in-process and returns parsed JSON objects (or raw text). This enables AI coding agents, build tools, and other JS/TS consumers to use the CLI without spawning a subprocess. ## API ```typescript import sentry from "sentry"; const issues = await sentry("issue", "list", "-l", "5"); const orgs = await sentry("org", "list", { token: "sntrys_..." }); ``` Options: `token` (auth override), `text` (human output), `cwd` (working dir). Errors throw `SentryError` with `.exitCode` and `.stderr`. ## Architecture - **Env registry** (`src/lib/env.ts`): `getEnv()`/`setEnv()` replaces all direct `process.env` reads (~14 files ported). Library mode creates an isolated env copy — consumer's `process.env` is never mutated. - **Zero-copy return**: `renderCommandOutput` duck-types a `captureObject` method on the Writer to hand back the in-memory object directly, avoiding the `JSON.stringify` → buffer → `JSON.parse` round-trip. - **Single npm bundle**: esbuild entry point changes from `src/bin.ts` to `src/index.ts`. A tiny `dist/bin.cjs` wrapper (~200 bytes) calls `require("./index.cjs")._cli()`. npm package size stays flat. - **Library-safe telemetry**: `initSentry({ libraryMode: true })` strips all global-polluting integrations (process listeners, HTTP trace headers, Function.prototype wrapping). Manual `client.flush()` replaces beforeExit. - **CLI extraction**: `src/bin.ts` logic moved to `src/cli.ts` (exported functions, no top-level execution). `bin.ts` becomes a thin bun compile entry point. - **`SENTRY_OUTPUT_FORMAT=json`**: New env var in `command.ts` forces JSON mode without `--json` flag — how the library gets JSON by default.
1 parent 92e472e commit 7d5d0d3

31 files changed

+1013
-319
lines changed

AGENTS.md

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -927,4 +927,36 @@ mock.module("./some-module", () => ({
927927
928928
<!-- lore:019cb3e6-da61-7dfe-83c2-17fe3257bece -->
929929
* **PR workflow: address review comments, resolve threads, wait for CI**: User's PR workflow after creation: (1) Wait for CI checks to pass, (2) Check for unresolved review comments via \`gh api\` for PR review comments, (3) Fix issues in follow-up commits (not amends), (4) Reply to the comment thread explaining the fix, (5) Resolve the thread programmatically via \`gh api graphql\` with \`resolveReviewThread\` mutation, (6) Push and wait for CI again, (7) Final sweep for any remaining unresolved comments. Use \`git notes add\` to attach implementation plans to commits. Branch naming: \`fix/descriptive-slug\` or \`feat/descriptive-slug\`.
930+
<!-- lore:019d2690-4df2-7ac8-82c4-54656d987339 -->
931+
* **Bundle uses esbuild with bun:sqlite polyfill plugin for Node.js compatibility**: \`script/bundle.ts\` uses esbuild (not Bun.build) to produce a Node.js-compatible CJS bundle at \`dist/bin.cjs\`. Key setup: \`bunSqlitePlugin\` replaces \`bun:sqlite\` imports with a polyfill from \`script/node-polyfills.ts\`. Build defines \`SENTRY\_CLI\_VERSION\` and \`SENTRY\_CLIENT\_ID\_BUILD\`, externalizes \`node:\*\` builtins. A \`sentrySourcemapPlugin\` handles debug ID injection and sourcemap upload. \*\*Bundle size constraint\*\*: the npm package size must not increase. Two output files are acceptable — one Node-optimized bundle (serves both \`npx\` CLI and library import) and a separate file for \`bun compile\` — but total npm size must stay flat. A shebang in the library bundle is acceptable. Debug IDs solve sourcemap deduplication when both bundles share the same source. Having the library entry point in the same bundle as the CLI (single file) is preferred over a second bundle when possible.
932+
933+
<!-- lore:019d2690-4de7-75ed-b6c6-fb3deaf8871e -->
934+
* **getConfigDir and getAuthToken read global process.env directly**: ~14 files read \`process.env\` directly for \`SENTRY\_\*\` vars instead of using Stricli's \`context.env\`. The planned fix is \`src/lib/env.ts\` — 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 Stricli, restores in \`finally\`. All \`process.env.SENTRY\_\*\` reads across ~14 files get ported to \`getEnv().SENTRY\_\*\`. Key files: \`db/auth.ts\`, \`db/index.ts\`, \`constants.ts\`, \`resolve-target.ts\`, \`telemetry.ts\`, \`formatters/plain-detect.ts\`, \`sentry-url-parser.ts\` (which also WRITES env vars). CLI mode never calls \`setEnv()\` so behavior is unchanged. Existing tests using \`useTestConfigDir()\` (which mutates \`process.env\`) continue to work since \`getEnv()\` defaults to \`process.env\`.
935+
936+
<!-- lore:019d26a2-8d2f-7a31-81aa-e665b4dde197 -->
937+
* **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, parsed into object. Non-zero exit throws \`SentryError\` with \`.exitCode\` and \`.stderr\`. Successful stderr (hints/tips) silently discarded. Telemetry runs in library mode (no process listeners, no HTTP instrumentation). Env isolation via \`setEnv({ ...process.env })\` — consumer's \`process.env\` never mutated.
938+
939+
<!-- lore:019d26a2-8d34-76ef-a855-c28a7e7796d1 -->
940+
* **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: no \`OnUncaughtException\`, \`OnUnhandledRejection\`, \`ProcessSession\` (process listeners), no \`Http\`/\`NodeFetch\` (trace header injection on consumer's HTTP), no \`FunctionToString\` (wraps \`Function.prototype.toString\`), no \`ChildProcess\`/\`NodeContext\`. Also disables \`enableLogs\` and \`sendClientReports\` (both use timers/\`beforeExit\`). Keeps pure integrations: \`eventFiltersIntegration\`, \`linkedErrorsIntegration\`. Library entry manually calls \`client.flush(3000)\` after command completion instead of relying on \`beforeExit\` handler. Only unavoidable global: \`globalThis.\_\_SENTRY\_\_\[SDK\_VERSION]\`.
941+
942+
### Decision
943+
944+
<!-- lore:019d26a2-8d3b-770a-866c-10ca04a374e7 -->
945+
* **OutputError propagates via throw instead of process.exit()**: The \`process.exit()\` call in \`command.ts\` (OutputError handler, line ~441) 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. Both entry points catch it: \`bin.ts\` sets \`process.exitCode\` silently, \`index.ts\` sets \`fakeProcess.exitCode\`. Data was already rendered to stdout by the \`buildCommand\` wrapper before the throw. This eliminates the only \`process.exit()\` outside of \`bin.ts\`, making the CLI safe for in-process library invocation.
946+
947+
### Pattern
948+
949+
<!-- lore:019d26a2-8d37-72ea-b7bd-14fef8556fea -->
950+
* **SENTRY\_OUTPUT\_FORMAT env var enables JSON mode from env instead of --json flag**: In \`src/lib/command.ts\`, the \`wrappedFunc\` checks \`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\`.
951+
952+
<!-- lore:019d2495-54da-7506-b6ba-fee422164bca -->
953+
* **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).
954+
955+
<!-- lore:019d2690-4df5-7b2d-8194-00771eb3a9ce -->
956+
* **Writer type is the minimal output interface for streams and mocks**: The \`Writer\` type in \`src/types/index.ts\` is just \`{ write(data: string): void }\` — deliberately minimal to avoid Node.js stream dependencies. Used for \`context.stdout\` and \`context.stderr\` in \`SentryContext\`. Library wrappers and test harnesses can implement this trivially with string concatenation buffers instead of needing real Node.js writable streams. The \`buildContext()\` function in \`src/context.ts\` assigns \`process.stdout\`/\`process.stderr\` as writers, but any object with a \`write\` method works.
957+
958+
### Preference
959+
960+
<!-- lore:019d26db-3ed0-7773-85ff-72226b404b98 -->
961+
* **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 goes between Configuration and Development sections, 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. Note: \`features.md\` and \`agent-guidance.md\` exist but are NOT in the sidebar — they're only reachable via direct URL.
930962
<!-- End lore-managed section -->

README.md

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,35 @@ For detailed documentation, visit [cli.sentry.dev](https://cli.sentry.dev).
8383

8484
Credentials are stored in `~/.sentry/` with restricted permissions (mode 600).
8585

86+
## Library Usage
87+
88+
Use Sentry CLI programmatically in Node.js (≥22) or Bun without spawning a subprocess:
89+
90+
```typescript
91+
import sentry from "sentry";
92+
93+
// Returns parsed JSON — same as `sentry issue list --json`
94+
const issues = await sentry("issue", "list", "-l", "5");
95+
96+
// Explicit auth token (auto-fills from SENTRY_AUTH_TOKEN env var)
97+
const orgs = await sentry("org", "list", { token: "sntrys_..." });
98+
99+
// Human-readable text output
100+
const text = await sentry("issue", "list", { text: true });
101+
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+
}
108+
```
109+
110+
Options (all optional):
111+
- `token` — Auth token. Falls back to `SENTRY_AUTH_TOKEN` / `SENTRY_TOKEN` env vars.
112+
- `text` — Return human-readable string instead of parsed JSON.
113+
- `cwd` — Working directory for DSN auto-detection. Defaults to `process.cwd()`.
114+
86115
---
87116

88117
## Development

docs/astro.config.mjs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -199,6 +199,7 @@ export default defineConfig({
199199
{ label: "Installation", slug: "getting-started" },
200200
{ label: "Self-Hosted", slug: "self-hosted" },
201201
{ label: "Configuration", slug: "configuration" },
202+
{ label: "Library Usage", slug: "library-usage" },
202203
],
203204
},
204205
{
Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
---
2+
title: Library Usage
3+
description: Use the Sentry CLI programmatically in Node.js or Bun
4+
---
5+
6+
The Sentry CLI can be used as a JavaScript/TypeScript library, running commands
7+
in-process without spawning a subprocess. This is useful for AI coding agents,
8+
build tools, CI scripts, and other tools that want structured Sentry data.
9+
10+
## Installation
11+
12+
```bash
13+
npm install sentry
14+
```
15+
16+
## Quick Start
17+
18+
```typescript
19+
import sentry from "sentry";
20+
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
24+
```
25+
26+
## Authentication
27+
28+
The `token` option provides an auth token for the current invocation. When
29+
omitted, it falls back to environment variables and stored credentials:
30+
31+
1. `token` option (highest priority)
32+
2. `SENTRY_AUTH_TOKEN` environment variable
33+
3. `SENTRY_TOKEN` environment variable
34+
4. Stored OAuth token from `sentry auth login`
35+
36+
```typescript
37+
// Explicit token
38+
const orgs = await sentry("org", "list", { token: "sntrys_..." });
39+
40+
// Or set the env var — it's picked up automatically
41+
process.env.SENTRY_AUTH_TOKEN = "sntrys_...";
42+
const orgs = await sentry("org", "list");
43+
```
44+
45+
## Options
46+
47+
All options are optional. Pass them as the last argument:
48+
49+
```typescript
50+
await sentry("issue", "list", { token: "...", text: true, cwd: "/my/project" });
51+
```
52+
53+
| Option | Type | Default | Description |
54+
|--------|------|---------|-------------|
55+
| `token` | `string` | Auto-detected | Auth token for this invocation |
56+
| `text` | `boolean` | `false` | Return human-readable text instead of parsed JSON |
57+
| `cwd` | `string` | `process.cwd()` | Working directory for DSN auto-detection |
58+
59+
## Return Values
60+
61+
By default, commands that support JSON output return a **parsed JavaScript object**
62+
— no serialization overhead. Commands without JSON support (like `help` or `--version`)
63+
return a trimmed string.
64+
65+
```typescript
66+
// Data commands → parsed object
67+
const issues = await sentry("issue", "list");
68+
// { data: [...], hasMore: true, nextCursor: "..." }
69+
70+
// Single-entity commands → parsed object
71+
const issue = await sentry("issue", "view", "PROJ-123");
72+
// { id: "123", title: "Bug", status: "unresolved", ... }
73+
74+
// Text commands → string
75+
const version = await sentry("--version");
76+
// "sentry 0.21.0"
77+
```
78+
79+
### Text Mode
80+
81+
Pass `{ text: true }` to get the human-readable output as a string:
82+
83+
```typescript
84+
const text = await sentry("issue", "list", { text: true });
85+
// "ID TITLE STATUS\n..."
86+
```
87+
88+
## Error Handling
89+
90+
Commands that exit with a non-zero code throw a `SentryError`:
91+
92+
```typescript
93+
import sentry, { SentryError } from "sentry";
94+
95+
try {
96+
await sentry("issue", "view", "NONEXISTENT-1");
97+
} catch (err) {
98+
if (err instanceof SentryError) {
99+
console.error(err.message); // Clean error message (no ANSI codes)
100+
console.error(err.exitCode); // Non-zero exit code
101+
console.error(err.stderr); // Raw stderr output
102+
}
103+
}
104+
```
105+
106+
## Environment Isolation
107+
108+
The library never mutates `process.env`. Each invocation creates an isolated
109+
copy of the environment. This means:
110+
111+
- Your application's env vars are never touched
112+
- Multiple sequential calls are safe
113+
- Auth tokens passed via `token` don't leak to subsequent calls
114+
115+
:::note
116+
Concurrent calls to `sentry()` are not supported in the current version.
117+
Calls should be sequential (awaited one at a time).
118+
:::
119+
120+
## Comparison with Subprocess
121+
122+
| | Library (`sentry()`) | Subprocess (`child_process`) |
123+
|---|---|---|
124+
| **Startup** | ~0ms (in-process) | ~200ms (process spawn + init) |
125+
| **Output** | Parsed object (zero-copy) | String (needs JSON.parse) |
126+
| **Errors** | `SentryError` with typed fields | Exit code + stderr string |
127+
| **Auth** | `token` option or env vars | Env vars only |
128+
| **Node.js** | ≥22 required | Any version |
129+
130+
## Requirements
131+
132+
- **Node.js ≥ 22** (required for `node:sqlite`)
133+
- Or **Bun** (any recent version)

package.json

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,12 +46,23 @@
4646
"bin": {
4747
"sentry": "./dist/bin.cjs"
4848
},
49+
"main": "./dist/index.cjs",
50+
"types": "./dist/index.d.cts",
51+
"exports": {
52+
".": {
53+
"types": "./dist/index.d.cts",
54+
"require": "./dist/index.cjs",
55+
"default": "./dist/index.cjs"
56+
}
57+
},
4958
"description": "Sentry CLI - A command-line interface for using Sentry built by robots and humans for robots and humans",
5059
"engines": {
5160
"node": ">=22"
5261
},
5362
"files": [
54-
"dist/bin.cjs"
63+
"dist/bin.cjs",
64+
"dist/index.cjs",
65+
"dist/index.d.cts"
5566
],
5667
"license": "FSL-1.1-Apache-2.0",
5768
"packageManager": "bun@1.3.11",

script/bundle.ts

Lines changed: 39 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -178,20 +178,17 @@ if (process.env.SENTRY_AUTH_TOKEN) {
178178
}
179179

180180
const result = await build({
181-
entryPoints: ["./src/bin.ts"],
181+
entryPoints: ["./src/index.ts"],
182182
bundle: true,
183183
minify: true,
184184
banner: {
185-
// Check Node.js version (>= 22 required for node:sqlite) and suppress warnings
186-
js: `#!/usr/bin/env node
187-
if(parseInt(process.versions.node)<22){console.error("Error: sentry requires Node.js 22 or later (found "+process.version+").\\n\\nEither upgrade Node.js, or install the standalone binary instead:\\n curl -fsSL https://cli.sentry.dev/install | bash\\n");process.exit(1)}
188-
{let e=process.emit;process.emit=function(n,...a){return n==="warning"?!1:e.apply(this,[n,...a])}}`,
185+
js: `{let e=process.emit;process.emit=function(n,...a){return n==="warning"?!1:e.apply(this,[n,...a])}}`,
189186
},
190187
sourcemap: true,
191188
platform: "node",
192189
target: "node22",
193190
format: "cjs",
194-
outfile: "./dist/bin.cjs",
191+
outfile: "./dist/index.cjs",
195192
// Inject Bun polyfills and import.meta.url shim for CJS compatibility
196193
inject: ["./script/node-polyfills.ts", "./script/import-meta-url.js"],
197194
define: {
@@ -207,11 +204,45 @@ if(parseInt(process.versions.node)<22){console.error("Error: sentry requires Nod
207204
plugins,
208205
});
209206

207+
// Write the CLI bin wrapper (tiny — shebang + version check + dispatch)
208+
const BIN_WRAPPER = `#!/usr/bin/env node
209+
if(parseInt(process.versions.node)<22){console.error("Error: sentry requires Node.js 22 or later (found "+process.version+").\\n\\nEither upgrade Node.js, or install the standalone binary instead:\\n curl -fsSL https://cli.sentry.dev/install | bash\\n");process.exit(1)}
210+
require('./index.cjs')._cli().catch(()=>{});
211+
`;
212+
await Bun.write("./dist/bin.cjs", BIN_WRAPPER);
213+
214+
// Write TypeScript declarations for the library API
215+
const TYPE_DECLARATIONS = `export type SentryOptions = {
216+
/** Auth token. Auto-filled from SENTRY_AUTH_TOKEN / SENTRY_TOKEN env vars. */
217+
token?: string;
218+
/** Return human-readable text instead of parsed JSON. */
219+
text?: boolean;
220+
/** Working directory (affects DSN detection, project root). Defaults to process.cwd(). */
221+
cwd?: string;
222+
};
223+
224+
export declare class SentryError extends Error {
225+
readonly exitCode: number;
226+
readonly stderr: string;
227+
constructor(message: string, exitCode: number, stderr: string);
228+
}
229+
230+
export declare function sentry(...args: string[]): Promise<unknown>;
231+
export declare function sentry(...args: [...string[], SentryOptions]): Promise<unknown>;
232+
233+
export { sentry };
234+
export default sentry;
235+
`;
236+
await Bun.write("./dist/index.d.cts", TYPE_DECLARATIONS);
237+
238+
console.log(" -> dist/bin.cjs (CLI wrapper)");
239+
console.log(" -> dist/index.d.cts (type declarations)");
240+
210241
// Calculate bundle size (only the main bundle, not source maps)
211-
const bundleOutput = result.metafile?.outputs["dist/bin.cjs"];
242+
const bundleOutput = result.metafile?.outputs["dist/index.cjs"];
212243
const bundleSize = bundleOutput?.bytes ?? 0;
213244
const bundleSizeKB = (bundleSize / 1024).toFixed(1);
214245

215-
console.log(`\n -> dist/bin.cjs (${bundleSizeKB} KB)`);
246+
console.log(`\n -> dist/index.cjs (${bundleSizeKB} KB)`);
216247
console.log(`\n${"=".repeat(40)}`);
217248
console.log("Bundle complete!");

0 commit comments

Comments
 (0)