Skip to content

Commit ff76443

Browse files
feat(init): support org/project positional to pin org and project name (#428)
## Summary Redesign `sentry init` to support smart positional arguments for org/project targeting and directory specification. ### Supported forms ``` 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 → sets org + project 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 sentry init ./subdir acme/my-app # swapped → auto-correct with warning ``` ### Design Two optional positionals with smart disambiguation: - **Path-like args** (starting with `.` `/` `~` `~/`) are treated as the **directory** - **Everything else** is treated as the **org/project target** - When args are in wrong order (path first, target second), they are **auto-swapped with a warning** — following the established swap pattern from view commands - **Bare slugs** (e.g., `my-app`) are resolved via `resolveProjectBySlug` to search across all accessible orgs, setting both org and project from the match - **Two paths** or **two targets** → error with helpful message - User-provided org/project slugs are validated via `validateResourceId` at parse time; API-resolved values from `resolveProjectBySlug` skip redundant validation ### Files changed | File | Change | |---|---| | `src/lib/arg-parsing.ts` | Add `looksLikePath()` for syntactic path detection (no filesystem I/O) | | `src/commands/init.ts` | Two positionals, `classifyArgs()` disambiguation, `resolveTarget()` with `resolveProjectBySlug` for bare slugs, `validateResourceId` on user-provided slugs only | | `src/lib/init/types.ts` | Add `org?` and `project?` to `WizardOptions` | | `src/lib/init/local-ops.ts` | Use `options.org`/`options.project` in `createSentryProject()` to skip interactive org resolution and override wizard-detected project name | | `test/commands/init.test.ts` | 26 tests covering all arg combinations, swap detection, error cases | ### Breaking change `sentry init <directory>` no longer works for bare directory names (e.g., `sentry init mydir`). Use path syntax: `sentry init ./mydir`. --------- Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
1 parent 94c3727 commit ff76443

6 files changed

Lines changed: 487 additions & 73 deletions

File tree

plugins/sentry-cli/skills/sentry-cli/SKILL.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -727,7 +727,7 @@ Start a product trial
727727

728728
Initialize Sentry in your project
729729

730-
#### `sentry init <directory>`
730+
#### `sentry init <target> <directory>`
731731

732732
Initialize Sentry in your project
733733

src/commands/init.ts

Lines changed: 159 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,33 +4,165 @@
44
* Initialize Sentry in a project using the remote wizard workflow.
55
* Communicates with the Mastra API via suspend/resume to perform
66
* local filesystem operations and interactive prompts.
7+
*
8+
* Supports two optional positionals with smart disambiguation:
9+
* sentry init — auto-detect everything, dir = cwd
10+
* sentry init . — dir = cwd, auto-detect org
11+
* sentry init ./subdir — dir = subdir, auto-detect org
12+
* sentry init acme/ — explicit org, dir = cwd
13+
* sentry init acme/my-app — explicit org + project, dir = cwd
14+
* sentry init my-app — search for project across orgs
15+
* sentry init acme/ ./subdir — explicit org, dir = subdir
16+
* sentry init acme/my-app ./subdir — explicit org + project, dir = subdir
17+
* sentry init ./subdir acme/ — swapped, auto-correct with warning
718
*/
819

920
import path from "node:path";
1021
import type { SentryContext } from "../context.js";
22+
import { looksLikePath, parseOrgProjectArg } from "../lib/arg-parsing.js";
1123
import { buildCommand } from "../lib/command.js";
24+
import { ContextError } from "../lib/errors.js";
1225
import { runWizard } from "../lib/init/wizard-runner.js";
26+
import { validateResourceId } from "../lib/input-validation.js";
27+
import { logger } from "../lib/logger.js";
28+
import { resolveProjectBySlug } from "../lib/resolve-target.js";
29+
30+
const log = logger.withTag("init");
1331

1432
const FEATURE_DELIMITER = /[,+ ]+/;
1533

34+
const USAGE_HINT = "sentry init <org>/<project> [directory]";
35+
1636
type InitFlags = {
1737
readonly yes: boolean;
1838
readonly "dry-run": boolean;
1939
readonly features?: string[];
2040
readonly team?: string;
2141
};
2242

23-
export const initCommand = buildCommand<InitFlags, [string?], SentryContext>({
43+
/**
44+
* Classify and separate two optional positional args into a target and a directory.
45+
*
46+
* Uses {@link looksLikePath} to distinguish filesystem paths from org/project targets.
47+
* Detects swapped arguments and emits a warning when auto-correcting.
48+
*
49+
* @returns Resolved target string (or undefined) and directory string (or undefined)
50+
*/
51+
function classifyArgs(
52+
first?: string,
53+
second?: string
54+
): { target: string | undefined; directory: string | undefined } {
55+
// No args — auto-detect everything
56+
if (!first) {
57+
return { target: undefined, directory: undefined };
58+
}
59+
60+
const firstIsPath = looksLikePath(first);
61+
62+
// Single arg
63+
if (!second) {
64+
return firstIsPath
65+
? { target: undefined, directory: first }
66+
: { target: first, directory: undefined };
67+
}
68+
69+
const secondIsPath = looksLikePath(second);
70+
71+
// Two paths → error
72+
if (firstIsPath && secondIsPath) {
73+
throw new ContextError("Arguments", USAGE_HINT, [
74+
"Two directory paths provided. Only one directory is allowed.",
75+
]);
76+
}
77+
78+
// Two targets → error
79+
if (!(firstIsPath || secondIsPath)) {
80+
throw new ContextError("Arguments", USAGE_HINT, [
81+
"Two targets provided. Use <org>/<project> for the target and a path (e.g., ./dir) for the directory.",
82+
]);
83+
}
84+
85+
// (TARGET, PATH) — correct order
86+
if (!firstIsPath && secondIsPath) {
87+
return { target: first, directory: second };
88+
}
89+
90+
// (PATH, TARGET) — swapped, auto-correct with warning
91+
log.warn(`Arguments appear reversed. Interpreting as: ${second} ${first}`);
92+
return { target: second, directory: first };
93+
}
94+
95+
/**
96+
* Resolve the parsed org/project target into explicit org and project values.
97+
*
98+
* For `project-search` (bare slug), calls {@link resolveProjectBySlug} to search
99+
* across all accessible orgs and determine both org and project from the match.
100+
*/
101+
async function resolveTarget(targetArg: string | undefined): Promise<{
102+
org: string | undefined;
103+
project: string | undefined;
104+
}> {
105+
const parsed = parseOrgProjectArg(targetArg);
106+
107+
switch (parsed.type) {
108+
case "explicit":
109+
// Validate user-provided slugs before they reach API calls
110+
validateResourceId(parsed.org, "organization slug");
111+
validateResourceId(parsed.project, "project name");
112+
return { org: parsed.org, project: parsed.project };
113+
case "org-all":
114+
validateResourceId(parsed.org, "organization slug");
115+
return { org: parsed.org, project: undefined };
116+
case "project-search": {
117+
// Bare slug — search for a project with this name across all orgs.
118+
// resolveProjectBySlug handles not-found, ambiguity, and org-name-collision errors.
119+
const resolved = await resolveProjectBySlug(
120+
parsed.projectSlug,
121+
USAGE_HINT,
122+
`sentry init ${parsed.projectSlug}/ (if '${parsed.projectSlug}' is an org)`
123+
);
124+
return { org: resolved.org, project: resolved.project };
125+
}
126+
case "auto-detect":
127+
return { org: undefined, project: undefined };
128+
default: {
129+
const _exhaustive: never = parsed;
130+
throw new ContextError("Target", String(_exhaustive));
131+
}
132+
}
133+
}
134+
135+
export const initCommand = buildCommand<
136+
InitFlags,
137+
[string?, string?],
138+
SentryContext
139+
>({
24140
docs: {
25141
brief: "Initialize Sentry in your project",
26142
fullDescription:
27143
"Runs the Sentry setup wizard to detect your project's framework, " +
28-
"install the SDK, and configure Sentry.",
144+
"install the SDK, and configure Sentry.\n\n" +
145+
"Supports org/project syntax and a directory positional. Path-like\n" +
146+
"arguments (starting with . / ~) are treated as the directory;\n" +
147+
"everything else is treated as the target.\n\n" +
148+
"Examples:\n" +
149+
" sentry init\n" +
150+
" sentry init acme/\n" +
151+
" sentry init acme/my-app\n" +
152+
" sentry init my-app\n" +
153+
" sentry init acme/my-app ./my-project\n" +
154+
" sentry init ./my-project",
29155
},
30156
parameters: {
31157
positional: {
32158
kind: "tuple",
33159
parameters: [
160+
{
161+
placeholder: "target",
162+
brief: "<org>/<project>, <org>/, <project>, or a directory path",
163+
parse: String,
164+
optional: true,
165+
},
34166
{
35167
placeholder: "directory",
36168
brief: "Project directory (default: current directory)",
@@ -69,19 +201,42 @@ export const initCommand = buildCommand<InitFlags, [string?], SentryContext>({
69201
t: "team",
70202
},
71203
},
72-
async *func(this: SentryContext, flags: InitFlags, directory?: string) {
73-
const targetDir = directory ? path.resolve(this.cwd, directory) : this.cwd;
204+
async *func(
205+
this: SentryContext,
206+
flags: InitFlags,
207+
first?: string,
208+
second?: string
209+
) {
210+
// 1. Classify positionals into target vs directory
211+
const { target: targetArg, directory: dirArg } = classifyArgs(
212+
first,
213+
second
214+
);
215+
216+
// 2. Resolve directory
217+
const targetDir = dirArg ? path.resolve(this.cwd, dirArg) : this.cwd;
218+
219+
// 3. Parse features
74220
const featuresList = flags.features
75221
?.flatMap((f) => f.split(FEATURE_DELIMITER))
76222
.map((f) => f.trim())
77223
.filter(Boolean);
78224

225+
// 4. Resolve target → org + project
226+
// Validation of user-provided slugs happens inside resolveTarget.
227+
// API-resolved values (from resolveProjectBySlug) are already valid.
228+
const { org: explicitOrg, project: explicitProject } =
229+
await resolveTarget(targetArg);
230+
231+
// 5. Run the wizard
79232
await runWizard({
80233
directory: targetDir,
81234
yes: flags.yes,
82235
dryRun: flags["dry-run"],
83236
features: featuresList,
84237
team: flags.team,
238+
org: explicitOrg,
239+
project: explicitProject,
85240
});
86241
},
87242
});

src/lib/arg-parsing.ts

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,52 @@ export function looksLikeIssueShortId(str: string): boolean {
110110
}
111111

112112
// ---------------------------------------------------------------------------
113+
// Path detection
114+
// ---------------------------------------------------------------------------
115+
116+
/**
117+
* Check if a string looks like a filesystem path rather than a slug/identifier.
118+
*
119+
* Uses purely syntactic checks — no filesystem I/O. Detects:
120+
* - `.` (current directory)
121+
* - `./foo`, `../foo` (relative paths)
122+
* - `/foo` (absolute paths)
123+
* - `~` or `~/foo` (home directory paths)
124+
*
125+
* Bare names like `my-org` or `my-project` never match, which is what makes
126+
* this useful for disambiguating positional arguments that could be either
127+
* a filesystem path or an org/project target.
128+
*
129+
* Note: `~` is only matched as `~` alone or `~/...`, not `~foo`. This avoids
130+
* false positives on slugs that happen to start with tilde (valid in Sentry slugs).
131+
* Shell expansion of `~/foo` happens before the CLI sees the argument, so a literal
132+
* `~/foo` reaching this function means the shell didn't expand it (e.g., it was quoted).
133+
*
134+
* @param arg - CLI argument string to check
135+
* @returns true if the string looks like a filesystem path
136+
*
137+
* @example
138+
* looksLikePath(".") // true
139+
* looksLikePath("./subdir") // true
140+
* looksLikePath("../parent") // true
141+
* looksLikePath("/absolute") // true
142+
* looksLikePath("~/home") // true
143+
* looksLikePath("~") // true
144+
* looksLikePath("~foo") // false (could be a slug)
145+
* looksLikePath("my-project") // false
146+
* looksLikePath("acme/app") // false
147+
*/
148+
export function looksLikePath(arg: string): boolean {
149+
return (
150+
arg === "." ||
151+
arg === "~" ||
152+
arg.startsWith("./") ||
153+
arg.startsWith("../") ||
154+
arg.startsWith("/") ||
155+
arg.startsWith("~/")
156+
);
157+
}
158+
113159
// Argument swap detection for view commands
114160
// ---------------------------------------------------------------------------
115161

0 commit comments

Comments
 (0)