diff --git a/plugins/sentry-cli/skills/sentry-cli/SKILL.md b/plugins/sentry-cli/skills/sentry-cli/SKILL.md index 986ba5fb..5c7a1d89 100644 --- a/plugins/sentry-cli/skills/sentry-cli/SKILL.md +++ b/plugins/sentry-cli/skills/sentry-cli/SKILL.md @@ -700,7 +700,7 @@ Start a product trial Initialize Sentry in your project -#### `sentry init ` +#### `sentry init ` Initialize Sentry in your project diff --git a/src/commands/init.ts b/src/commands/init.ts index 10fdd7fe..233c572c 100644 --- a/src/commands/init.ts +++ b/src/commands/init.ts @@ -4,15 +4,35 @@ * Initialize Sentry in a project using the remote wizard workflow. * Communicates with the Mastra API via suspend/resume to perform * local filesystem operations and interactive prompts. + * + * Supports two optional positionals with smart disambiguation: + * sentry init — auto-detect everything, dir = cwd + * sentry init . — dir = cwd, auto-detect org + * sentry init ./subdir — dir = subdir, auto-detect org + * sentry init acme/ — explicit org, dir = cwd + * sentry init acme/my-app — explicit org + project, dir = cwd + * sentry init my-app — search for project across orgs + * sentry init acme/ ./subdir — explicit org, dir = subdir + * sentry init acme/my-app ./subdir — explicit org + project, dir = subdir + * sentry init ./subdir acme/ — swapped, auto-correct with warning */ import path from "node:path"; import type { SentryContext } from "../context.js"; +import { looksLikePath, parseOrgProjectArg } from "../lib/arg-parsing.js"; import { buildCommand } from "../lib/command.js"; +import { ContextError } from "../lib/errors.js"; import { runWizard } from "../lib/init/wizard-runner.js"; +import { validateResourceId } from "../lib/input-validation.js"; +import { logger } from "../lib/logger.js"; +import { resolveProjectBySlug } from "../lib/resolve-target.js"; + +const log = logger.withTag("init"); const FEATURE_DELIMITER = /[,+ ]+/; +const USAGE_HINT = "sentry init / [directory]"; + type InitFlags = { readonly yes: boolean; readonly "dry-run": boolean; @@ -20,17 +40,129 @@ type InitFlags = { readonly team?: string; }; -export const initCommand = buildCommand({ +/** + * Classify and separate two optional positional args into a target and a directory. + * + * Uses {@link looksLikePath} to distinguish filesystem paths from org/project targets. + * Detects swapped arguments and emits a warning when auto-correcting. + * + * @returns Resolved target string (or undefined) and directory string (or undefined) + */ +function classifyArgs( + first?: string, + second?: string +): { target: string | undefined; directory: string | undefined } { + // No args — auto-detect everything + if (!first) { + return { target: undefined, directory: undefined }; + } + + const firstIsPath = looksLikePath(first); + + // Single arg + if (!second) { + return firstIsPath + ? { target: undefined, directory: first } + : { target: first, directory: undefined }; + } + + const secondIsPath = looksLikePath(second); + + // Two paths → error + if (firstIsPath && secondIsPath) { + throw new ContextError("Arguments", USAGE_HINT, [ + "Two directory paths provided. Only one directory is allowed.", + ]); + } + + // Two targets → error + if (!(firstIsPath || secondIsPath)) { + throw new ContextError("Arguments", USAGE_HINT, [ + "Two targets provided. Use / for the target and a path (e.g., ./dir) for the directory.", + ]); + } + + // (TARGET, PATH) — correct order + if (!firstIsPath && secondIsPath) { + return { target: first, directory: second }; + } + + // (PATH, TARGET) — swapped, auto-correct with warning + log.warn(`Arguments appear reversed. Interpreting as: ${second} ${first}`); + return { target: second, directory: first }; +} + +/** + * Resolve the parsed org/project target into explicit org and project values. + * + * For `project-search` (bare slug), calls {@link resolveProjectBySlug} to search + * across all accessible orgs and determine both org and project from the match. + */ +async function resolveTarget(targetArg: string | undefined): Promise<{ + org: string | undefined; + project: string | undefined; +}> { + const parsed = parseOrgProjectArg(targetArg); + + switch (parsed.type) { + case "explicit": + // Validate user-provided slugs before they reach API calls + validateResourceId(parsed.org, "organization slug"); + validateResourceId(parsed.project, "project name"); + return { org: parsed.org, project: parsed.project }; + case "org-all": + validateResourceId(parsed.org, "organization slug"); + return { org: parsed.org, project: undefined }; + case "project-search": { + // Bare slug — search for a project with this name across all orgs. + // resolveProjectBySlug handles not-found, ambiguity, and org-name-collision errors. + const resolved = await resolveProjectBySlug( + parsed.projectSlug, + USAGE_HINT, + `sentry init ${parsed.projectSlug}/ (if '${parsed.projectSlug}' is an org)` + ); + return { org: resolved.org, project: resolved.project }; + } + case "auto-detect": + return { org: undefined, project: undefined }; + default: { + const _exhaustive: never = parsed; + throw new ContextError("Target", String(_exhaustive)); + } + } +} + +export const initCommand = buildCommand< + InitFlags, + [string?, string?], + SentryContext +>({ docs: { brief: "Initialize Sentry in your project", fullDescription: "Runs the Sentry setup wizard to detect your project's framework, " + - "install the SDK, and configure Sentry.", + "install the SDK, and configure Sentry.\n\n" + + "Supports org/project syntax and a directory positional. Path-like\n" + + "arguments (starting with . / ~) are treated as the directory;\n" + + "everything else is treated as the target.\n\n" + + "Examples:\n" + + " sentry init\n" + + " sentry init acme/\n" + + " sentry init acme/my-app\n" + + " sentry init my-app\n" + + " sentry init acme/my-app ./my-project\n" + + " sentry init ./my-project", }, parameters: { positional: { kind: "tuple", parameters: [ + { + placeholder: "target", + brief: "/, /, , or a directory path", + parse: String, + optional: true, + }, { placeholder: "directory", brief: "Project directory (default: current directory)", @@ -69,19 +201,42 @@ export const initCommand = buildCommand({ t: "team", }, }, - async *func(this: SentryContext, flags: InitFlags, directory?: string) { - const targetDir = directory ? path.resolve(this.cwd, directory) : this.cwd; + async *func( + this: SentryContext, + flags: InitFlags, + first?: string, + second?: string + ) { + // 1. Classify positionals into target vs directory + const { target: targetArg, directory: dirArg } = classifyArgs( + first, + second + ); + + // 2. Resolve directory + const targetDir = dirArg ? path.resolve(this.cwd, dirArg) : this.cwd; + + // 3. Parse features const featuresList = flags.features ?.flatMap((f) => f.split(FEATURE_DELIMITER)) .map((f) => f.trim()) .filter(Boolean); + // 4. Resolve target → org + project + // Validation of user-provided slugs happens inside resolveTarget. + // API-resolved values (from resolveProjectBySlug) are already valid. + const { org: explicitOrg, project: explicitProject } = + await resolveTarget(targetArg); + + // 5. Run the wizard await runWizard({ directory: targetDir, yes: flags.yes, dryRun: flags["dry-run"], features: featuresList, team: flags.team, + org: explicitOrg, + project: explicitProject, }); }, }); diff --git a/src/lib/arg-parsing.ts b/src/lib/arg-parsing.ts index 9bfb3d46..a78b3f5a 100644 --- a/src/lib/arg-parsing.ts +++ b/src/lib/arg-parsing.ts @@ -79,6 +79,52 @@ export function looksLikeIssueShortId(str: string): boolean { } // --------------------------------------------------------------------------- +// Path detection +// --------------------------------------------------------------------------- + +/** + * Check if a string looks like a filesystem path rather than a slug/identifier. + * + * Uses purely syntactic checks — no filesystem I/O. Detects: + * - `.` (current directory) + * - `./foo`, `../foo` (relative paths) + * - `/foo` (absolute paths) + * - `~` or `~/foo` (home directory paths) + * + * Bare names like `my-org` or `my-project` never match, which is what makes + * this useful for disambiguating positional arguments that could be either + * a filesystem path or an org/project target. + * + * Note: `~` is only matched as `~` alone or `~/...`, not `~foo`. This avoids + * false positives on slugs that happen to start with tilde (valid in Sentry slugs). + * Shell expansion of `~/foo` happens before the CLI sees the argument, so a literal + * `~/foo` reaching this function means the shell didn't expand it (e.g., it was quoted). + * + * @param arg - CLI argument string to check + * @returns true if the string looks like a filesystem path + * + * @example + * looksLikePath(".") // true + * looksLikePath("./subdir") // true + * looksLikePath("../parent") // true + * looksLikePath("/absolute") // true + * looksLikePath("~/home") // true + * looksLikePath("~") // true + * looksLikePath("~foo") // false (could be a slug) + * looksLikePath("my-project") // false + * looksLikePath("acme/app") // false + */ +export function looksLikePath(arg: string): boolean { + return ( + arg === "." || + arg === "~" || + arg.startsWith("./") || + arg.startsWith("../") || + arg.startsWith("/") || + arg.startsWith("~/") + ); +} + // Argument swap detection for view commands // --------------------------------------------------------------------------- diff --git a/src/lib/init/local-ops.ts b/src/lib/init/local-ops.ts index 5944b62b..a68fe815 100644 --- a/src/lib/init/local-ops.ts +++ b/src/lib/init/local-ops.ts @@ -11,6 +11,7 @@ import path from "node:path"; import { isCancel, select } from "@clack/prompts"; import { createProject, + getProject, listOrganizations, tryGetPrimaryDsn, } from "../api-client.js"; @@ -702,11 +703,45 @@ async function resolveOrgSlug( return selected; } +/** + * Try to fetch an existing project by org + slug. Returns a successful + * LocalOpResult if the project exists, or null if it doesn't (404). + * Other errors are left to propagate. + */ +async function tryGetExistingProject( + orgSlug: string, + projectSlug: string +): Promise { + try { + const project = await getProject(orgSlug, projectSlug); + const dsn = await tryGetPrimaryDsn(orgSlug, project.slug); + const url = buildProjectUrl(orgSlug, project.slug); + return { + ok: true, + data: { + orgSlug, + projectSlug: project.slug, + projectId: project.id, + dsn: dsn ?? "", + url, + }, + }; + } catch (error) { + // 404 means project doesn't exist — fall through to creation + if (error instanceof ApiError && error.status === 404) { + return null; + } + throw error; + } +} + async function createSentryProject( payload: CreateSentryProjectPayload, options: WizardOptions ): Promise { - const { name, platform } = payload.params; + // Use CLI-provided project name if available, otherwise use wizard-detected name + const name = options.project ?? payload.params.name; + const { platform } = payload.params; const slug = slugify(name); if (!slug) { return { @@ -720,7 +755,7 @@ async function createSentryProject( return { ok: true, data: { - orgSlug: "(dry-run)", + orgSlug: options.org ?? "(dry-run)", projectSlug: slug, projectId: "(dry-run)", dsn: "(dry-run)", @@ -730,30 +765,45 @@ async function createSentryProject( } try { - // 1. Resolve org - const orgResult = await resolveOrgSlug(payload.cwd, options.yes); - if (typeof orgResult !== "string") { - return orgResult; + // 1. Resolve org — skip interactive resolution if explicitly provided via CLI arg + let orgSlug: string; + if (options.org) { + orgSlug = options.org; + } else { + const orgResult = await resolveOrgSlug(payload.cwd, options.yes); + if (typeof orgResult !== "string") { + return orgResult; + } + orgSlug = orgResult; } - const orgSlug = orgResult; - // 2. Resolve or create team + // 2. If both org and project were provided, check if the project already exists. + // This avoids a 409 Conflict from the create API when re-running init on an + // existing Sentry project (e.g., bare slug resolved via resolveProjectBySlug). + if (options.org && options.project) { + const existing = await tryGetExistingProject(orgSlug, slug); + if (existing) { + return existing; + } + } + + // 3. Resolve or create team const team = await resolveOrCreateTeam(orgSlug, { team: options.team, autoCreateSlug: slug, usageHint: "sentry init", }); - // 3. Create project + // 4. Create project const project = await createProject(orgSlug, team.slug, { name, platform, }); - // 4. Get DSN (best-effort) + // 5. Get DSN (best-effort) const dsn = await tryGetPrimaryDsn(orgSlug, project.slug); - // 5. Build URL + // 6. Build URL const url = buildProjectUrl(orgSlug, project.slug); return { @@ -767,14 +817,17 @@ async function createSentryProject( }, }; } catch (error) { - let message: string; - if (error instanceof ApiError) { - message = error.format(); - } else if (error instanceof Error) { - message = error.message; - } else { - message = String(error); - } - return { ok: false, error: message }; + return { ok: false, error: formatLocalOpError(error) }; + } +} + +/** Format an error from a local-op into a user-facing message string. */ +function formatLocalOpError(error: unknown): string { + if (error instanceof ApiError) { + return error.format(); + } + if (error instanceof Error) { + return error.message; } + return String(error); } diff --git a/src/lib/init/types.ts b/src/lib/init/types.ts index a7e425e9..3cb0fe2f 100644 --- a/src/lib/init/types.ts +++ b/src/lib/init/types.ts @@ -11,6 +11,10 @@ export type WizardOptions = { features?: string[]; /** Explicit team slug to create the project under. Skips team resolution. */ team?: string; + /** Explicit org slug from CLI arg (e.g., "acme" from "acme/my-app"). Skips interactive org selection. */ + org?: string; + /** Explicit project name from CLI arg (e.g., "my-app" from "acme/my-app"). Overrides wizard-detected name. */ + project?: string; }; // Local-op suspend payloads diff --git a/test/commands/init.test.ts b/test/commands/init.test.ts index 374cd2b9..6b16fbb2 100644 --- a/test/commands/init.test.ts +++ b/test/commands/init.test.ts @@ -1,21 +1,26 @@ /** * Tests for the `sentry init` command entry point. * - * Uses spyOn on the wizard-runner namespace to capture runWizard calls - * without mock.module (which leaks across test files). + * Uses spyOn on the wizard-runner and resolve-target namespaces to + * capture runWizard calls and mock resolveProjectBySlug without + * mock.module (which leaks across test files). */ import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test"; import path from "node:path"; import { initCommand } from "../../src/commands/init.js"; +import { ContextError } from "../../src/lib/errors.js"; // biome-ignore lint/performance/noNamespaceImport: spyOn requires object reference import * as wizardRunner from "../../src/lib/init/wizard-runner.js"; +// biome-ignore lint/performance/noNamespaceImport: spyOn requires object reference +import * as resolveTarget from "../../src/lib/resolve-target.js"; -// ── Spy on runWizard to capture call args ───────────────────────────────── +// ── Spy setup ───────────────────────────────────────────────────────────── let capturedArgs: Record | undefined; let runWizardSpy: ReturnType; +let resolveProjectSpy: ReturnType; -const func = (await initCommand.loader()) as ( +const func = (await initCommand.loader()) as unknown as ( this: { cwd: string; stdout: { write: () => boolean }; @@ -23,7 +28,8 @@ const func = (await initCommand.loader()) as ( stdin: typeof process.stdin; }, flags: Record, - directory?: string + first?: string, + second?: string ) => Promise; function makeContext(cwd = "/projects/app") { @@ -35,6 +41,8 @@ function makeContext(cwd = "/projects/app") { }; } +const DEFAULT_FLAGS = { yes: true, "dry-run": false } as const; + beforeEach(() => { capturedArgs = undefined; runWizardSpy = spyOn(wizardRunner, "runWizard").mockImplementation( @@ -43,129 +51,277 @@ beforeEach(() => { return Promise.resolve(); } ); + // Default: mock resolveProjectBySlug to return a match + resolveProjectSpy = spyOn( + resolveTarget, + "resolveProjectBySlug" + ).mockImplementation(async (slug: string) => ({ + org: "resolved-org", + project: slug, + })); }); afterEach(() => { runWizardSpy.mockRestore(); + resolveProjectSpy.mockRestore(); }); describe("init command func", () => { + // ── Features parsing ────────────────────────────────────────────────── + describe("features parsing", () => { test("splits comma-separated features", async () => { const ctx = makeContext(); await func.call(ctx, { - yes: true, - "dry-run": false, + ...DEFAULT_FLAGS, features: ["errors,tracing,logs"], }); - expect(capturedArgs?.features).toEqual(["errors", "tracing", "logs"]); }); test("splits plus-separated features", async () => { const ctx = makeContext(); await func.call(ctx, { - yes: true, - "dry-run": false, + ...DEFAULT_FLAGS, features: ["errors+tracing+logs"], }); - expect(capturedArgs?.features).toEqual(["errors", "tracing", "logs"]); }); test("splits space-separated features", async () => { const ctx = makeContext(); await func.call(ctx, { - yes: true, - "dry-run": false, + ...DEFAULT_FLAGS, features: ["errors tracing logs"], }); - expect(capturedArgs?.features).toEqual(["errors", "tracing", "logs"]); }); test("merges multiple --features flags", async () => { const ctx = makeContext(); await func.call(ctx, { - yes: true, - "dry-run": false, + ...DEFAULT_FLAGS, features: ["errors,tracing", "logs"], }); - expect(capturedArgs?.features).toEqual(["errors", "tracing", "logs"]); }); test("trims whitespace from features", async () => { const ctx = makeContext(); await func.call(ctx, { - yes: true, - "dry-run": false, + ...DEFAULT_FLAGS, features: [" errors , tracing "], }); - expect(capturedArgs?.features).toEqual(["errors", "tracing"]); }); test("filters empty segments", async () => { const ctx = makeContext(); await func.call(ctx, { - yes: true, - "dry-run": false, + ...DEFAULT_FLAGS, features: ["errors,,tracing,"], }); - expect(capturedArgs?.features).toEqual(["errors", "tracing"]); }); test("passes undefined when features not provided", async () => { const ctx = makeContext(); - await func.call(ctx, { - yes: true, - "dry-run": false, - }); - + await func.call(ctx, DEFAULT_FLAGS); expect(capturedArgs?.features).toBeUndefined(); }); }); - describe("directory resolution", () => { - test("defaults to cwd when no directory provided", async () => { + // ── No arguments ────────────────────────────────────────────────────── + + describe("no arguments", () => { + test("defaults to cwd with auto-detect", async () => { const ctx = makeContext("/projects/app"); - await func.call(ctx, { - yes: true, - "dry-run": false, - }); + await func.call(ctx, DEFAULT_FLAGS); + expect(capturedArgs?.directory).toBe("/projects/app"); + expect(capturedArgs?.org).toBeUndefined(); + expect(capturedArgs?.project).toBeUndefined(); + }); + }); + + // ── Single path argument ────────────────────────────────────────────── + describe("single path argument", () => { + test(". resolves to cwd", async () => { + const ctx = makeContext("/projects/app"); + await func.call(ctx, DEFAULT_FLAGS, "."); + expect(capturedArgs?.directory).toBe(path.resolve("/projects/app", ".")); + expect(capturedArgs?.org).toBeUndefined(); + expect(capturedArgs?.project).toBeUndefined(); + }); + + test("./subdir resolves relative to cwd", async () => { + const ctx = makeContext("/projects/app"); + await func.call(ctx, DEFAULT_FLAGS, "./subdir"); + expect(capturedArgs?.directory).toBe( + path.resolve("/projects/app", "./subdir") + ); + expect(capturedArgs?.org).toBeUndefined(); + }); + + test("../other resolves relative to cwd", async () => { + const ctx = makeContext("/projects/app"); + await func.call(ctx, DEFAULT_FLAGS, "../other"); + expect(capturedArgs?.directory).toBe( + path.resolve("/projects/app", "../other") + ); + expect(capturedArgs?.org).toBeUndefined(); + }); + + test("/absolute/path used as-is", async () => { + const ctx = makeContext("/projects/app"); + await func.call(ctx, DEFAULT_FLAGS, "/absolute/path"); + expect(capturedArgs?.directory).toBe("/absolute/path"); + expect(capturedArgs?.org).toBeUndefined(); + }); + + test("~/path treated as literal path (no shell expansion)", async () => { + const ctx = makeContext("/projects/app"); + await func.call(ctx, DEFAULT_FLAGS, "~/projects/other"); + expect(capturedArgs?.directory).toBe( + path.resolve("/projects/app", "~/projects/other") + ); + expect(capturedArgs?.org).toBeUndefined(); + }); + }); + + // ── Single target argument ──────────────────────────────────────────── + + describe("single target argument", () => { + test("org/ sets explicit org, dir = cwd", async () => { + const ctx = makeContext("/projects/app"); + await func.call(ctx, DEFAULT_FLAGS, "acme/"); + expect(capturedArgs?.org).toBe("acme"); + expect(capturedArgs?.project).toBeUndefined(); expect(capturedArgs?.directory).toBe("/projects/app"); }); - test("resolves relative directory against cwd", async () => { + test("org/project sets both, dir = cwd", async () => { const ctx = makeContext("/projects/app"); - await func.call( - ctx, - { - yes: true, - "dry-run": false, - }, - "sub/dir" + await func.call(ctx, DEFAULT_FLAGS, "acme/my-app"); + expect(capturedArgs?.org).toBe("acme"); + expect(capturedArgs?.project).toBe("my-app"); + expect(capturedArgs?.directory).toBe("/projects/app"); + }); + + test("bare slug resolves project via resolveProjectBySlug", async () => { + const ctx = makeContext("/projects/app"); + await func.call(ctx, DEFAULT_FLAGS, "my-app"); + expect(resolveProjectSpy).toHaveBeenCalledWith( + "my-app", + expect.any(String), + expect.any(String) ); + expect(capturedArgs?.org).toBe("resolved-org"); + expect(capturedArgs?.project).toBe("my-app"); + expect(capturedArgs?.directory).toBe("/projects/app"); + }); + }); + + // ── Two arguments: target + directory ───────────────────────────────── + + describe("two arguments (target + directory)", () => { + test("org/ + path", async () => { + const ctx = makeContext("/projects/app"); + await func.call(ctx, DEFAULT_FLAGS, "acme/", "./subdir"); + expect(capturedArgs?.org).toBe("acme"); + expect(capturedArgs?.project).toBeUndefined(); + expect(capturedArgs?.directory).toBe( + path.resolve("/projects/app", "./subdir") + ); + }); + + test("org/project + path", async () => { + const ctx = makeContext("/projects/app"); + await func.call(ctx, DEFAULT_FLAGS, "acme/my-app", "./subdir"); + expect(capturedArgs?.org).toBe("acme"); + expect(capturedArgs?.project).toBe("my-app"); + expect(capturedArgs?.directory).toBe( + path.resolve("/projects/app", "./subdir") + ); + }); + + test("bare slug + path", async () => { + const ctx = makeContext("/projects/app"); + await func.call(ctx, DEFAULT_FLAGS, "my-app", "./subdir"); + expect(resolveProjectSpy).toHaveBeenCalled(); + expect(capturedArgs?.org).toBe("resolved-org"); + expect(capturedArgs?.project).toBe("my-app"); + expect(capturedArgs?.directory).toBe( + path.resolve("/projects/app", "./subdir") + ); + }); + }); + + // ── Swapped arguments ───────────────────────────────────────────────── + + describe("swapped arguments (path first, target second)", () => { + test(". org/ swaps with warning", async () => { + const ctx = makeContext("/projects/app"); + await func.call(ctx, DEFAULT_FLAGS, ".", "acme/"); + expect(capturedArgs?.org).toBe("acme"); + expect(capturedArgs?.project).toBeUndefined(); + expect(capturedArgs?.directory).toBe(path.resolve("/projects/app", ".")); + }); + test("./subdir org/project swaps with warning", async () => { + const ctx = makeContext("/projects/app"); + await func.call(ctx, DEFAULT_FLAGS, "./subdir", "acme/my-app"); + expect(capturedArgs?.org).toBe("acme"); + expect(capturedArgs?.project).toBe("my-app"); expect(capturedArgs?.directory).toBe( - path.resolve("/projects/app", "sub/dir") + path.resolve("/projects/app", "./subdir") + ); + }); + }); + + // ── Error cases ─────────────────────────────────────────────────────── + + describe("error cases", () => { + test("two paths throws ContextError", async () => { + const ctx = makeContext(); + expect(func.call(ctx, DEFAULT_FLAGS, "./dir1", "./dir2")).rejects.toThrow( + ContextError + ); + }); + + test("two targets throws ContextError", async () => { + const ctx = makeContext(); + expect(func.call(ctx, DEFAULT_FLAGS, "acme/", "other/")).rejects.toThrow( + ContextError ); }); + + test("invalid org slug (whitespace) throws", async () => { + const ctx = makeContext(); + expect(func.call(ctx, DEFAULT_FLAGS, "acme corp/")).rejects.toThrow(); + }); }); + // ── Flag forwarding ─────────────────────────────────────────────────── + describe("flag forwarding", () => { test("forwards yes and dry-run flags", async () => { const ctx = makeContext(); - await func.call(ctx, { - yes: true, - "dry-run": true, - }); - + await func.call(ctx, { yes: true, "dry-run": true }); expect(capturedArgs?.yes).toBe(true); expect(capturedArgs?.dryRun).toBe(true); }); + + test("forwards team flag alongside org/project", async () => { + const ctx = makeContext(); + await func.call( + ctx, + { ...DEFAULT_FLAGS, team: "backend" }, + "acme/my-app" + ); + expect(capturedArgs?.org).toBe("acme"); + expect(capturedArgs?.project).toBe("my-app"); + expect(capturedArgs?.team).toBe("backend"); + }); }); });