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

Large diffs are not rendered by default.

183 changes: 154 additions & 29 deletions src/lib/command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ import {
} from "@stricli/core";
import type { Writer } from "../types/index.js";
import { getAuthConfig } from "./db/auth.js";
import { getEnv } from "./env.js";
import { AuthError, CliError, OutputError } from "./errors.js";
import { warning } from "./formatters/colors.js";
import { parseFieldsList } from "./formatters/json.js";
Expand All @@ -58,6 +59,7 @@ import { GLOBAL_FLAGS } from "./global-flags.js";
import {
LOG_LEVEL_NAMES,
type LogLevelName,
logger,
parseLogLevel,
setLogLevel,
} from "./logger.js";
Expand Down Expand Up @@ -230,6 +232,40 @@ export const FIELDS_FLAG = {
optional: true as const,
} as const;

// ---------------------------------------------------------------------------
// Hidden org/project compat flags (LLM error recovery)
// ---------------------------------------------------------------------------

/**
* Hidden `--org` flag injected into every command by {@link buildCommand}.
*
* Backward-compatibility shim for the older `sentry-cli` that accepted
* `--org` on every command. LLMs trained on the old CLI generate this flag.
* The value is written to `SENTRY_ORG` so the existing resolution chain
* in `resolve-target.ts` picks it up at priority #2 (env vars).
*/
const ORG_FLAG = {
kind: "parsed" as const,
parse: String,
brief: "Organization slug",
optional: true as const,
hidden: true as const,
} as const;

/**
* Hidden `--project` flag injected into every command by {@link buildCommand}.
*
* Same backward-compatibility shim as `--org`. Written to `SENTRY_PROJECT`
* before the command's `func` runs.
*/
const PROJECT_FLAG = {
kind: "parsed" as const,
parse: String,
brief: "Project slug",
optional: true as const,
hidden: true as const,
} as const;

/** The flag key for the injected --log-level flag (always stripped) */
const LOG_LEVEL_KEY = "log-level";

Expand All @@ -253,6 +289,44 @@ export function applyLoggingFlags(
}
}

const orgProjectLog = logger.withTag("compat-flags");

/**
* Write `--org` / `--project` flag values to environment variables.
*
* Called by the {@link buildCommand} wrapper before the command's `func()`
* runs. The values are written to `SENTRY_ORG` and `SENTRY_PROJECT` so
* the existing resolution chain in `resolve-target.ts` picks them up at
* priority #2 (env vars). Overwrites existing env vars because explicit
* CLI flags are highest-priority user intent.
*
* Empty and whitespace-only values are treated as "not provided" — they
* fall through to the rest of the resolution chain rather than overwriting
* a real pre-existing env var with garbage. This matters for LLM-generated
* commands that occasionally emit `--org ""` or stray whitespace.
*
* Skipped when the command defines its own `--org` / `--project` flag
* (e.g., `release create --project`) — those are passed through to `func()`.
*/
export function applyOrgProjectFlags(
org: string | undefined,
project: string | undefined,
commandOwnsOrg: boolean,
commandOwnsProject: boolean
): void {
const env = getEnv();
const orgTrimmed = org?.trim();
const projectTrimmed = project?.trim();
if (orgTrimmed && !commandOwnsOrg) {
orgProjectLog.debug(`--org flag → SENTRY_ORG=${orgTrimmed}`);
env.SENTRY_ORG = orgTrimmed;
}
if (projectTrimmed && !commandOwnsProject) {
orgProjectLog.debug(`--project flag → SENTRY_PROJECT=${projectTrimmed}`);
env.SENTRY_PROJECT = projectTrimmed;
}
}

// ---------------------------------------------------------------------------
// buildCommand — the single entry point for all Sentry CLI commands
// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -334,6 +408,70 @@ function enrichDocsWithSchema(
};
}

/**
* Global flag defaults keyed by flag name.
* Used by {@link mergeGlobalFlags} to inject flags when the command
* doesn't define its own.
*/
const GLOBAL_FLAG_DEFAULTS: Record<string, unknown> = {
[LOG_LEVEL_KEY]: LOG_LEVEL_FLAG,
verbose: VERBOSE_FLAG,
org: ORG_FLAG,
project: PROJECT_FLAG,
};

/** Flags that are always stripped (command never sees them). */
const ALWAYS_STRIP = new Set([LOG_LEVEL_KEY]);

/**
* Merge global flags into a command's existing flags.
*
* For each global flag, if the command doesn't already define it, the
* default shape is injected and its key is added to the returned
* `stripKeys` set (so the wrapper strips it before calling `func`).
*
* @returns The merged flags, ownership booleans, and keys to strip.
*/
function mergeGlobalFlags(
existingFlags: Record<string, unknown>,
// biome-ignore lint/suspicious/noExplicitAny: OutputConfig type is erased at the builder level
outputConfig?: OutputConfig<any>
): {
mergedFlags: Record<string, unknown>;
commandOwnsOrg: boolean;
commandOwnsProject: boolean;
stripKeys: Set<string>;
} {
const commandOwnsJson = "json" in existingFlags;
const commandOwnsOrg = "org" in existingFlags;
const commandOwnsProject = "project" in existingFlags;

const mergedFlags: Record<string, unknown> = { ...existingFlags };
const stripKeys = new Set<string>(ALWAYS_STRIP);

for (const [name, shape] of Object.entries(GLOBAL_FLAG_DEFAULTS)) {
if (!(name in existingFlags)) {
mergedFlags[name] = shape;
stripKeys.add(name);
}
}

// Inject --json and --fields when output config is set
if (outputConfig) {
if (!commandOwnsJson) {
mergedFlags.json = JSON_FLAG;
}
mergedFlags.fields = buildFieldsFlag(outputConfig);
}

return {
mergedFlags,
commandOwnsOrg,
commandOwnsProject,
stripKeys,
};
}

export function buildCommand<
const FLAGS extends BaseFlags = NonNullable<unknown>,
const ARGS extends BaseArgs = [],
Expand All @@ -345,34 +483,15 @@ export function buildCommand<
const outputConfig = builderArgs.output;
const requiresAuth = builderArgs.auth !== false;

// Merge logging flags into the command's flag definitions.
// Quoted keys produce kebab-case CLI flags: "log-level" → --log-level
// Merge global flags into the command's flag definitions.
const existingParams = (builderArgs.parameters ?? {}) as Record<
string,
unknown
>;
const existingFlags = (existingParams.flags ?? {}) as Record<string, unknown>;

// If the command already defines --verbose (e.g. api command), don't override it.
const commandOwnsVerbose = "verbose" in existingFlags;
// If the command already defines --json (e.g. custom brief), don't override it.
const commandOwnsJson = "json" in existingFlags;

const mergedFlags: Record<string, unknown> = {
...existingFlags,
[LOG_LEVEL_KEY]: LOG_LEVEL_FLAG,
};
if (!commandOwnsVerbose) {
mergedFlags.verbose = VERBOSE_FLAG;
}

// Inject --json and --fields when output config is set
if (outputConfig) {
if (!commandOwnsJson) {
mergedFlags.json = JSON_FLAG;
}
mergedFlags.fields = buildFieldsFlag(outputConfig);
}
const { mergedFlags, commandOwnsOrg, commandOwnsProject, stripKeys } =
mergeGlobalFlags(existingFlags, outputConfig);

// Enrich fullDescription with JSON fields when schema is registered.
// This makes field info visible in Stricli's --help output.
Expand Down Expand Up @@ -434,19 +553,16 @@ export function buildCommand<

/**
* Strip injected flags from the raw Stricli-parsed flags object.
* --log-level is always stripped. --verbose is stripped only when we
* injected it (not when the command defines its own). --fields is
* pre-parsed from comma-string to string[] when output: { human: ... }.
* Global flags we injected (tracked in `stripKeys` from
* {@link mergeGlobalFlags}) are removed. --fields is pre-parsed from
* comma-string to string[] when output: { human: ... }.
*/
function cleanRawFlags(
raw: Record<string, unknown>
): Record<string, unknown> {
const clean: Record<string, unknown> = {};
for (const [key, value] of Object.entries(raw)) {
if (key === LOG_LEVEL_KEY) {
continue;
}
if (key === "verbose" && !commandOwnsVerbose) {
if (stripKeys.has(key)) {
continue;
}
clean[key] = value;
Expand Down Expand Up @@ -549,6 +665,15 @@ export function buildCommand<
flags.verbose as boolean
);

// Map --org / --project compat flags to env vars before anything
// else reads them (auth guard, org/project resolution, etc.)
applyOrgProjectFlags(
flags.org as string | undefined,
flags.project as string | undefined,
commandOwnsOrg,
commandOwnsProject
);

const cleanFlags = cleanRawFlags(flags as Record<string, unknown>);
setFlagContext(cleanFlags);
if (args.length > 0) {
Expand Down
6 changes: 6 additions & 0 deletions src/lib/global-flags.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,4 +40,10 @@ export const GLOBAL_FLAGS: readonly GlobalFlagDef[] = [
{ name: "log-level", short: null, kind: "value" },
{ name: "json", short: null, kind: "boolean" },
{ name: "fields", short: null, kind: "value" },
// Hidden compat shims: LLMs trained on the older sentry-cli generate
// `--org` and `--project` flags. We silently accept them and map to
// SENTRY_ORG / SENTRY_PROJECT env vars so the resolution chain handles them.
// No short aliases: -p conflicts with release create's -p (--project).
{ name: "org", short: null, kind: "value" },
{ name: "project", short: null, kind: "value" },
];
11 changes: 10 additions & 1 deletion test/lib/argv-hoist.property.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ const GLOBAL_FLAG_TOKENS = [
"-v",
"--log-level",
"--fields",
"--org",
"--project",
] as const;

/** Tokens that should never be hoisted */
Expand Down Expand Up @@ -62,6 +64,8 @@ const HOISTABLE_SET = new Set([
"-v",
"--log-level",
"--fields",
"--org",
"--project",
]);

function isHoistableToken(token: string): boolean {
Expand Down Expand Up @@ -120,7 +124,12 @@ describe("property: hoistGlobalFlags", () => {

test("hoisted tokens appear after all non-hoisted tokens", () => {
/** Value-taking flags whose next token also gets hoisted */
const VALUE_TAKING = new Set(["--log-level", "--fields"]);
const VALUE_TAKING = new Set([
"--log-level",
"--fields",
"--org",
"--project",
]);

fcAssert(
property(argvArb, (argv) => {
Expand Down
52 changes: 52 additions & 0 deletions test/lib/argv-hoist.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -176,6 +176,58 @@ describe("hoistGlobalFlags", () => {
]);
});

// -------------------------------------------------------------------------
// Value flag: --org (compat)
// -------------------------------------------------------------------------

test("hoists --org with separate value", () => {
expect(hoistGlobalFlags(["--org", "sentry", "issue", "list"])).toEqual([
"issue",
"list",
"--org",
"sentry",
]);
});

test("hoists --org=sentry as single token", () => {
expect(hoistGlobalFlags(["--org=sentry", "issue", "list"])).toEqual([
"issue",
"list",
"--org=sentry",
]);
});

// -------------------------------------------------------------------------
// Value flag: --project (compat)
// -------------------------------------------------------------------------

test("hoists --project with separate value", () => {
expect(hoistGlobalFlags(["--project", "cli", "issue", "list"])).toEqual([
"issue",
"list",
"--project",
"cli",
]);
});

test("hoists --project=cli as single token", () => {
expect(hoistGlobalFlags(["--project=cli", "issue", "list"])).toEqual([
"issue",
"list",
"--project=cli",
]);
});

// -------------------------------------------------------------------------
// Combined: --org + --project (compat)
// -------------------------------------------------------------------------

test("hoists --org and --project together", () => {
expect(
hoistGlobalFlags(["--org", "sentry", "--project", "cli", "issue", "list"])
).toEqual(["issue", "list", "--org", "sentry", "--project", "cli"]);
});

// -------------------------------------------------------------------------
// Multiple flags
// -------------------------------------------------------------------------
Expand Down
Loading
Loading