From 5d5223f204f394f73d301c052e1a1f2dc001ff29 Mon Sep 17 00:00:00 2001 From: Burak Yigit Kaya Date: Tue, 9 Jun 2026 14:13:53 +0000 Subject: [PATCH 1/2] feat: implement `dart-symbol-map upload` command (#1055) Port the legacy `sentry-cli dart-symbol-map upload` command. Uploads Dart/Flutter obfuscation maps to Sentry for deobfuscating Dart exception types, using the DIF chunk-upload protocol. Key design decisions: - Requires `--debug-id ` flag (not a companion debug file) since native binary parsing requires the Rust `symbolic` crate. The sentry-dart-plugin already extracts the debug ID before calling this command, so `--debug-id` is the practical interface. - Validates the map file is a JSON array of strings with even length - `--no-upload` validates without uploading (no auth needed) - Uses the same DIF assemble endpoint as ProGuard uploads: `projects/{org}/{project}/files/difs/assemble/` - Includes `debug_id` in the assemble body to link the map to its companion native debug file (dSYM/ELF) Files: - src/lib/api/dart-symbols.ts \u2014 DIF chunk-upload API module - src/commands/dart-symbol-map/upload.ts \u2014 command implementation - src/commands/dart-symbol-map/index.ts \u2014 route map - src/app.ts \u2014 route registration - test/commands/dart-symbol-map/upload.test.ts \u2014 11 tests --- docs/src/content/docs/contributing.md | 1 + docs/src/fragments/commands/cli.md | 13 + .../src/fragments/commands/dart-symbol-map.md | 22 ++ plugins/sentry-cli/skills/sentry-cli/SKILL.md | 8 + .../sentry-cli/references/dart-symbol-map.md | 22 ++ .../skills/sentry-cli/references/issue.md | 12 +- script/generate-sdk.ts | 6 +- src/app.ts | 2 + src/commands/dart-symbol-map/index.ts | 20 ++ src/commands/dart-symbol-map/upload.ts | 277 ++++++++++++++++++ src/lib/api/dart-symbols.ts | 217 ++++++++++++++ test/commands/dart-symbol-map/upload.test.ts | 220 ++++++++++++++ 12 files changed, 812 insertions(+), 8 deletions(-) create mode 100644 docs/src/fragments/commands/dart-symbol-map.md create mode 100644 plugins/sentry-cli/skills/sentry-cli/references/dart-symbol-map.md create mode 100644 src/commands/dart-symbol-map/index.ts create mode 100644 src/commands/dart-symbol-map/upload.ts create mode 100644 src/lib/api/dart-symbols.ts create mode 100644 test/commands/dart-symbol-map/upload.test.ts diff --git a/docs/src/content/docs/contributing.md b/docs/src/content/docs/contributing.md index 1ef2c5f13..c1d9b86a4 100644 --- a/docs/src/content/docs/contributing.md +++ b/docs/src/content/docs/contributing.md @@ -55,6 +55,7 @@ cli/ │ │ ├── alert/ # create, delete, edit, list, view │ │ ├── auth/ # login, logout, refresh, status, token, whoami │ │ ├── cli/ # defaults, feedback, fix, import, setup, uninstall, upgrade +│ │ ├── dart-symbol-map/# upload │ │ ├── dashboard/ # add, create, delete, edit, list, restore, revisions, view │ │ ├── event/ # list, send, view │ │ ├── issue/ # archive, events, explain, list, merge, plan, resolve, unresolve, view diff --git a/docs/src/fragments/commands/cli.md b/docs/src/fragments/commands/cli.md index 6439b1f19..4f3e6f79c 100644 --- a/docs/src/fragments/commands/cli.md +++ b/docs/src/fragments/commands/cli.md @@ -143,3 +143,16 @@ sentry cli setup --no-agent-skills # Skip PATH and completion modifications sentry cli setup --no-modify-path --no-completions ``` + +### Uninstall + +```bash +# Show what would be removed (dry run) +sentry cli uninstall --dry-run + +# Uninstall, keeping config directory +sentry cli uninstall --yes --keep-config + +# Full uninstall with confirmation +sentry cli uninstall +``` diff --git a/docs/src/fragments/commands/dart-symbol-map.md b/docs/src/fragments/commands/dart-symbol-map.md new file mode 100644 index 000000000..9ff976fcb --- /dev/null +++ b/docs/src/fragments/commands/dart-symbol-map.md @@ -0,0 +1,22 @@ + + +## Examples + +```bash +# Upload a dart symbol map with a debug ID +sentry dart-symbol-map upload --debug-id 12345678-1234-1234-1234-123456789abc mapping.json + +# Validate without uploading +sentry dart-symbol-map upload --debug-id 12345678-1234-1234-1234-123456789abc mapping.json --no-upload + +# Output as JSON +sentry dart-symbol-map upload --debug-id 12345678-1234-1234-1234-123456789abc mapping.json --json +``` + +## Important Notes + +- The `--debug-id` flag is **required** — it associates the map with a native + debug file (dSYM/ELF). The sentry-dart-plugin extracts this automatically. +- The mapping file must be a **JSON array of strings** with an even number of + entries (alternating obfuscated/original name pairs). +- Supported on Sentry SaaS and self-hosted >= 25.8.0. diff --git a/plugins/sentry-cli/skills/sentry-cli/SKILL.md b/plugins/sentry-cli/skills/sentry-cli/SKILL.md index 16c9a2404..723f192e3 100644 --- a/plugins/sentry-cli/skills/sentry-cli/SKILL.md +++ b/plugins/sentry-cli/skills/sentry-cli/SKILL.md @@ -390,6 +390,14 @@ CLI-related commands → Full flags and examples: `references/cli.md` +### Dart-symbol-map + +Work with Dart/Flutter symbol maps + +- `sentry dart-symbol-map upload ` — Upload a Dart/Flutter symbol map to Sentry + +→ Full flags and examples: `references/dart-symbol-map.md` + ### Dashboard Manage Sentry dashboards diff --git a/plugins/sentry-cli/skills/sentry-cli/references/dart-symbol-map.md b/plugins/sentry-cli/skills/sentry-cli/references/dart-symbol-map.md new file mode 100644 index 000000000..a94a0a273 --- /dev/null +++ b/plugins/sentry-cli/skills/sentry-cli/references/dart-symbol-map.md @@ -0,0 +1,22 @@ +--- +name: sentry-cli-dart-symbol-map +version: 0.37.0-dev.0 +description: Work with Dart/Flutter symbol maps +requires: + bins: ["sentry"] + auth: true +--- + +# Dart-symbol-map Commands + +Work with Dart/Flutter symbol maps + +### `sentry dart-symbol-map upload ` + +Upload a Dart/Flutter symbol map to Sentry + +**Flags:** +- `-d, --debug-id - Debug ID (UUID) from the companion native debug file` +- `--no-upload - Validate the file without uploading (dry-run)` + +All commands also support `--json`, `--fields`, `--help`, `--log-level`, and `--verbose` flags. diff --git a/plugins/sentry-cli/skills/sentry-cli/references/issue.md b/plugins/sentry-cli/skills/sentry-cli/references/issue.md index f6af30f10..9d5a75a5f 100644 --- a/plugins/sentry-cli/skills/sentry-cli/references/issue.md +++ b/plugins/sentry-cli/skills/sentry-cli/references/issue.md @@ -31,11 +31,11 @@ List issues in a project | `id` | string | Numeric issue ID | | `shortId` | string | Human-readable short ID (e.g. PROJ-ABC) | | `title` | string | Issue title | -| `culprit` | string \| null | Culprit string | +| `culprit` | string | Culprit string | | `count` | string | Total event count | | `userCount` | number | Number of affected users | -| `firstSeen` | string \| null | First occurrence (ISO 8601) | -| `lastSeen` | string \| null | Most recent occurrence (ISO 8601) | +| `firstSeen` | string | First occurrence (ISO 8601) | +| `lastSeen` | string | Most recent occurrence (ISO 8601) | | `level` | string | Severity level | | `status` | string | Issue status | | `permalink` | string | URL to the issue in Sentry | @@ -190,11 +190,11 @@ View details of a specific issue | `id` | string | Numeric issue ID | | `shortId` | string | Human-readable short ID (e.g. PROJ-ABC) | | `title` | string | Issue title | -| `culprit` | string \| null | Culprit string | +| `culprit` | string | Culprit string | | `count` | string | Total event count | | `userCount` | number | Number of affected users | -| `firstSeen` | string \| null | First occurrence (ISO 8601) | -| `lastSeen` | string \| null | Most recent occurrence (ISO 8601) | +| `firstSeen` | string | First occurrence (ISO 8601) | +| `lastSeen` | string | Most recent occurrence (ISO 8601) | | `level` | string | Severity level | | `status` | string | Issue status | | `permalink` | string | URL to the issue in Sentry | diff --git a/script/generate-sdk.ts b/script/generate-sdk.ts index 5988bb6b3..58d9a1c44 100644 --- a/script/generate-sdk.ts +++ b/script/generate-sdk.ts @@ -490,7 +490,8 @@ function renderNamespaceNode(node: NamespaceNode, indent: string): string { // Render child namespaces for (const [name, child] of node.children) { const childBody = renderNamespaceNode(child, `${indent} `); - parts.push(`${indent}${name}: {\n${childBody}\n${indent}},`); + const key = needsQuoting(name) ? `"${name}"` : name; + parts.push(`${indent}${key}: {\n${childBody}\n${indent}},`); } return parts.join("\n"); @@ -508,7 +509,8 @@ function renderNamespaceTypeNode(node: NamespaceNode, indent: string): string { // Render child namespaces as nested object types for (const [name, child] of node.children) { const childBody = renderNamespaceTypeNode(child, `${indent} `); - parts.push(`${indent}${name}: {\n${childBody}\n${indent}};`); + const key = needsQuoting(name) ? `"${name}"` : name; + parts.push(`${indent}${key}: {\n${childBody}\n${indent}};`); } return parts.join("\n"); diff --git a/src/app.ts b/src/app.ts index 3ae352b0c..bfe31e3ab 100644 --- a/src/app.ts +++ b/src/app.ts @@ -11,6 +11,7 @@ import { authRoute } from "./commands/auth/index.js"; import { whoamiCommand } from "./commands/auth/whoami.js"; import { bashHookCommand } from "./commands/bash-hook.js"; import { cliRoute } from "./commands/cli/index.js"; +import { dartSymbolMapRoute } from "./commands/dart-symbol-map/index.js"; import { dashboardRoute } from "./commands/dashboard/index.js"; import { listCommand as dashboardListCommand } from "./commands/dashboard/list.js"; import { eventRoute } from "./commands/event/index.js"; @@ -95,6 +96,7 @@ export const routes = buildRouteMap({ alert: alertRoute, auth: authRoute, cli: cliRoute, + "dart-symbol-map": dartSymbolMapRoute, dashboard: dashboardRoute, org: orgRoute, project: projectRoute, diff --git a/src/commands/dart-symbol-map/index.ts b/src/commands/dart-symbol-map/index.ts new file mode 100644 index 000000000..5c48d0974 --- /dev/null +++ b/src/commands/dart-symbol-map/index.ts @@ -0,0 +1,20 @@ +/** + * sentry dart-symbol-map + * + * Route map for Dart/Flutter symbol map commands. + */ + +import { buildRouteMap } from "../../lib/route-map.js"; +import { uploadCommand } from "./upload.js"; + +export const dartSymbolMapRoute = buildRouteMap({ + routes: { + upload: uploadCommand, + }, + docs: { + brief: "Work with Dart/Flutter symbol maps", + fullDescription: + "Upload Dart/Flutter obfuscation maps for deobfuscating Dart " + + "exception types in Sentry.", + }, +}); diff --git a/src/commands/dart-symbol-map/upload.ts b/src/commands/dart-symbol-map/upload.ts new file mode 100644 index 000000000..d819df382 --- /dev/null +++ b/src/commands/dart-symbol-map/upload.ts @@ -0,0 +1,277 @@ +/** + * sentry dart-symbol-map upload + * + * Upload a Dart/Flutter obfuscation map to Sentry for deobfuscating + * Dart exception types. The map must be a JSON array of strings with + * an even number of entries (alternating obfuscated/original names). + * + * Requires a `--debug-id` flag to associate the map with a native + * debug file. The sentry-dart-plugin extracts this from the companion + * dSYM/ELF file before calling this command. + */ + +import { readFile } from "node:fs/promises"; +import { basename } from "node:path"; +import type { SentryContext } from "../../context.js"; +import { uploadDartSymbolMap } from "../../lib/api/dart-symbols.js"; +import { buildCommand } from "../../lib/command.js"; +import { ContextError, ValidationError } from "../../lib/errors.js"; +import { mdKvTable, renderMarkdown } from "../../lib/formatters/markdown.js"; +import { CommandOutput } from "../../lib/formatters/output.js"; +import { logger } from "../../lib/logger.js"; +import { resolveOrgAndProject } from "../../lib/resolve-target.js"; + +const log = logger.withTag("dart-symbol-map.upload"); + +// ── Types ─────────────────────────────────────────────────────────── + +/** Structured result for the upload command. */ +type DartSymbolMapUploadResult = { + /** Organization slug. Omitted for --no-upload. */ + org?: string; + /** Project slug. Omitted for --no-upload. */ + project?: string; + /** Path to the mapping file. */ + path: string; + /** Debug ID associated with the mapping. */ + debugId: string; + /** Whether the file was uploaded. */ + uploaded: boolean; +}; + +// ── Formatter ─────────────────────────────────────────────────────── + +const USAGE_HINT = "sentry dart-symbol-map upload --debug-id "; + +/** Format human-readable output for upload results. */ +function formatUploadResult(data: DartSymbolMapUploadResult): string { + const rows: [string, string][] = []; + if (data.org) { + rows.push(["Organization", data.org]); + } + if (data.project) { + rows.push(["Project", data.project]); + } + rows.push(["File", data.path]); + rows.push(["Debug ID", data.debugId]); + rows.push(["Uploaded", data.uploaded ? "yes" : "no (dry run)"]); + return renderMarkdown(mdKvTable(rows)); +} + +// ── Helpers ───────────────────────────────────────────────────────── + +/** UUID format: 8-4-4-4-12 hex with hyphens. */ +const UUID_RE = + /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + +/** + * Validate that a debug ID is a well-formed UUID. + * + * @throws {ValidationError} If the debug ID is malformed + */ +function validateDebugId(debugId: string): void { + if (!UUID_RE.test(debugId)) { + throw new ValidationError( + `Invalid debug ID format: '${debugId}'. Expected UUID format: xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx`, + "debug-id" + ); + } +} + +/** + * Validate that the file content is a valid dart symbol map. + * + * Must be a JSON array of strings with an even number of entries. + * + * @throws {ValidationError} If the content is not valid + */ +function validateDartSymbolMap(content: Buffer, path: string): void { + let parsed: unknown; + try { + parsed = JSON.parse(content.toString("utf-8")); + } catch { + throw new ValidationError( + `Invalid dart symbol map '${path}': not valid JSON`, + "path" + ); + } + + if (!Array.isArray(parsed)) { + throw new ValidationError( + `Invalid dart symbol map '${path}': expected a JSON array, got ${typeof parsed}`, + "path" + ); + } + + for (let i = 0; i < parsed.length; i++) { + if (typeof parsed[i] !== "string") { + throw new ValidationError( + `Invalid dart symbol map '${path}': entry at index ${i} is ${typeof parsed[i]}, expected string`, + "path" + ); + } + } + + if (parsed.length % 2 !== 0) { + throw new ValidationError( + `Invalid dart symbol map '${path}': expected an even number of entries (pairs), got ${parsed.length}`, + "path" + ); + } +} + +/** + * Read a mapping file from disk with descriptive error handling. + * + * @param path - Path to the mapping file + * @returns File content as a Buffer + * @throws {ValidationError} On ENOENT, EISDIR, or other read failures + */ +async function readMappingFile(path: string): Promise { + try { + return await readFile(path); + } catch (err) { + const code = (err as NodeJS.ErrnoException).code; + if (code === "ENOENT") { + throw new ValidationError( + `Dart symbol map '${path}' does not exist.`, + "path" + ); + } + if (code === "EISDIR") { + throw new ValidationError( + `Path '${path}' is a directory, not a dart symbol map file.`, + "path" + ); + } + const msg = err instanceof Error ? err.message : String(err); + throw new ValidationError( + `Cannot read dart symbol map '${path}': ${msg}`, + "path" + ); + } +} + +// ── Command ───────────────────────────────────────────────────────── + +export const uploadCommand = buildCommand({ + // Auth is not required for --no-upload (dry-run mode). + // The upload path calls resolveOrgAndProject which triggers auth. + auth: false, + docs: { + brief: "Upload a Dart/Flutter symbol map to Sentry", + fullDescription: + "Upload a Dart/Flutter obfuscation map for deobfuscating Dart exception " + + "types. The map must be a JSON array of strings with an even number of " + + "entries (alternating obfuscated/original name pairs).\n\n" + + "A debug ID (--debug-id) is required to associate the map with its " + + "companion native debug file (dSYM/ELF). The sentry-dart-plugin " + + "extracts this automatically.\n\n" + + "Usage:\n" + + " sentry dart-symbol-map upload --debug-id mapping.json\n" + + " sentry dart-symbol-map upload --debug-id mapping.json --no-upload\n" + + " sentry dart-symbol-map upload --debug-id mapping.json --json\n\n" + + "Supported on Sentry SaaS and self-hosted >= 25.8.0.", + }, + output: { + human: formatUploadResult, + }, + parameters: { + positional: { + kind: "tuple", + parameters: [ + { + brief: "Path to the dart symbol map JSON file", + parse: String, + placeholder: "path", + }, + ], + }, + flags: { + "debug-id": { + kind: "parsed", + parse: String, + brief: "Debug ID (UUID) from the companion native debug file", + }, + "no-upload": { + kind: "boolean", + brief: "Validate the file without uploading (dry-run)", + optional: true, + default: false, + }, + }, + aliases: { + d: "debug-id", + }, + }, + async *func( + this: SentryContext, + flags: { + "debug-id": string; + "no-upload"?: boolean; + }, + mappingPath: string + ) { + // 1. Validate debug ID format + validateDebugId(flags["debug-id"]); + + // 2. Read and validate the mapping file + const content = await readMappingFile(mappingPath); + if (content.length === 0) { + throw new ValidationError( + `Dart symbol map '${mappingPath}' is empty.`, + "path" + ); + } + validateDartSymbolMap(content, mappingPath); + + // 3. --no-upload: validate only, no auth needed + if (flags["no-upload"]) { + yield new CommandOutput({ + path: mappingPath, + debugId: flags["debug-id"], + uploaded: false, + }); + return { + hint: `Validated dart symbol map: ${mappingPath} (debug ID: ${flags["debug-id"]})`, + }; + } + + // 4. Resolve org/project + const resolved = await resolveOrgAndProject({ + cwd: this.cwd, + usageHint: USAGE_HINT, + }); + if (!resolved) { + throw new ContextError("Organization and project", USAGE_HINT); + } + const { org, project } = resolved; + + // 5. Upload + log.debug( + `Uploading dart symbol map '${mappingPath}' (debug ID: ${flags["debug-id"]}) to ${org}/${project}` + ); + await uploadDartSymbolMap({ + org, + project, + mapping: { + path: basename(mappingPath), + debugId: flags["debug-id"], + content, + }, + }); + + // 6. Yield result + yield new CommandOutput({ + org, + project, + path: mappingPath, + debugId: flags["debug-id"], + uploaded: true, + }); + + return { + hint: `Uploaded dart symbol map: ${mappingPath} (debug ID: ${flags["debug-id"]})`, + }; + }, +}); diff --git a/src/lib/api/dart-symbols.ts b/src/lib/api/dart-symbols.ts new file mode 100644 index 000000000..62c8a2071 --- /dev/null +++ b/src/lib/api/dart-symbols.ts @@ -0,0 +1,217 @@ +/** + * Dart Symbol Map DIF Upload API + * + * Uploads Dart/Flutter obfuscation maps via the DIF chunk-upload + assemble + * protocol. Each mapping file is chunked as raw bytes and assembled through + * the DIF endpoint with an externally-provided debug ID. + * + * Protocol: identical to ProGuard DIF uploads — raw bytes chunked directly + * (no ZIP), assembled via `projects/{org}/{project}/files/difs/assemble/`. + * The `debug_id` field in the assemble body links the map to a native + * debug file (dSYM/ELF). + */ + +import { z } from "zod"; +import { ApiError } from "../errors.js"; +import { logger } from "../logger.js"; +import { resolveOrgRegion } from "../region.js"; +import { + ASSEMBLE_MAX_WAIT_MS, + ASSEMBLE_POLL_INTERVAL_MS, + type AssembleResponse, + AssembleResponseSchema, + type ChunkInfo, + getChunkUploadOptions, + hashBuffer, + pickUploadEncoding, + uploadMissingBufferChunks, +} from "./chunk-upload.js"; +import { apiRequestToRegion } from "./infrastructure.js"; + +const log = logger.withTag("api.dart-symbols"); + +// ── Types ─────────────────────────────────────────────────────────── + +/** A single dart symbol map file to upload. */ +export type DartSymbolMap = { + /** Filesystem path (for display/logging). */ + path: string; + /** The debug ID to associate with this map. */ + debugId: string; + /** Pre-read content buffer. */ + content: Buffer; +}; + +/** Options for {@link uploadDartSymbolMap}. */ +export type DartSymbolMapUploadOptions = { + /** Organization slug. */ + org: string; + /** Project slug. */ + project: string; + /** The mapping file to upload. */ + mapping: DartSymbolMap; +}; + +// ── Schemas ───────────────────────────────────────────────────────── + +/** + * DIF assemble response — keyed by overall checksum, each value has + * the same shape as the standard assemble response. + */ +const DifAssembleResponseSchema = z.record(z.string(), AssembleResponseSchema); + +type DifAssembleResponse = z.infer; + +// ── Helpers ───────────────────────────────────────────────────────── + +/** Result of checking a DIF assemble response. */ +type AssembleCheckResult = { + /** True when the entry is `"ok"` or `"created"`. */ + allDone: boolean; + /** SHA-1 checksums the server still needs uploaded. */ + missingChecksums: Set; +}; + +/** + * Check a DIF assemble response for completion, errors, and missing chunks. + * + * @throws {ApiError} If the entry reports an `"error"` state. + */ +function checkAssembleResponse( + response: DifAssembleResponse, + checksum: string, + endpoint: string +): AssembleCheckResult { + const missingChecksums = new Set(); + const entry: AssembleResponse | undefined = response[checksum]; + + if (!entry) { + log.debug(`No assemble response for checksum ${checksum}`); + return { allDone: false, missingChecksums }; + } + + if (entry.state === "error") { + throw new ApiError( + "Dart symbol map assembly failed", + 500, + entry.detail ?? "Unknown error", + endpoint + ); + } + + if (entry.state === "ok" || entry.state === "created") { + return { allDone: true, missingChecksums }; + } + + for (const sha1 of entry.missingChunks ?? []) { + missingChecksums.add(sha1); + } + + return { allDone: false, missingChecksums }; +} + +// ── API Function ──────────────────────────────────────────────────── + +/** + * Upload a dart symbol map to Sentry via the DIF chunk-upload protocol. + * + * The mapping's raw bytes are chunked directly (no ZIP wrapping) and + * assembled through the DIF endpoint with the provided debug ID. + * + * @param options - Upload configuration + * @throws {ApiError} If the upload or assembly fails + */ +export async function uploadDartSymbolMap( + options: DartSymbolMapUploadOptions +): Promise { + const { org, project, mapping } = options; + + // Step 1: Get chunk upload configuration + const serverOptions = await getChunkUploadOptions(org); + const encoding = pickUploadEncoding(serverOptions.compression); + + // Step 2: Hash the mapping file into chunks + const { chunks, overallChecksum } = hashBuffer( + mapping.content, + serverOptions.chunkSize + ); + + const regionUrl = await resolveOrgRegion(org); + const assembleEndpoint = `projects/${org}/${project}/files/difs/assemble/`; + + // Step 3: Build assemble body with debug_id + const assembleBody: Record< + string, + { name: string; debug_id: string; chunks: string[] } + > = { + [overallChecksum]: { + name: mapping.path, + debug_id: mapping.debugId, + chunks: chunks.map((c: ChunkInfo) => c.sha1), + }, + }; + + // Step 4: Request DIF assembly + const { data: firstAssemble } = await apiRequestToRegion( + regionUrl, + assembleEndpoint, + { + method: "POST", + body: assembleBody, + schema: DifAssembleResponseSchema, + } + ); + + const { allDone, missingChecksums } = checkAssembleResponse( + firstAssemble, + overallChecksum, + assembleEndpoint + ); + + if (allDone) { + return; + } + + // Step 5: Upload missing chunks + await uploadMissingBufferChunks({ + chunks, + missingChecksums, + content: mapping.content, + serverOptions, + encoding, + regionUrl, + }); + + // Step 6: Poll assemble endpoint until done + const deadline = Date.now() + ASSEMBLE_MAX_WAIT_MS; + while (Date.now() < deadline) { + await new Promise((r) => setTimeout(r, ASSEMBLE_POLL_INTERVAL_MS)); + + const { data: pollResult } = await apiRequestToRegion( + regionUrl, + assembleEndpoint, + { + method: "POST", + body: assembleBody, + schema: DifAssembleResponseSchema, + } + ); + + const { allDone: done } = checkAssembleResponse( + pollResult, + overallChecksum, + assembleEndpoint + ); + + if (done) { + return; + } + } + + throw new ApiError( + "Dart symbol map assembly timed out", + 408, + `Assembly did not complete within ${ASSEMBLE_MAX_WAIT_MS / 1000}s`, + assembleEndpoint + ); +} diff --git a/test/commands/dart-symbol-map/upload.test.ts b/test/commands/dart-symbol-map/upload.test.ts new file mode 100644 index 000000000..c352419c3 --- /dev/null +++ b/test/commands/dart-symbol-map/upload.test.ts @@ -0,0 +1,220 @@ +/** + * Tests for `sentry dart-symbol-map upload` command. + * + * Tests validation of dart symbol map files, debug ID format, + * --no-upload dry-run mode, and error handling. + */ + +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { run } from "@stricli/core"; +import { afterEach, beforeEach, describe, expect, test } from "vitest"; +import { app } from "../../../src/app.js"; +import type { SentryContext } from "../../../src/context.js"; +import { useTestConfigDir } from "../../helpers.js"; + +useTestConfigDir("dart-symbol-map-"); + +let tempDir: string; + +beforeEach(async () => { + tempDir = await mkdtemp(join(tmpdir(), "dart-symbol-map-test-")); +}); + +afterEach(async () => { + await rm(tempDir, { recursive: true, force: true }); +}); + +/** + * Run the dart-symbol-map upload command and capture stdout/stderr. + */ +async function runUpload( + args: string[] +): Promise<{ output: string; exitCode: number | undefined; error?: string }> { + let output = ""; + let error = ""; + const mockContext: SentryContext = { + process: { + ...process, + exitCode: undefined, + } as typeof process, + env: process.env, + cwd: process.cwd(), + homeDir: "/tmp", + configDir: "/tmp", + stdout: { + write(data: string | Uint8Array) { + output += + typeof data === "string" ? data : new TextDecoder().decode(data); + return true; + }, + }, + stderr: { + write(data: string | Uint8Array) { + error += + typeof data === "string" ? data : new TextDecoder().decode(data); + return true; + }, + }, + stdin: process.stdin, + }; + + await run(app, ["dart-symbol-map", "upload", ...args], mockContext); + return { output, exitCode: mockContext.process.exitCode, error }; +} + +const VALID_DEBUG_ID = "12345678-1234-1234-1234-123456789abc"; + +describe("sentry dart-symbol-map upload", () => { + test("--no-upload validates and prints result without uploading", async () => { + const mapPath = join(tempDir, "map.json"); + await writeFile( + mapPath, + JSON.stringify(["obfuscated1", "original1", "obfuscated2", "original2"]) + ); + + const { output } = await runUpload([ + "--debug-id", + VALID_DEBUG_ID, + "--no-upload", + mapPath, + ]); + expect(output).toContain(VALID_DEBUG_ID); + expect(output).toContain(mapPath); + }); + + test("--no-upload --json outputs structured data", async () => { + const mapPath = join(tempDir, "map.json"); + await writeFile(mapPath, JSON.stringify(["obfuscated", "original"])); + + const { output } = await runUpload([ + "--debug-id", + VALID_DEBUG_ID, + "--no-upload", + "--json", + mapPath, + ]); + const parsed = JSON.parse(output); + expect(parsed).toHaveProperty("debugId", VALID_DEBUG_ID); + expect(parsed).toHaveProperty("path", mapPath); + expect(parsed).toHaveProperty("uploaded", false); + }); + + test("rejects invalid debug ID format", async () => { + const mapPath = join(tempDir, "map.json"); + await writeFile(mapPath, JSON.stringify(["a", "b"])); + + const { exitCode } = await runUpload([ + "--debug-id", + "not-a-uuid", + "--no-upload", + mapPath, + ]); + expect(exitCode).not.toBe(0); + }); + + test("rejects non-JSON file", async () => { + const mapPath = join(tempDir, "map.json"); + await writeFile(mapPath, "this is not json"); + + const { exitCode } = await runUpload([ + "--debug-id", + VALID_DEBUG_ID, + "--no-upload", + mapPath, + ]); + expect(exitCode).not.toBe(0); + }); + + test("rejects non-array JSON", async () => { + const mapPath = join(tempDir, "map.json"); + await writeFile(mapPath, JSON.stringify({ key: "value" })); + + const { exitCode } = await runUpload([ + "--debug-id", + VALID_DEBUG_ID, + "--no-upload", + mapPath, + ]); + expect(exitCode).not.toBe(0); + }); + + test("rejects array with non-string entries", async () => { + const mapPath = join(tempDir, "map.json"); + await writeFile(mapPath, JSON.stringify(["valid", 42])); + + const { exitCode } = await runUpload([ + "--debug-id", + VALID_DEBUG_ID, + "--no-upload", + mapPath, + ]); + expect(exitCode).not.toBe(0); + }); + + test("rejects array with odd number of entries", async () => { + const mapPath = join(tempDir, "map.json"); + await writeFile( + mapPath, + JSON.stringify(["obfuscated1", "original1", "orphan"]) + ); + + const { exitCode } = await runUpload([ + "--debug-id", + VALID_DEBUG_ID, + "--no-upload", + mapPath, + ]); + expect(exitCode).not.toBe(0); + }); + + test("rejects empty file", async () => { + const mapPath = join(tempDir, "map.json"); + await writeFile(mapPath, ""); + + const { exitCode } = await runUpload([ + "--debug-id", + VALID_DEBUG_ID, + "--no-upload", + mapPath, + ]); + expect(exitCode).not.toBe(0); + }); + + test("rejects nonexistent file", async () => { + const { exitCode } = await runUpload([ + "--debug-id", + VALID_DEBUG_ID, + "--no-upload", + join(tempDir, "nonexistent.json"), + ]); + expect(exitCode).not.toBe(0); + }); + + test("accepts valid map with empty array", async () => { + const mapPath = join(tempDir, "map.json"); + await writeFile(mapPath, JSON.stringify([])); + + const { output } = await runUpload([ + "--debug-id", + VALID_DEBUG_ID, + "--no-upload", + mapPath, + ]); + expect(output).toContain(VALID_DEBUG_ID); + }); + + test("accepts -d as alias for --debug-id", async () => { + const mapPath = join(tempDir, "map.json"); + await writeFile(mapPath, JSON.stringify(["a", "b"])); + + const { output } = await runUpload([ + "-d", + VALID_DEBUG_ID, + "--no-upload", + mapPath, + ]); + expect(output).toContain(VALID_DEBUG_ID); + }); +}); From f706c7838cc192b4ddccc0ea6d2952c212ebaaa2 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Tue, 9 Jun 2026 16:26:26 +0000 Subject: [PATCH 2/2] chore: regenerate docs --- .../sentry-cli/skills/sentry-cli/references/cli.md | 13 +++++++++++++ .../skills/sentry-cli/references/dart-symbol-map.md | 13 +++++++++++++ .../skills/sentry-cli/references/issue.md | 12 ++++++------ 3 files changed, 32 insertions(+), 6 deletions(-) diff --git a/plugins/sentry-cli/skills/sentry-cli/references/cli.md b/plugins/sentry-cli/skills/sentry-cli/references/cli.md index c380f07db..9126a15e1 100644 --- a/plugins/sentry-cli/skills/sentry-cli/references/cli.md +++ b/plugins/sentry-cli/skills/sentry-cli/references/cli.md @@ -143,6 +143,19 @@ Uninstall Sentry CLI - `-f, --force - Force the operation without confirmation` - `-n, --dry-run - Show what would happen without making changes` +**Examples:** + +```bash +# Show what would be removed (dry run) +sentry cli uninstall --dry-run + +# Uninstall, keeping config directory +sentry cli uninstall --yes --keep-config + +# Full uninstall with confirmation +sentry cli uninstall +``` + ### `sentry cli upgrade ` Update the Sentry CLI to the latest version diff --git a/plugins/sentry-cli/skills/sentry-cli/references/dart-symbol-map.md b/plugins/sentry-cli/skills/sentry-cli/references/dart-symbol-map.md index a94a0a273..fe57d6405 100644 --- a/plugins/sentry-cli/skills/sentry-cli/references/dart-symbol-map.md +++ b/plugins/sentry-cli/skills/sentry-cli/references/dart-symbol-map.md @@ -19,4 +19,17 @@ Upload a Dart/Flutter symbol map to Sentry - `-d, --debug-id - Debug ID (UUID) from the companion native debug file` - `--no-upload - Validate the file without uploading (dry-run)` +**Examples:** + +```bash +# Upload a dart symbol map with a debug ID +sentry dart-symbol-map upload --debug-id 12345678-1234-1234-1234-123456789abc mapping.json + +# Validate without uploading +sentry dart-symbol-map upload --debug-id 12345678-1234-1234-1234-123456789abc mapping.json --no-upload + +# Output as JSON +sentry dart-symbol-map upload --debug-id 12345678-1234-1234-1234-123456789abc mapping.json --json +``` + All commands also support `--json`, `--fields`, `--help`, `--log-level`, and `--verbose` flags. diff --git a/plugins/sentry-cli/skills/sentry-cli/references/issue.md b/plugins/sentry-cli/skills/sentry-cli/references/issue.md index 9d5a75a5f..f6af30f10 100644 --- a/plugins/sentry-cli/skills/sentry-cli/references/issue.md +++ b/plugins/sentry-cli/skills/sentry-cli/references/issue.md @@ -31,11 +31,11 @@ List issues in a project | `id` | string | Numeric issue ID | | `shortId` | string | Human-readable short ID (e.g. PROJ-ABC) | | `title` | string | Issue title | -| `culprit` | string | Culprit string | +| `culprit` | string \| null | Culprit string | | `count` | string | Total event count | | `userCount` | number | Number of affected users | -| `firstSeen` | string | First occurrence (ISO 8601) | -| `lastSeen` | string | Most recent occurrence (ISO 8601) | +| `firstSeen` | string \| null | First occurrence (ISO 8601) | +| `lastSeen` | string \| null | Most recent occurrence (ISO 8601) | | `level` | string | Severity level | | `status` | string | Issue status | | `permalink` | string | URL to the issue in Sentry | @@ -190,11 +190,11 @@ View details of a specific issue | `id` | string | Numeric issue ID | | `shortId` | string | Human-readable short ID (e.g. PROJ-ABC) | | `title` | string | Issue title | -| `culprit` | string | Culprit string | +| `culprit` | string \| null | Culprit string | | `count` | string | Total event count | | `userCount` | number | Number of affected users | -| `firstSeen` | string | First occurrence (ISO 8601) | -| `lastSeen` | string | Most recent occurrence (ISO 8601) | +| `firstSeen` | string \| null | First occurrence (ISO 8601) | +| `lastSeen` | string \| null | Most recent occurrence (ISO 8601) | | `level` | string | Severity level | | `status` | string | Issue status | | `permalink` | string | URL to the issue in Sentry |