diff --git a/packages/cli/script/bundle.ts b/packages/cli/script/bundle.ts index 6619c4011..0ba238886 100644 --- a/packages/cli/script/bundle.ts +++ b/packages/cli/script/bundle.ts @@ -6,6 +6,7 @@ import { build, type Plugin } from "esbuild"; import pkg from "../package.json"; import { uploadSourcemaps } from "../src/lib/api/sourcemaps.js"; import { injectDebugId, PLACEHOLDER_DEBUG_ID } from "./debug-id.js"; +import { buildCoreDeclarations, SDK_TYPES_PATH } from "./sdk-declarations.js"; import { textImportPlugin } from "./text-import-plugin.js"; const VERSION = pkg.version; @@ -360,43 +361,11 @@ require('./index.cjs')._cli().catch(()=>{process.exitCode=1}); await writeFile("./dist/bin.cjs", BIN_WRAPPER); // Write TypeScript declarations for the library API. -// The SentrySDK type is read from sdk.generated.d.cts (produced by generate-sdk.ts). -const CORE_DECLARATIONS = `export type SentryOptions = { - /** Auth token. Auto-filled from SENTRY_AUTH_TOKEN / SENTRY_TOKEN env vars. */ - token?: string; - /** Sentry instance URL for self-hosted. Defaults to sentry.io. */ - url?: string; - /** Default organization slug. */ - org?: string; - /** Default project slug. */ - project?: string; - /** Return human-readable text instead of parsed JSON. */ - 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 = AsyncIterable & { - push(value: T): void; - close(): void; - error(err: Error): void; -}; - -export declare class SentryError extends Error { - readonly exitCode: number; - readonly stderr: string; - constructor(message: string, exitCode: number, stderr: string); -} - -export declare function createSentrySDK(options?: SentryOptions): SentrySDK & { - /** Run an arbitrary CLI command (escape hatch). Streaming flags return AsyncIterable. */ - run(...args: string[]): Promise | AsyncIterable; -}; - -export default createSentrySDK; -`; +// The SentrySDK type is read from sdk.generated.d.cts (produced by generate-sdk.ts), and +// SentryOptions from sdk-types.ts so the published type cannot drift from the source. +const CORE_DECLARATIONS = buildCoreDeclarations( + await readFile(`./${SDK_TYPES_PATH}`, "utf-8") +); // Read pre-built SDK type declarations (generated by generate-sdk.ts) const sdkTypes = await readFile("./src/sdk.generated.d.cts", "utf-8"); diff --git a/packages/cli/script/sdk-declarations.ts b/packages/cli/script/sdk-declarations.ts new file mode 100644 index 000000000..c32a7772c --- /dev/null +++ b/packages/cli/script/sdk-declarations.ts @@ -0,0 +1,75 @@ +/** + * Hand-written half of the published type declarations (`dist/index.d.cts` / `.d.mts`). + * + * The per-command `SentrySDK` types are generated by `generate-sdk.ts`; everything here + * describes the library entry point itself. + */ + +/** Location of the module `SentryOptions` is lifted from, relative to `packages/cli`. */ +export const SDK_TYPES_PATH = "src/lib/sdk-types.ts"; + +const SENTRY_OPTIONS_START = /export type SentryOptions = \{/m; +const SENTRY_OPTIONS_END = /^\}\s*;/; + +/** + * Lifts the `SentryOptions` declaration verbatim out of `sdk-types.ts`. + * + * This used to be a hand-written copy, which drifted without anything failing: `headers` was + * added to the source and worked at runtime in 0.44.0, yet consumers passing it got a type + * error because the copy never learned about the option. + */ +export function extractSentryOptions(sdkTypesSource: string): string { + const startMatch = sdkTypesSource.match(SENTRY_OPTIONS_START); + if (!startMatch || startMatch.index === undefined) { + throw new Error( + `Could not find the \`SentryOptions\` declaration in ${SDK_TYPES_PATH}.` + ); + } + + const start = startMatch.index; + let depth = 1; + let i = start + startMatch[0].length; + while (i < sdkTypesSource.length && depth > 0) { + const char = sdkTypesSource[i]; + if (char === "{") { + depth += 1; + } else if (char === "}") { + depth -= 1; + } + i += 1; + } + + const endMatch = sdkTypesSource.slice(i - 1).match(SENTRY_OPTIONS_END); + if (depth !== 0 || !endMatch) { + throw new Error( + `Could not find the \`SentryOptions\` declaration in ${SDK_TYPES_PATH}.` + ); + } + + return sdkTypesSource.slice(start, i - 1 + endMatch[0].length); +} + +/** Assembles the entry-point declarations from the current `sdk-types.ts` source. */ +export function buildCoreDeclarations(sdkTypesSource: string): string { + return `${extractSentryOptions(sdkTypesSource)} + +export type AsyncChannel = AsyncIterable & { + push(value: T): void; + close(): void; + error(err: Error): void; +}; + +export declare class SentryError extends Error { + readonly exitCode: number; + readonly stderr: string; + constructor(message: string, exitCode: number, stderr: string); +} + +export declare function createSentrySDK(options?: SentryOptions): SentrySDK & { + /** Run an arbitrary CLI command (escape hatch). Streaming flags return AsyncIterable. */ + run(...args: string[]): Promise | AsyncIterable; +}; + +export default createSentrySDK; +`; +} diff --git a/packages/cli/test/script/sdk-declarations.test.ts b/packages/cli/test/script/sdk-declarations.test.ts new file mode 100644 index 000000000..2906f2779 --- /dev/null +++ b/packages/cli/test/script/sdk-declarations.test.ts @@ -0,0 +1,50 @@ +import { readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { describe, expect, test } from "vitest"; +import { + buildCoreDeclarations, + extractSentryOptions, + SDK_TYPES_PATH, +} from "../../script/sdk-declarations.js"; + +/** + * The published `SentryOptions` type used to be a hand-written copy of the one in + * `sdk-types.ts`. It drifted silently: `headers` was added to the source and worked at + * runtime in 0.44.0, but consumers passing it got "does not exist in type 'SentryOptions'". + */ +describe("SDK entry-point declarations", () => { + const source = readFileSync( + fileURLToPath(new URL(`../../${SDK_TYPES_PATH}`, import.meta.url)), + "utf-8" + ); + + test("declares every option the source type declares", () => { + const declarations = buildCoreDeclarations(source); + + const options = extractSentryOptions(source); + const optionNames = [...options.matchAll(/^ {2}(\w+)\??:/gm)].map( + (match) => match[1] + ); + + expect(optionNames).toContain("headers"); + for (const name of optionNames) { + expect(declarations).toContain(`${name}?:`); + } + }); + + test("throws when the source type can no longer be located", () => { + expect(() => extractSentryOptions("export type Something = {};")).toThrow( + SDK_TYPES_PATH + ); + }); + + test("captures the whole declaration when nested object literals are present", () => { + const fixture = `export type SentryOptions = { + token?: string; + nested?: { + inner: number; + }; +};`; + expect(extractSentryOptions(fixture)).toBe(fixture); + }); +});