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
109 changes: 53 additions & 56 deletions AGENTS.md

Large diffs are not rendered by default.

81 changes: 81 additions & 0 deletions src/lib/cache-hint.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
/**
* Cache-age hint for command output.
*
* Reads the process-global cache-hit state from `response-cache.ts` and
* formats a human-readable hint like "cached · 3m ago · use -f to refresh".
* Applied automatically by `buildCommand` in `command.ts` — individual
* commands don't need to call this themselves.
*
* getsentry/cli#785 item #1 — `-f/--fresh` flag discoverability.
*
* @module
*/

import { getLastCacheHitAge } from "./response-cache.js";

/**
* Format a millisecond duration as a compact human-readable string.
*
* - `< 5s` → `"just now"`
* - `5s–59s` → `"Ns ago"`
* - `1m–59m` → `"Nm ago"`
* - `1h–23h` → `"Nh ago"`
* - `≥ 24h` → `"Nd ago"`
*
* @internal Exported for testing
*/
export function formatAge(ms: number): string {
const seconds = Math.floor(ms / 1000);
if (seconds < 5) {
return "just now";
}
if (seconds < 60) {
return `${seconds}s ago`;
}
const minutes = Math.floor(seconds / 60);
if (minutes < 60) {
return `${minutes}m ago`;
}
const hours = Math.floor(minutes / 60);
if (hours < 24) {
return `${hours}h ago`;
}
const days = Math.floor(hours / 24);
return `${days}d ago`;
}

/**
* Build a cache-age hint string, or `undefined` when the last request was
* not served from cache.
*
* When multiple API calls run in parallel (e.g. `Promise.all`), the
* displayed age corresponds to whichever resolves last — acceptable
* since all hits share similar ages in practice.
*
* Example output: `"cached · 3m ago · use -f to refresh"`
*/
export function formatCacheHint(): string | undefined {
const ageMs = getLastCacheHitAge();
if (ageMs === undefined) {
return;
}
return `cached · ${formatAge(ageMs)} · use -f to refresh`;
}

/**
* Append a cache-age hint to an existing hint string.
*
* - Both present → `"existingHint | cached · 3m ago · ..."`
* - Only cache hint → `"cached · 3m ago · ..."`
* - Only existing → `"existingHint"` (unchanged)
* - Neither → `undefined`
*/
export function appendCacheHint(
existingHint: string | undefined
): string | undefined {
const cacheHint = formatCacheHint();
if (existingHint && cacheHint) {
return `${existingHint} | ${cacheHint}`;
}
return cacheHint ?? existingHint;
}
10 changes: 8 additions & 2 deletions src/lib/command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ import {
numberParser as stricliNumberParser,
} from "@stricli/core";
import type { Writer } from "../types/index.js";
import { appendCacheHint } from "./cache-hint.js";
import { getAuthConfig } from "./db/auth.js";
import { getEnv } from "./env.js";
import { AuthError, CliError, OutputError } from "./errors.js";
Expand Down Expand Up @@ -768,9 +769,14 @@ export function buildCommand<
}
);

// Render phase: output finalization
// Render phase: output finalization.
// Append cache-age hint automatically so every command gets
// "cached · 3m ago · use -f to refresh" when data was cached.
// Skip bare `return;` paths (e.g. `--web` which opens a browser
// without yielding) — no rendered output should mean no footer.
const finalHint = returned ? appendCacheHint(returned.hint) : undefined;
await withTracing("render", "cli.command.render", () => {
writeFinalization(stdout, returned?.hint, cleanFlags.json, renderer);
writeFinalization(stdout, finalHint, cleanFlags.json, renderer);
});
} catch (err) {
// Finalize before error handling to close streaming state
Expand Down
157 changes: 143 additions & 14 deletions src/lib/help.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,56 @@ type HelpCommand = {
description: string;
};

// ---------------------------------------------------------------------------
// Common flags — surfaced in the branded `sentry --help` output
// ---------------------------------------------------------------------------

/**
* Metadata for a flag shown in the top-level help.
*
* Only the highest-signal flags belong here — per-command flags are
* documented in each command's own `--help`.
*/
type CommonFlagEntry = {
/** Long flag name with `--` prefix (e.g., `"--json"`). */
long: string;
/** Short alias with `-` prefix, or `undefined` if none. */
short?: string;
/** One-line description for the branded help. */
description: string;
};

/**
* Flags surfaced in the branded `sentry` help output.
*
* Includes both truly global flags (injected into every command) and
* widely-used flags present on most list/view commands.
*/
const COMMON_FLAGS: readonly CommonFlagEntry[] = [
{
long: "--json",
description: "Output as JSON (with --fields to select)",
},
{
long: "--fresh",
short: "-f",
description: "Bypass cache and fetch fresh data",
},
{
long: "--verbose",
short: "-v",
description: "Enable debug logging",
},
{
long: "--help",
description: "Show help for a command",
},
{
long: "--version",
description: "Show version",
},
];

/**
* Generate the commands list dynamically from Stricli's route structure.
* This ensures help text stays in sync with actual registered commands.
Expand Down Expand Up @@ -100,6 +150,27 @@ function formatCommands(commands: HelpCommand[]): string {
.join("\n");
}

/**
* Format the common flags list with aligned descriptions.
*
* Renders flag names (with optional short alias) left-aligned and
* descriptions right-aligned, styled identically to the env-var section.
*/
function formatCommonFlags(): string {
if (COMMON_FLAGS.length === 0) {
return "";
}
const padding = 4;
const labels = COMMON_FLAGS.map((f) =>
f.short ? `${f.short}, ${f.long}` : ` ${f.long}`
);
const maxLabelLength = Math.max(...labels.map((l) => l.length));
return COMMON_FLAGS.map((f, i) => {
const labelPadded = (labels[i] ?? "").padEnd(maxLabelLength + padding);
return ` ${cyan(labelPadded)}${muted(f.description)}`;
}).join("\n");
}

/**
* Format the top-level environment variable list with aligned descriptions.
*
Expand Down Expand Up @@ -146,6 +217,14 @@ export function printCustomHelp(): string {
lines.push(formatCommands(generateCommands()));
lines.push("");

// Common flags
const flags = formatCommonFlags();
if (flags) {
lines.push(` ${muted("Flags:")}`);
lines.push(flags);
lines.push("");
}

// Environment variables (auto-generated from env-registry)
const envVars = formatEnvVars();
if (envVars) {
Expand Down Expand Up @@ -187,6 +266,18 @@ export type HelpEnvVarInfo = {
defaultValue?: string;
};

/**
* Metadata for a common flag as exposed to JSON callers of `sentry help --json`.
*/
export type HelpFlagInfo = {
/** Long flag name including `--` prefix (e.g. `"--json"`). */
long: string;
/** Short alias including `-` prefix (e.g. `"-f"`), or undefined if none. */
short?: string;
/** One-line description. */
description: string;
};

/**
* Result of introspecting the CLI.
* Yielded as CommandOutput — JSON mode serializes directly, human mode
Expand All @@ -196,7 +287,13 @@ export type HelpEnvVarInfo = {
* it's stripped from JSON output via `jsonExclude`.
*/
export type HelpJsonResult =
| ({ routes: RouteInfo[]; envVars: HelpEnvVarInfo[] } & { _banner?: string })
| ({
routes: RouteInfo[];
envVars: HelpEnvVarInfo[];
flags: HelpFlagInfo[];
} & {
_banner?: string;
})
| CommandInfo
| RouteInfo
| { error: string; suggestions?: string[] };
Expand All @@ -218,19 +315,35 @@ function buildTopLevelEnvVars(): HelpEnvVarInfo[] {
}));
}

/**
* Build the common flags list for JSON output.
*
* Exposes the same entries shown in the branded help so that consumers
* (AI agents, docs tooling) can discover them programmatically.
*/
function buildCommonFlags(): HelpFlagInfo[] {
return COMMON_FLAGS.map((f) => ({
long: f.long,
short: f.short,
description: f.description,
}));
}

/**
* Introspect the full command tree.
* Returns all visible routes with all flags included, plus the top-level
* environment variables recognized by the CLI.
* environment variables and common flags recognized by the CLI.
*/
export function introspectAllCommands(): {
routes: RouteInfo[];
envVars: HelpEnvVarInfo[];
flags: HelpFlagInfo[];
} {
const routeMap = routes as unknown as RouteMap;
return {
routes: extractAllRoutes(routeMap),
envVars: buildTopLevelEnvVars(),
flags: buildCommonFlags(),
};
}

Expand Down Expand Up @@ -378,6 +491,32 @@ function formatGroupHuman(group: RouteInfo): string {
return lines.join("\n");
}

/**
* Format the full command tree (without banner) as human-readable text.
* Includes flags and env-var sections when present.
*/
function formatFullTreeHuman(data: {
routes: RouteInfo[];
envVars?: HelpEnvVarInfo[];
flags?: HelpFlagInfo[];
}): string {
const lines: string[] = [data.routes.map(formatGroupHuman).join("\n\n")];
if (data.flags && data.flags.length > 0) {
lines.push("", "Flags:");
for (const f of data.flags) {
const label = f.short ? `${f.short}, ${f.long}` : f.long;
lines.push(` ${label} — ${f.description}`);
}
}
if (data.envVars && data.envVars.length > 0) {
lines.push("", "Environment Variables:");
for (const v of data.envVars) {
lines.push(` ${v.name} — ${v.brief}`);
}
}
return lines.join("\n");
}

/**
* Human renderer for help introspection data.
*
Expand Down Expand Up @@ -413,19 +552,9 @@ export function formatHelpHuman(data: HelpJsonResult): string {
}

// Full tree without banner (shouldn't happen in practice — the help
// command always attaches one). Keep the envVars in sync anyway.
// command always attaches one). Keep the envVars/flags in sync anyway.
if ("routes" in data) {
const routesText = data.routes.map(formatGroupHuman).join("\n\n");
if ("envVars" in data && data.envVars.length > 0) {
const lines = [
routesText,
"",
"Environment Variables:",
...data.envVars.map((v) => ` ${v.name} — ${v.brief}`),
];
return lines.join("\n");
}
return routesText;
return formatFullTreeHuman(data);
}

return "";
Expand Down
44 changes: 44 additions & 0 deletions src/lib/response-cache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -314,6 +314,47 @@ function buildResponseHeaders(
return result;
}

// ---------------------------------------------------------------------------
// Last cache-hit age — process-global signal for cache-age hints
// ---------------------------------------------------------------------------

/**
* Age of the most recent cache hit, in milliseconds.
*
* Set inside {@link getCachedResponse} on a hit, cleared at the start of each
* `authenticatedFetch` call in `sentry-client.ts`. Commands read it via
* {@link getLastCacheHitAge} to show "cached · 3m ago · use -f to refresh".
*
* Safe because the CLI is single-process, single-command — no races.
*/
let lastCacheHitAgeMs: number | undefined;

/**
* Get the age (in ms) of the most recent cache hit, or `undefined` if the
* last request was not served from cache.
*/
export function getLastCacheHitAge(): number | undefined {
return lastCacheHitAgeMs;
}

/**
* Clear the last cache-hit age. Called at the top of each `authenticatedFetch`
* call so the signal reflects only the current request.
*/
export function clearLastCacheHitAge(): void {
lastCacheHitAgeMs = undefined;
}

/**
* Set the last cache-hit age directly. Test-only — production code paths
* set this implicitly via {@link getCachedResponse} on a real cache hit.
*
* @internal Exported for testing
*/
export function setLastCacheHitAgeForTesting(ageMs: number): void {
lastCacheHitAgeMs = ageMs;
}

// ---------------------------------------------------------------------------
// Cache bypass control
// ---------------------------------------------------------------------------
Comment thread
cursor[bot] marked this conversation as resolved.
Expand Down Expand Up @@ -426,6 +467,9 @@ export async function getCachedResponse(
recordCacheHit("http", true);
span.setAttribute("cache.item_size", body.length);

// Surface cache age for command-level hints (getsentry/cli#785 #1)
lastCacheHitAgeMs = Date.now() - entry.createdAt;

const responseHeaders = buildResponseHeaders(policy, entry);
return new Response(body, {
status: entry.status,
Expand Down
Loading
Loading