diff --git a/AGENTS.md b/AGENTS.md index 1f0e59510..7d7ac5a6d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -629,7 +629,7 @@ mock.module("./some-module", () => ({ ### Architecture -* **CLI telemetry DSN is public write-only — safe to embed in install script**: The CLI's own Sentry DSN (\`SENTRY\_CLI\_DSN\` in \`src/lib/constants.ts\`) is a public write-only ingest key (\`1188a86f...@o1.ingest.us.sentry.io/4510776311808000\`). It's already baked into every distributed binary. Safe to hardcode in the bash install script for error reporting via the envelope API — no secrets needed. Opt-out via \`SENTRY\_CLI\_NO\_TELEMETRY=1\` (same env var the binary checks). Envelope endpoint: \`https://o1.ingest.us.sentry.io/api/{PROJECT\_ID}/envelope/\` with \`x-sentry-auth\` header containing the public key. +* **CLI telemetry DSN is public write-only — safe to embed in install script**: The CLI's Sentry DSN (\`SENTRY\_CLI\_DSN\` in \`src/lib/constants.ts\`) is a public write-only ingest key already baked into every binary. Safe to hardcode in install scripts. Opt-out: \`SENTRY\_CLI\_NO\_TELEMETRY=1\`. * **cli.sentry.dev is served from gh-pages branch via GitHub Pages**: \`cli.sentry.dev\` is served from gh-pages branch via GitHub Pages. Craft's gh-pages target runs \`git rm -r -f .\` before extracting docs — persist extra files via \`postReleaseCommand\` in \`.craft.yml\`. Install script supports \`--channel nightly\`, downloading from the \`nightly\` release tag directly. version.json is only used by upgrade/version-check flow. @@ -640,22 +640,13 @@ mock.module("./some-module", () => ({ * **Numeric issue ID resolution returns org:undefined despite API success**: Numeric issue ID resolution in \`resolveNumericIssue()\`: (1) try DSN/env/config for org, (2) if found use \`getIssueInOrg(org, id)\` with region routing, (3) else fall back to unscoped \`getIssue(id)\`, (4) extract org from \`issue.permalink\` via \`parseSentryUrl\` as final fallback. The \`explicit-org-numeric\` case uses \`getIssueInOrg\`. \`resolveOrgAndIssueId\` no longer throws for bare numeric IDs when permalink contains the org slug. - -* **Self-hosted OAuth device flow requires Sentry 26.1.0+ and SENTRY\_CLIENT\_ID**: The OAuth device flow for self-hosted Sentry requires version 26.1.0+ (PR #105675 added device flow, #106169 added public client support). Users must create a public OAuth application in Settings → Developer Settings → New Public Integration, then set both \`SENTRY\_URL\` and \`SENTRY\_CLIENT\_ID\`. The client ID is NOT optional for self-hosted — without it, \`sentry auth login\` cannot use the device flow. For older instances, the fallback is \`sentry auth login --token\`. The \`getSentryUrl()\` and \`getClientId()\` functions in \`src/lib/oauth.ts\` read these lazily (not at module load) so URL parsing from arguments can set \`SENTRY\_URL\` after import. - - -* **Sentry CLI markdown-first formatting pipeline replaces ad-hoc ANSI**: Formatters build CommonMark strings; \`renderMarkdown()\` renders to ANSI for TTY or raw markdown for non-TTY. Key helpers: \`colorTag()\`, \`mdKvTable()\`, \`mdRow()\`, \`mdTableHeader()\` (\`:\` suffix = right-aligned), \`renderTextTable()\`. \`isPlainOutput()\` checks \`SENTRY\_PLAIN\_OUTPUT\` > \`NO\_COLOR\` > \`!isTTY\`. Batch path: \`formatXxxTable()\`. Streaming path: \`StreamingTable\` (TTY) or raw markdown rows (plain). Both share \`buildXxxRowCells()\`. - * **parseSentryUrl does not handle subdomain-style SaaS URLs**: parseSentryUrl in src/lib/sentry-url-parser.ts handles both path-based (\`/organizations/{org}/...\`) and subdomain-style (\`https://{org}.sentry.io/issues/123/\`) URLs. \`matchSubdomainOrg()\` extracts org from hostname ending in \`.sentry.io\`. Region subdomains (\`us\`, \`de\`) filtered by requiring org slug length > 2. Supports \`/issues/{id}/\`, \`/issues/{id}/events/{eventId}/\`, and \`/traces/{traceId}/\` paths. Self-hosted uses path-based only. - -* **Consola chosen as CLI logger with Sentry createConsolaReporter integration**: The CLI uses \`consola\` (v3.x, ~33KB) for structured logging. Two reporters: (1) built-in FancyReporter writes to stderr with color/icons, (2) \`Sentry.createConsolaReporter()\` from \`@sentry/bun\` auto-forwards all logs to Sentry structured logs (requires \`enableLogs: true\` in Sentry.init, already set). Consola respects \`NO_COLOR\` env var natively. Level controlled by \`SENTRY_LOG_LEVEL\` env var mapped to consola numeric levels: error=0, warn=1, info=3 (default), debug=4, trace=5. Use \`consola.withTag('upgrade')\` for domain-scoped logging. User-facing messages use \`logger.info()\`/\`logger.success()\`; diagnostics use \`logger.debug()\`/\`logger.trace()\`. Global \`--verbose\`/\`--log-level\` flags pre-parsed from argv before Stricli, then set \`logger.level\` directly. - ### Decision -* **Raw markdown output for non-interactive terminals, rendered for TTY**: Output raw CommonMark when stdout is not a TTY; render through marked-terminal only for TTY. Detection: \`process.stdout.isTTY\`. Override precedence: \`SENTRY\_PLAIN\_OUTPUT\` > \`NO\_COLOR\` > auto-detect. \`--json\` always outputs JSON. Streaming formatters (log/trace) use ANSI-colored text for TTY, markdown table rows for non-TTY. +* **Raw markdown output for non-interactive terminals, rendered for TTY**: Markdown-first output pipeline: custom renderer in \`src/lib/formatters/markdown.ts\` walks \`marked\` tokens to produce ANSI-styled output. Commands build CommonMark using helpers (\`mdKvTable()\`, \`mdRow()\`, \`colorTag()\`, \`escapeMarkdownCell()\`, \`safeCodeSpan()\`) and pass through \`renderMarkdown()\`. \`isPlainOutput()\` precedence: \`SENTRY\_PLAIN\_OUTPUT\` > \`NO\_COLOR\` > \`FORCE\_COLOR\` > \`!isTTY\`. \`--json\` always outputs JSON. Terminal styling: code spans render as cyan (\`codeFg: #22d3ee\`) on dark teal background (\`codeBg: #1a2f3a\`) with space padding for pill look. h1/h2 headings are bold cyan with a \`━\` divider bar underneath (clamped to 30 chars via \`stringWidth\`). h3+ are bold cyan without divider. Colors defined in \`COLORS\` object in \`colors.ts\`. Tests run non-TTY so assertions match raw CommonMark; use \`stripAnsi()\` helper for rendered-mode content assertions. * **whoami should be separate from auth status command**: The \`sentry auth whoami\` command should be a dedicated command separate from \`sentry auth status\`. They serve different purposes: \`status\` shows everything about auth state (token, expiry, defaults, org verification), while \`whoami\` just shows user identity (name, email, username, ID) by fetching live from \`/auth/\` endpoint. \`sentry whoami\` should be a top-level alias (like \`sentry issues\` → \`sentry issue list\`). \`whoami\` should support \`--json\` for machine consumption and be lightweight — no credential verification, no defaults listing. @@ -669,13 +660,13 @@ mock.module("./some-module", () => ({ * **Bun binary build requires SENTRY\_CLIENT\_ID env var**: The build script (\`script/bundle.ts\`) requires \`SENTRY\_CLIENT\_ID\` environment variable and exits with code 1 if missing. When building locally, use \`bun run --env-file=.env.local build\` or set the env var explicitly. The binary build (\`bun run build\`) also needs it. Without it you get: \`Error: SENTRY\_CLIENT\_ID environment variable is required.\` -* **GitHub immutable releases prevent rolling nightly tag pattern**: getsentry/cli has immutable GitHub releases — assets can't be modified and tags can NEVER be reused. Nightly uses per-version tags (e.g., \`0.13.0-dev.1772062077\`) with API-based latest discovery; deletes all existing assets before uploading. Craft minVersion >= 2.21.0 with no \`preReleaseCommand\` silently skips \`bump-version.sh\` if the only target is \`github\`. Fix: explicitly set \`preReleaseCommand: bash scripts/bump-version.sh\`. +* **GitHub immutable releases prevent rolling nightly tag pattern**: getsentry/cli has immutable GitHub releases — assets can't be modified and tags can NEVER be reused. Nightly uses per-version tags (e.g., \`0.13.0-dev.1772062077\`) with API-based latest discovery. Craft with no \`preReleaseCommand\` silently skips \`bump-version.sh\` if the only target is \`github\` — must explicitly set it. -* **Install script: BSD sed and awk JSON parsing breaks OCI digest extraction**: The install script parses OCI manifests with awk (no jq dependency). Key trap: \`sed 's/},{/}\n{/g'\` doesn't insert newlines on macOS BSD sed (\`\n\` is literal). Also, the first layer shares a line with the config block after \`\[{\` split. Fix: use a single awk pass tracking last-seen \`"digest"\` value, printing it when \`"org.opencontainers.image.title"\` matches target. Works because \`digest\` always precedes \`annotations\` within each OCI layer object. This avoids sed entirely and handles both GNU/BSD awk. The config digest (\`sha256:44136fa...\`) is a 2-byte \`{}\` blob — downloading it instead of the real binary causes \`gunzip: unexpected end of file\`. The install script now has fire-and-forget Sentry telemetry via \`die()\` + ERR trap, which would catch such failures automatically. +* **Install script: BSD sed and awk JSON parsing breaks OCI digest extraction**: The install script parses OCI manifests with awk (no jq). Key trap: BSD sed \`\n\` is literal, not newline. Fix: single awk pass tracking last-seen \`"digest"\`, printing when \`"org.opencontainers.image.title"\` matches target. The config digest (\`sha256:44136fa...\`) is a 2-byte \`{}\` blob — downloading it instead of the real binary causes \`gunzip: unexpected end of file\`. -* **macOS SIGKILL on MAP\_SHARED mmap of signed Mach-O binaries**: macOS AMFI (code signing enforcement) sends SIGKILL when \`MAP\_SHARED\` with \`PROT\_WRITE\` is used on a code-signed Mach-O binary. \`Bun.mmap()\` defaults to \`{ shared: true }\` (MAP\_SHARED). In \`src/lib/bspatch.ts\`, \`Bun.mmap(process.execPath)\` kills the process on macOS during delta upgrades because the running CLI binary is ad-hoc signed (all Bun binaries are). Fix: pass \`{ shared: false }\` for MAP\_PRIVATE. Since the mapping is read-only in practice, no COW pages are allocated — identical performance. Linux ELF binaries have no such restriction. +* **macOS SIGKILL on MAP\_SHARED mmap of signed Mach-O binaries**: macOS AMFI (code signing enforcement) sends uncatchable SIGKILL when \`Bun.mmap()\` is used on code-signed Mach-O binaries. \`Bun.mmap()\` always requests PROT\_WRITE regardless of the \`shared\` flag, and macOS rejects ANY writable mapping (MAP\_SHARED or MAP\_PRIVATE) on signed Mach-O. PR #339's MAP\_PRIVATE fix was insufficient. Fixed by replacing \`Bun.mmap(oldPath)\` with \`new Uint8Array(await Bun.file(oldPath).arrayBuffer())\` in bspatch.ts. Costs ~100 MB heap for the old binary but avoids the uncatchable SIGKILL entirely. Sentry telemetry confirmed: zero successful macOS upgrade traces from delta-enabled versions, while Linux worked fine. * **Multiple mockFetch calls replace each other — use unified mocks for multi-endpoint tests**: Bun test mocking gotchas: (1) \`mockFetch()\` replaces \`globalThis.fetch\` — calling it twice replaces the first mock. Use a single unified fetch mock dispatching by URL pattern. (2) \`mock.module()\` pollutes the module registry for ALL subsequent test files. Tests using it must live in \`test/isolated/\` and run via \`test:isolated\`. (3) For \`Bun.spawn\`, use direct property assignment in \`beforeEach\`/\`afterEach\`. @@ -688,9 +679,6 @@ mock.module("./some-module", () => ({ ### Pattern - -* **Markdown table structure for marked-terminal: blank header row + separator + data rows**: Markdown tables for marked-terminal: blank header row (\`| | |\`), separator (\`|---|---|\`), then data rows (\`| \*\*Label\*\* | value |\`). Data rows before separator produce malformed output. Escape user content via \`escapeMarkdownCell()\` in \`src/lib/formatters/markdown.ts\` — backslashes first, then pipes. CodeQL flags incomplete escaping as high severity. - * **Org-scoped SDK calls follow getOrgSdkConfig + unwrapResult pattern**: All org-scoped API calls in src/lib/api-client.ts: (1) call \`getOrgSdkConfig(orgSlug)\` for regional URL + SDK config, (2) spread into SDK function: \`{ ...config, path: { organization\_id\_or\_slug: orgSlug, ... } }\`, (3) pass to \`unwrapResult(result, errorContext)\`. Shared helpers \`resolveAllTargets\`/\`resolveOrgAndProject\` must NOT call \`fetchProjectId\` — commands that need it enrich targets themselves. @@ -703,5 +691,5 @@ mock.module("./some-module", () => ({ ### Preference -* **CI scripts: prefer jq/sed over node -e for JSON manipulation**: Reviewer (BYK) prefers using standard Unix tools (\`jq\`, \`sed\`, \`awk\`) over \`node -e\` for simple JSON manipulation in CI workflow scripts. For example, reading/modifying package.json version: \`jq -r .version package.json\` to read, \`jq --arg v "$NEW" '.version = $v' package.json > tmp && mv tmp package.json\` to write. This avoids requiring Node.js to be installed in CI steps that only need basic JSON operations, and is more readable for shell-centric workflows. +* **CI scripts: prefer jq/sed over node -e for JSON manipulation**: Prefer \`jq\`/\`sed\`/\`awk\` over \`node -e\` for JSON manipulation in CI scripts. Example: \`jq -r .version package.json\` to read, \`jq --arg v "$NEW" '.version = $v' package.json > tmp && mv tmp package.json\` to write. diff --git a/docs/src/components/FeatureTerminal.astro b/docs/src/components/FeatureTerminal.astro index 72db71bf7..3f13576d4 100644 --- a/docs/src/components/FeatureTerminal.astro +++ b/docs/src/components/FeatureTerminal.astro @@ -106,33 +106,39 @@ const { title = "Terminal" } = Astro.props; height: 0.75rem; } - /* Table styles */ - .feature-terminal-body :global(.table-header) { - color: rgba(255, 255, 255, 0.4); + /* Table box styles */ + .feature-terminal-body :global(.table-box) { + font-family: 'JetBrains Mono', ui-monospace, monospace; font-size: 0.7rem; - text-transform: uppercase; - letter-spacing: 0.03em; - padding-bottom: 0.25rem; - border-bottom: 1px solid rgba(255, 255, 255, 0.1); + line-height: 1.5; + margin: 0; + padding: 0; + background: transparent; + border: none; + color: rgba(255, 255, 255, 0.85); + overflow-x: auto; } - .feature-terminal-body :global(.table-row) { - font-size: 0.78rem; + .feature-terminal-body :global(.table-box .border) { + color: rgba(255, 255, 255, 0.15); + } + + .feature-terminal-body :global(.table-box .header-text) { + color: rgba(255, 255, 255, 0.4); + font-size: 0.7rem; } - .feature-terminal-body :global(.col-id) { - min-width: 70px; + .feature-terminal-body :global(.red-text) { + color: #ef4444; } - .feature-terminal-body :global(.col-title) { - flex: 1; - color: rgba(255, 255, 255, 0.7); + .feature-terminal-body :global(.yellow-text) { + color: #eab308; } - .feature-terminal-body :global(.col-count) { - min-width: 45px; - text-align: right; - color: rgba(255, 255, 255, 0.5); + .feature-terminal-body :global(.alias-hl) { + font-weight: 700; + text-decoration: underline; } @media (max-width: 640px) { diff --git a/docs/src/components/Terminal.astro b/docs/src/components/Terminal.astro index 9fc2b9687..f1f7d4aac 100644 --- a/docs/src/components/Terminal.astro +++ b/docs/src/components/Terminal.astro @@ -50,34 +50,13 @@ if (background) {
╭─────────┬───────┬───────────────┬────────┬──────┬────────────┬──────────────────────────────────────────╮ +│ LEVEL │ ALIAS │ SHORT ID │ COUNT │ SEEN │ FIXABILITY │ TITLE │ +├─────────┼───────┼───────────────┼────────┼──────┼────────────┼──────────────────────────────────────────┤ +│ ERROR │ f-79 │ FRONTEND-79 │ 2.4k │ 2h │ 92% │ TypeError: Cannot read property 'map'... │ +│ WARN │ a-2f │ API-2F │ 891 │ 1d │ 67% │ ReferenceError: user is not defined │ +│ ERROR │ m-4d │ MOBILE-4D │ 456 │ 5d │ 23% │ NetworkError: Failed to fetch │ +╰─────────┴───────┴───────────────┴────────┴──────┴────────────┴──────────────────────────────────────────╯@@ -231,10 +210,6 @@ if (background) { color: #22d3ee; } - .accent { - color: #a78bfa; - } - .dimmed { color: rgba(255, 255, 255, 0.4); } @@ -269,45 +244,34 @@ if (background) { border-radius: 3px; } - .table-header { - color: rgba(255, 255, 255, 0.4); - font-size: 0.65rem; - text-transform: uppercase; - letter-spacing: 0.05em; - padding-bottom: 0.25rem; - border-bottom: 1px solid rgba(255, 255, 255, 0.1); - } - - .table-row { - font-size: 0.72rem; - } - - .col-shortid { - min-width: 95px; + .table-box { + font-family: 'JetBrains Mono', monospace; + font-size: 0.7rem; + line-height: 1.5; + margin: 0; + padding: 0; + background: transparent; + border: none; + color: rgba(255, 255, 255, 0.85); + overflow-x: auto; } - .col-alias { - min-width: 45px; + .table-box .border { + color: rgba(255, 255, 255, 0.15); } - .col-title { - flex: 1; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - color: rgba(255, 255, 255, 0.7); + .table-box .header-text { + color: rgba(255, 255, 255, 0.4); + font-size: 0.7rem; } - .col-count { - min-width: 40px; - text-align: right; - color: rgba(255, 255, 255, 0.5); + .accent { + color: #a78bfa; } - .col-fix { - min-width: 35px; - text-align: right; - font-weight: 500; + .alias-hl { + font-weight: 700; + text-decoration: underline; } @media (max-width: 640px) { @@ -321,13 +285,5 @@ if (background) { padding: 0 1rem 1rem; font-size: 0.7rem; } - - .col-shortid { - display: none; - } - - .col-alias { - min-width: 40px; - } } diff --git a/docs/src/content/docs/index.mdx b/docs/src/content/docs/index.mdx index a1b47e7ec..3a99e84e6 100644 --- a/docs/src/content/docs/index.mdx +++ b/docs/src/content/docs/index.mdx @@ -51,26 +51,13 @@ import sectionBg3 from '../../assets/section-bg-3.png';
╭─────────┬────────────┬──────────────────────────────────────────┬───────╮ +│ LEVEL │ SHORT ID │ TITLE │ COUNT │ +├─────────┼────────────┼──────────────────────────────────────────┼───────┤ +│ ERROR │ MYAPP-WQ │ TypeError: Cannot read property 'map'... │ 142 │ +│ WARN │ MYAPP-X3 │ Failed to fetch user data from API │ 89 │ +│ ERROR │ MYAPP-R7 │ Connection timeout after 30s │ 34 │ +╰─────────┴────────────┴──────────────────────────────────────────┴───────╯diff --git a/src/lib/formatters/colors.ts b/src/lib/formatters/colors.ts index 7e673417e..301077842 100644 --- a/src/lib/formatters/colors.ts +++ b/src/lib/formatters/colors.ts @@ -18,6 +18,10 @@ export const COLORS = { white: "#f9f8f9", cyan: "#79B8FF", muted: "#898294", + /** Background tint for inline code spans (dark teal, pairs with cyan text) */ + codeBg: "#1a2f3a", + /** Foreground color for inline code spans */ + codeFg: "#22d3ee", } as const; // Base Color Functions diff --git a/src/lib/formatters/markdown.ts b/src/lib/formatters/markdown.ts index ccc8807bf..26d1c4d23 100644 --- a/src/lib/formatters/markdown.ts +++ b/src/lib/formatters/markdown.ts @@ -26,6 +26,7 @@ import chalk from "chalk"; import { highlight as cliHighlight } from "cli-highlight"; import { marked, type Token, type Tokens } from "marked"; +import stringWidth from "string-width"; import { COLORS, muted, terminalLink } from "./colors.js"; import { type Alignment, renderTextTable } from "./text-table.js"; @@ -328,7 +329,9 @@ function renderOneInline(token: Token): string { case "em": return chalk.italic(renderInline((token as Tokens.Em).tokens)); case "codespan": - return chalk.hex(COLORS.yellow)((token as Tokens.Codespan).text); + return chalk.bgHex(COLORS.codeBg).hex(COLORS.codeFg)( + ` ${(token as Tokens.Codespan).text} ` + ); case "link": { const link = token as Tokens.Link; let linkText = renderInline(link.tokens); @@ -440,12 +443,18 @@ function renderBlocks(tokens: Token[]): string { case "heading": { const t = token as Tokens.Heading; const text = renderInline(t.tokens); - // h1/h2 → bold cyan; h3+ → plain cyan (less prominent) - const styled = - t.depth <= 2 - ? chalk.hex(COLORS.cyan).bold(text) - : chalk.hex(COLORS.cyan)(text); - parts.push(styled); + if (t.depth <= 2) { + // h1/h2 → bold cyan with colored divider bar for visual weight + parts.push(chalk.hex(COLORS.cyan).bold(text)); + parts.push( + chalk.hex(COLORS.cyan)( + "\u2501".repeat(Math.min(stringWidth(text), 30)) + ) + ); + } else { + // h3+ → bold cyan (less prominent, no divider) + parts.push(chalk.hex(COLORS.cyan).bold(text)); + } parts.push(""); break; } diff --git a/test/lib/formatters/markdown.test.ts b/test/lib/formatters/markdown.test.ts index 804980fc6..b43bbdfa0 100644 --- a/test/lib/formatters/markdown.test.ts +++ b/test/lib/formatters/markdown.test.ts @@ -318,12 +318,14 @@ describe("renderInlineMarkdown", () => { }); }); - test("rendered mode: renders code spans", () => { + test("rendered mode: renders code spans with padding", () => { withEnv({ SENTRY_PLAIN_OUTPUT: "0", NO_COLOR: undefined }, false, () => { const result = stripAnsi(renderInlineMarkdown("`trace-id`")); expect(result).toContain("trace-id"); // Should not contain the backtick delimiters expect(result).not.toContain("`"); + // Code spans have space padding for the "pill" look + expect(result).toBe(" trace-id "); }); }); @@ -627,9 +629,33 @@ describe("renderMarkdown blocks (rendered mode)", () => { return result; } - test("renders headings", () => { + test("renders h1/h2 headings with divider bar", () => { const result = rendered("## My Heading"); - expect(stripAnsi(result)).toContain("My Heading"); + const plain = stripAnsi(result); + expect(plain).toContain("My Heading"); + // h1/h2 have a colored divider bar (━) underneath + expect(plain).toContain("━"); + // Divider length = min(stringWidth("My Heading"), 30) = 10 + expect(plain).toContain("━".repeat(10)); + }); + + test("renders h3+ headings as bold cyan without divider", () => { + const result = rendered("### Sub Heading"); + const plain = stripAnsi(result); + expect(plain).toContain("Sub Heading"); + // h3+ should NOT have a divider bar + expect(plain).not.toContain("━"); + }); + + test("h1/h2 divider bar is clamped to 30 chars", () => { + const longHeading = + "## This Is A Very Long Heading That Exceeds Thirty Characters"; + const result = rendered(longHeading); + const plain = stripAnsi(result); + // Divider should be exactly 30 chars (clamped) + const dividerLine = plain.split("\n").find((line) => line.includes("━")); + expect(dividerLine).toBeDefined(); + expect(dividerLine!.length).toBe(30); }); test("renders paragraphs", () => {