Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,7 @@
"typescript": "^5.9.3",
"ultracite": "6.3.10",
"uuidv7": "^1.2.1",
"valibot": "^1.4.2",
"vitest": "^4.1.9",
"wrap-ansi": "^10.0.0",
"zod": "^3.25.76"
Expand Down
34 changes: 17 additions & 17 deletions packages/cli/src/lib/dsn/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
* All types related to DSN parsing, detection, and caching.
*/

import { z } from "zod";
import { array, number, object, optional, picklist, string } from "valibot";

/**
* Source where DSN was detected from
Expand Down Expand Up @@ -92,24 +92,24 @@ export type CachedDsnEntry = {
cachedAt: number;
};

/** Zod schema for ResolvedProjectInfo */
export const ResolvedProjectInfoSchema = z.object({
orgSlug: z.string(),
orgName: z.string(),
projectSlug: z.string(),
projectName: z.string(),
/** Valibot schema for ResolvedProjectInfo */
export const ResolvedProjectInfoSchema = object({
orgSlug: string(),
orgName: string(),
projectSlug: string(),
projectName: string(),
});

/** Zod schema for cached DSN entries (for config validation) */
export const CachedDsnEntrySchema = z.object({
dsn: z.string(),
projectId: z.string(),
orgId: z.string().optional(),
source: z.enum(["env", "env_file", "config", "code", "inferred"]),
sourcePath: z.string().optional(),
resolved: ResolvedProjectInfoSchema.optional(),
allResolved: z.array(ResolvedProjectInfoSchema).optional(),
cachedAt: z.number(),
/** Valibot schema for cached DSN entries (for config validation) */
export const CachedDsnEntrySchema = object({
dsn: string(),
projectId: string(),
orgId: optional(string()),
source: picklist(["env", "env_file", "config", "code", "inferred"]),
sourcePath: optional(string()),
resolved: optional(ResolvedProjectInfoSchema),
allResolved: optional(array(ResolvedProjectInfoSchema)),
cachedAt: number(),
});

/**
Expand Down
27 changes: 14 additions & 13 deletions packages/cli/src/lib/oauth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
* https://datatracker.ietf.org/doc/html/rfc8628
*/

import { safeParse } from "valibot";
import type { TokenResponse } from "../types/index.js";
import {
DeviceCodeResponseSchema,
Expand Down Expand Up @@ -298,17 +299,17 @@ function requestDeviceCode(scope: string = SCOPES) {
);
}

const result = DeviceCodeResponseSchema.safeParse(data);
const result = safeParse(DeviceCodeResponseSchema, data);
if (!result.success) {
throw new ApiError(
"Invalid response from device authorization endpoint",
response.status,
result.error.errors.map((e) => e.message).join(", "),
result.issues.map((i) => i.message).join(", "),
"/oauth/device/code/"
);
}

return result.data;
return result.output;
});
}

Expand Down Expand Up @@ -344,17 +345,17 @@ function pollForToken(deviceCode: string): Promise<TokenResponse> {
}

// Try to parse as success response first
const tokenResult = TokenResponseSchema.safeParse(data);
const tokenResult = safeParse(TokenResponseSchema, data);
if (tokenResult.success) {
return tokenResult.data;
return tokenResult.output;
}

// Try to parse as error response
const errorResult = TokenErrorResponseSchema.safeParse(data);
const errorResult = safeParse(TokenErrorResponseSchema, data);
if (errorResult.success) {
throw new DeviceFlowError(
errorResult.data.error,
errorResult.data.error_description
errorResult.output.error,
errorResult.output.error_description
);
}

Expand Down Expand Up @@ -530,10 +531,10 @@ export function refreshAccessToken(
let errorDetail = "Token refresh failed";
try {
const errorData = await response.json();
const errorResult = TokenErrorResponseSchema.safeParse(errorData);
const errorResult = safeParse(TokenErrorResponseSchema, errorData);
if (errorResult.success) {
errorDetail =
errorResult.data.error_description ?? errorResult.data.error;
errorResult.output.error_description ?? errorResult.output.error;
}
} catch {
// Ignore JSON parse errors
Expand All @@ -558,17 +559,17 @@ export function refreshAccessToken(
);
}

const result = TokenResponseSchema.safeParse(data);
const result = safeParse(TokenResponseSchema, data);

if (!result.success) {
throw new ApiError(
"Invalid response from token refresh endpoint",
response.status,
result.error.errors.map((e) => e.message).join(", "),
result.issues.map((i) => i.message).join(", "),
"/oauth/token/"
);
}

return result.data;
return result.output;
});
}
10 changes: 5 additions & 5 deletions packages/cli/src/lib/qrcode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,22 +6,22 @@
*/

import qrcodeTerminal from "qrcode-terminal";
import { z } from "zod";
import { boolean, type InferOutput, object, optional, parse } from "valibot";

// Schema & Types

/**
* QR code generation options schema
*/
export const QRCodeOptionsSchema = z.object({
export const QRCodeOptionsSchema = object({
/**
* Use compact (small) QR code rendering.
* Recommended for terminal display.
*/
small: z.boolean().default(true),
small: optional(boolean(), true),
});

export type QRCodeOptions = z.infer<typeof QRCodeOptionsSchema>;
export type QRCodeOptions = InferOutput<typeof QRCodeOptionsSchema>;

// Public API

Expand All @@ -42,7 +42,7 @@ export function generateQRCode(
data: string,
options?: Partial<QRCodeOptions>
): Promise<string> {
const opts = QRCodeOptionsSchema.parse(options ?? {});
const opts = parse(QRCodeOptionsSchema, options ?? {});

return new Promise((resolve) => {
qrcodeTerminal.generate(data, { small: opts.small }, (qrcode) => {
Expand Down
75 changes: 41 additions & 34 deletions packages/cli/src/types/config.ts
Original file line number Diff line number Diff line change
@@ -1,96 +1,103 @@
/**
* Configuration Types
*
* Types and Zod schemas for the Sentry CLI configuration file.
* Types and Valibot schemas for the Sentry CLI configuration file.
*/

import { z } from "zod";
import {
type InferOutput,
number,
object,
optional,
record,
string,
} from "valibot";
import { CachedDsnEntrySchema } from "../lib/dsn/types.js";

/**
* Schema for cached project information
*/
export const CachedProjectSchema = z.object({
orgSlug: z.string(),
orgName: z.string(),
projectSlug: z.string(),
projectName: z.string(),
projectId: z.string().optional(),
cachedAt: z.number(),
export const CachedProjectSchema = object({
orgSlug: string(),
orgName: string(),
projectSlug: string(),
projectName: string(),
projectId: optional(string()),
cachedAt: number(),
});

export type CachedProject = z.infer<typeof CachedProjectSchema>;
export type CachedProject = InferOutput<typeof CachedProjectSchema>;

/**
* Schema for project alias entry (used for short issue ID resolution)
*/
export const ProjectAliasEntrySchema = z.object({
orgSlug: z.string(),
projectSlug: z.string(),
export const ProjectAliasEntrySchema = object({
orgSlug: string(),
projectSlug: string(),
});

export type ProjectAliasEntry = z.infer<typeof ProjectAliasEntrySchema>;
export type ProjectAliasEntry = InferOutput<typeof ProjectAliasEntrySchema>;

/**
* Schema for cached project aliases (A, B, C... -> org/project mapping).
* Scoped by DSN fingerprint to prevent cross-project conflicts in monorepos.
*/
export const ProjectAliasesSchema = z.object({
export const ProjectAliasesSchema = object({
/** Map of alias letter to project info */
aliases: z.record(ProjectAliasEntrySchema),
aliases: record(string(), ProjectAliasEntrySchema),
/** Timestamp when aliases were set */
cachedAt: z.number(),
cachedAt: number(),
/**
* Fingerprint of detected DSNs for validation.
* Format: sorted comma-separated list of "orgId:projectId" pairs.
* Aliases only valid when current DSN detection matches this fingerprint.
*/
dsnFingerprint: z.string().optional(),
dsnFingerprint: optional(string()),
});

export type ProjectAliases = z.infer<typeof ProjectAliasesSchema>;
export type ProjectAliases = InferOutput<typeof ProjectAliasesSchema>;

/**
* Schema for authentication configuration
*/
export const AuthConfigSchema = z.object({
token: z.string().optional(),
refreshToken: z.string().optional(),
expiresAt: z.number().optional(),
issuedAt: z.number().optional(),
export const AuthConfigSchema = object({
token: optional(string()),
refreshToken: optional(string()),
expiresAt: optional(number()),
issuedAt: optional(number()),
});

/**
* Schema for default organization/project settings
*/
export const DefaultsConfigSchema = z.object({
organization: z.string().optional(),
project: z.string().optional(),
export const DefaultsConfigSchema = object({
organization: optional(string()),
project: optional(string()),
});

/**
* Schema for the full Sentry CLI configuration file
*/
export const SentryConfigSchema = z.object({
auth: AuthConfigSchema.optional(),
defaults: DefaultsConfigSchema.optional(),
export const SentryConfigSchema = object({
auth: optional(AuthConfigSchema),
defaults: optional(DefaultsConfigSchema),
/**
* Cache of DSN -> project info mappings
* Key format: "{orgId}:{projectId}"
*/
projectCache: z.record(CachedProjectSchema).optional(),
projectCache: optional(record(string(), CachedProjectSchema)),
/**
* Cache of detected DSNs per directory
* Key: absolute directory path
* Value: cached DSN entry with source and resolution info
*/
dsnCache: z.record(CachedDsnEntrySchema).optional(),
dsnCache: optional(record(string(), CachedDsnEntrySchema)),
/**
* Cached project aliases for short issue ID resolution.
* Scoped by DSN fingerprint to prevent cross-project conflicts.
* Set by `issue list` when multiple projects are detected.
*/
projectAliases: ProjectAliasesSchema.optional(),
projectAliases: optional(ProjectAliasesSchema),
});

export type SentryConfig = z.infer<typeof SentryConfigSchema>;
export type SentryConfig = InferOutput<typeof SentryConfigSchema>;
Loading
Loading