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
9 changes: 9 additions & 0 deletions packages/cli/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -225,6 +225,15 @@ export const myRoute = buildRouteMap({
});
```

**Reading the command tree:** code in `src/lib/` reads the top-level route map
with `getRoutes()` from `src/lib/route-registry.ts`, never by importing
`app.ts`. Every command reaches `src/lib/` through `buildCommand`, so that
import would pull every command into any bundle built from one of them.
`app.ts` registers its map with `setRoutes()`; a test that needs the real tree
imports `src/app.js`. `test/lib/route-registry.test.ts` fails when a module in
`src/lib/` reaches `app.ts`; the only exception is `sdk-invoke.ts`, the
library-mode runner that executes the whole application.

### Positional Arguments

Required identifiers (trace IDs, span IDs) should be **positional args**, not
Expand Down
2 changes: 1 addition & 1 deletion packages/cli/script/build.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ const NODE_VERSION = "lts";

/** Files whose lazy relative imports must be included in the native bundle. */
const REQUIRE_ALIAS_FILTER =
/(?:db[\\/](?:index|schema)|formatters[\\/](?:highlight|json)|list-command|telemetry)\.ts$/;
/(?:db[\\/](?:index|schema)|formatters[\\/](?:highlight|json)|telemetry)\.ts$/;
const REQUIRE_ALIAS_RE = /\b_require\(/g;

/** Build-time constants injected into the binary */
Expand Down
2 changes: 1 addition & 1 deletion packages/cli/script/bundle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -171,7 +171,7 @@ const sentrySourcemapPlugin: Plugin = {
* `formatters/highlight` loads cli-highlight only on the first code block.
*/
const REQUIRE_ALIAS_FILTER =
/(?:db[\\/](?:index|schema|sqlite)|formatters[\\/](?:highlight|json)|list-command|telemetry)\.ts$/;
/(?:db[\\/](?:index|schema|sqlite)|formatters[\\/](?:highlight|json)|telemetry)\.ts$/;
const REQUIRE_ALIAS_RE = /\b_require\(/g;
const IDENTIFIER_FILTER_RE = /^[A-Za-z_$][\w$]*$/;
// Match both the prefixed and bare specifiers — bundled dependencies (e.g.
Expand Down
5 changes: 5 additions & 0 deletions packages/cli/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,7 @@ import { buildTopLevelFlags } from "./lib/global-flags.js";
import { renderJsonHelp } from "./lib/help.js";
import { isRouteMap, type RouteMap } from "./lib/introspect.js";
import { buildRouteMap } from "./lib/route-map.js";
import { setRoutes } from "./lib/route-registry.js";

/**
* Plural alias → singular route name mapping.
Expand Down Expand Up @@ -227,6 +228,10 @@ export const routes = buildRouteMap({
},
});

// Library modules (help, completions, ...) read the command tree from the
// registry instead of importing this module, which would pull in every command.
setRoutes(routes);

/**
* Route group names that have `defaultCommand` set.
*
Expand Down
4 changes: 2 additions & 2 deletions packages/cli/src/commands/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ import { CommandOutput } from "../lib/formatters/output.js";
import { fuzzyMatch } from "../lib/fuzzy.js";
import { getCommandApiReferences, type RouteMap } from "../lib/introspect.js";
import { filterByName, isGlobFilter } from "../lib/list-filter.js";
import { getRoutes } from "../lib/route-registry.js";

// ---------------------------------------------------------------------------
// Output data types
Expand Down Expand Up @@ -543,9 +544,8 @@ export const schemaCommand = buildCommand({
});
}

const { routes } = await import("../app.js");
const references = getCommandApiReferences(
routes as unknown as RouteMap,
getRoutes() as unknown as RouteMap,
parseEndpointQuery(query).text,
);

Expand Down
4 changes: 2 additions & 2 deletions packages/cli/src/lib/completions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,8 @@
import { existsSync, mkdirSync } from "node:fs";
import { writeFile } from "node:fs/promises";
import { dirname, join } from "node:path";
import { routes } from "../app.js";
import { isRouteMap } from "./introspect.js";
import { getRoutes } from "./route-registry.js";
import type { ShellType } from "./shell.js";

/** Where completions are installed */
Expand Down Expand Up @@ -55,7 +55,7 @@ export function extractCommandTree(): CommandTree {
const groups: CommandGroup[] = [];
const standalone: CommandEntry[] = [];

for (const entry of routes.getAllEntries()) {
for (const entry of getRoutes().getAllEntries()) {
if (entry.hidden) {
continue;
}
Expand Down
8 changes: 4 additions & 4 deletions packages/cli/src/lib/help.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@
* Commands are auto-generated from Stricli's route structure.
*/

import { routes } from "../app.js";
import { formatBanner } from "./banner.js";
import { isAuthenticated } from "./db/auth.js";
import { detectAgent } from "./detect-agent.js";
Expand All @@ -29,6 +28,7 @@ import {
type RouteMapEntry,
resolveCommandPath,
} from "./introspect.js";
import { getRoutes } from "./route-registry.js";
import { graphicsBanner, graphicsBannerExtraRows } from "./sixel.js";

const TAGLINE = "The command-line interface for Sentry";
Expand Down Expand Up @@ -112,7 +112,7 @@ const MIN_OVERVIEW_COMMANDS = 3;
/** Visible top-level route entries, in registration order. */
function visibleRouteEntries(): RouteMapEntry[] {
// Cast to our introspection types — Stricli's generic types are compatible
const routeMap = routes as unknown as RouteMap;
const routeMap = getRoutes() as unknown as RouteMap;
return routeMap
.getAllEntries()
.filter((entry: RouteMapEntry) => !entry.hidden);
Expand Down Expand Up @@ -460,7 +460,7 @@ export function introspectAllCommands(): {
envVars: HelpEnvVarInfo[];
flags: HelpFlagInfo[];
} {
const routeMap = routes as unknown as RouteMap;
const routeMap = getRoutes() as unknown as RouteMap;
return {
routes: extractAllRoutes(routeMap),
envVars: buildTopLevelEnvVars(),
Expand All @@ -476,7 +476,7 @@ export function introspectAllCommands(): {
export function introspectCommand(
commandPath: string[],
): CommandInfo | RouteInfo | { error: string; suggestions?: string[] } {
const routeMap = routes as unknown as RouteMap;
const routeMap = getRoutes() as unknown as RouteMap;
const resolved = resolveCommandPath(routeMap, commandPath);
if (!resolved) {
return { error: `Command not found: ${commandPath.join(" ")}` };
Expand Down
61 changes: 24 additions & 37 deletions packages/cli/src/lib/list-command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,12 +13,8 @@
* buildOrgListCommand
*/

import { createRequire } from "node:module";
import type { Aliases, Command, CommandContext } from "@stricli/core";
import type { SentryContext } from "../context.js";

const _require = createRequire(import.meta.url);

import type { ListTargetOptions } from "./arg-parsing.js";
import {
buildCommand,
Expand All @@ -27,10 +23,6 @@ import {
} from "./command.js";
import { ORG_PROJECT_TARGET, ORG_TARGET } from "./completion-target.js";
import { disableOrgCache } from "./db/regions.js";
import { logger } from "./logger.js";

const log = logger.withTag("list-command");

import { disableDsnCache } from "./dsn/index.js";
import { warning } from "./formatters/colors.js";
import {
Expand All @@ -51,6 +43,7 @@ import {
skippedArgument,
} from "./positional-parse.js";
import { disableResponseCache } from "./response-cache.js";
import { type CliRoutes, findRoutes } from "./route-registry.js";
import { type ListCommandTarget, listTarget } from "./target-positional.js";
import { PERIOD_BRIEF, parsePeriod } from "./time-range.js";
import { shellQuote } from "./utils.js";
Expand Down Expand Up @@ -445,7 +438,8 @@ export const LIST_BASE_ALIASES: Aliases<string> = { n: "limit", c: "cursor" };
// Level B: subcommand interception for plural aliases
// ---------------------------------------------------------------------------

let _subcommandsByRoute: Map<string, Set<string>> | undefined;
/** Subcommand names by route group, per registered route map. */
const subcommandsByRoutes = new WeakMap<CliRoutes, Map<string, Set<string>>>();

/**
* Entry shape returned by Stricli's getAllEntries().
Expand Down Expand Up @@ -475,39 +469,32 @@ function collectChildNames(parent: {
/**
* Get the subcommand names for a given singular route (e.g. "project" → {"list", "view"}).
*
* Lazily walks the Stricli route map on first call. Uses `require()` to break
* the circular dependency: list-command → app → commands → list-command.
* Walks the registered route map once and caches the result for it. With no
* route map registered (a command's `func` called directly, as in unit
* tests), there are no subcommands to intercept.
*/
function getSubcommandsForRoute(routeName: string): Set<string> {
if (!_subcommandsByRoute) {
_subcommandsByRoute = new Map();

try {
const { routes } = _require("../app.js") as {
routes: { getAllEntries: () => readonly RouteEntry[] };
};

for (const entry of routes.getAllEntries()) {
const target = entry.target as unknown as Record<string, unknown>;
if (typeof target?.getAllEntries === "function") {
_subcommandsByRoute.set(
entry.name.original,
collectChildNames(
target as { getAllEntries: () => readonly RouteEntry[] },
),
);
}
const routes = findRoutes();
if (!routes) {
return new Set();
}
let byRoute = subcommandsByRoutes.get(routes);
if (!byRoute) {
byRoute = new Map();
for (const entry of routes.getAllEntries()) {
const target = entry.target as unknown as Record<string, unknown>;
if (typeof target?.getAllEntries === "function") {
byRoute.set(
entry.name.original,
collectChildNames(
target as { getAllEntries: () => readonly RouteEntry[] },
),
);
}
} catch (error) {
// In test environments (vitest), require("../app.js") may fail because
// Node's ESM resolver can't resolve .js→.ts for transitive imports.
// Gracefully degrade: interceptSubcommand will treat all targets as
// plain values (no subcommand interception).
log.debug("Failed to load app routes for subcommand detection", error);
}
subcommandsByRoutes.set(routes, byRoute);
}

return _subcommandsByRoute.get(routeName) ?? new Set();
return byRoute.get(routeName) ?? new Set();
}

/**
Expand Down
42 changes: 42 additions & 0 deletions packages/cli/src/lib/route-registry.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
/**
* The route map the running CLI was built from.
*
* Library modules (help, completions, list-command) need the command tree,
* but importing `app.ts` for it would pull every command into any bundle
* that reaches those modules — and every command reaches them through
* `buildCommand`. Instead, whatever builds the application registers its
* route map here: `app.ts` for the full CLI, or another entry point with a
* smaller map. Tests can register a fake one.
*/

import type { RouteMap } from "@stricli/core";
import type { SentryContext } from "../context.js";

/** Top-level route map of the CLI application. */
export type CliRoutes = RouteMap<SentryContext>;

let registeredRoutes: CliRoutes | undefined;

/** Register the route map the CLI application runs. Replaces any earlier one. */
export function setRoutes(routes: CliRoutes): void {
registeredRoutes = routes;
}

/** The registered route map, or `undefined` before registration. */
export function findRoutes(): CliRoutes | undefined {
return registeredRoutes;
}

/**
* The registered route map.
*
* @throws {Error} When no entry point has called {@link setRoutes} yet
*/
export function getRoutes(): CliRoutes {
if (!registeredRoutes) {
throw new Error(
"CLI routes are not registered. The entry point that builds the application must call setRoutes() first (src/app.ts does).",
);
}
return registeredRoutes;
}
2 changes: 2 additions & 0 deletions packages/cli/test/commands/schema-path.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@
*/

import { describe, expect, test, vi } from "vitest";
// Registers the CLI route map that command API references are read from.
import "../../src/app.js";
import { schemaCommand } from "../../src/commands/schema.js";
import type { SentryContext } from "../../src/context.js";
import { getAllEndpoints } from "../../src/lib/api-schema.js";
Expand Down
2 changes: 2 additions & 0 deletions packages/cli/test/lib/completions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ import { chmodSync, existsSync, mkdirSync, rmSync } from "node:fs";
import { readFile } from "node:fs/promises";
import { join } from "node:path";
import { afterEach, beforeEach, describe, expect, test } from "vitest";
// Registers the CLI route map the completion scripts are generated from.
import "../../src/app.js";
import {
extractCommandTree,
getCompletionPath,
Expand Down
2 changes: 2 additions & 0 deletions packages/cli/test/lib/help-json.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@
*/

import { describe, expect, test } from "vitest";
// Registers the CLI route map the help output is built from.
import "../../src/app.js";
import {
isRecoverableUnknownCommand,
isVersionRequest,
Expand Down
2 changes: 2 additions & 0 deletions packages/cli/test/lib/help.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@
*/

import { describe, expect, test } from "vitest";
// Registers the CLI route map the help output is built from.
import "../../src/app.js";
import { bannerLinesForWidth, formatBanner } from "../../src/lib/banner.js";
import { withEnv } from "../../src/lib/env.js";
import { introspectAllCommands, printCustomHelp } from "../../src/lib/help.js";
Expand Down
Loading
Loading