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
2 changes: 1 addition & 1 deletion plugins/sentry-cli/skills/sentry-cli/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -700,7 +700,7 @@ Start a product trial

Initialize Sentry in your project

#### `sentry init <directory>`
#### `sentry init <target> <directory>`

Initialize Sentry in your project

Expand Down
163 changes: 159 additions & 4 deletions src/commands/init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,33 +4,165 @@
* 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 <org>/<project> [directory]";

type InitFlags = {
readonly yes: boolean;
readonly "dry-run": boolean;
readonly features?: string[];
readonly team?: string;
};

export const initCommand = buildCommand<InitFlags, [string?], SentryContext>({
/**
* 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 <org>/<project> 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: "<org>/<project>, <org>/, <project>, or a directory path",
parse: String,
optional: true,
},
{
placeholder: "directory",
brief: "Project directory (default: current directory)",
Expand Down Expand Up @@ -69,19 +201,42 @@ export const initCommand = buildCommand<InitFlags, [string?], SentryContext>({
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,
});
},
});
46 changes: 46 additions & 0 deletions src/lib/arg-parsing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
// ---------------------------------------------------------------------------

Expand Down
Loading
Loading