Skip to content

Commit 0beee02

Browse files
feat(init): support org/project positional to pin org and project name
Replace the directory positional with an org/project target using parseOrgProjectArg. Directory is now a --directory/-d flag. Supported forms: sentry init — auto-detect everything sentry init acme — explicit org sentry init acme/my-app — explicit org + project name sentry init --directory ./dir — specify project directory When org is provided explicitly, createSentryProject skips interactive org resolution. When project name is provided, it overrides the wizard-detected name.
1 parent a3c6a91 commit 0beee02

File tree

4 files changed

+185
-24
lines changed

4 files changed

+185
-24
lines changed

src/commands/init.ts

Lines changed: 64 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,19 @@
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 org/project positional syntax to pin org and/or project name:
9+
* sentry init — auto-detect everything
10+
* sentry init acme — explicit org, wizard picks project name
11+
* sentry init acme/my-app — explicit org + project name override
12+
* sentry init --directory ./dir — specify project directory
713
*/
814

915
import path from "node:path";
1016
import type { SentryContext } from "../context.js";
17+
import { parseOrgProjectArg } from "../lib/arg-parsing.js";
1118
import { buildCommand } from "../lib/command.js";
19+
import { ContextError } from "../lib/errors.js";
1220
import { runWizard } from "../lib/init/wizard-runner.js";
1321

1422
const FEATURE_DELIMITER = /[,+ ]+/;
@@ -18,22 +26,31 @@ type InitFlags = {
1826
readonly "dry-run": boolean;
1927
readonly features?: string[];
2028
readonly team?: string;
29+
readonly directory?: string;
2130
};
2231

2332
export const initCommand = buildCommand<InitFlags, [string?], SentryContext>({
2433
docs: {
2534
brief: "Initialize Sentry in your project",
2635
fullDescription:
2736
"Runs the Sentry setup wizard to detect your project's framework, " +
28-
"install the SDK, and configure Sentry.",
37+
"install the SDK, and configure Sentry.\n\n" +
38+
"The target supports org/project syntax to specify context explicitly.\n" +
39+
"If omitted, the org is auto-detected from config defaults.\n\n" +
40+
"Examples:\n" +
41+
" sentry init\n" +
42+
" sentry init acme\n" +
43+
" sentry init acme/my-app\n" +
44+
" sentry init acme/my-app --directory ./my-project\n" +
45+
" sentry init --directory ./my-project",
2946
},
3047
parameters: {
3148
positional: {
3249
kind: "tuple",
3350
parameters: [
3451
{
35-
placeholder: "directory",
36-
brief: "Project directory (default: current directory)",
52+
placeholder: "target",
53+
brief: "<org>/<project>, <org>, or omit for auto-detect",
3754
parse: String,
3855
optional: true,
3956
},
@@ -63,25 +80,67 @@ export const initCommand = buildCommand<InitFlags, [string?], SentryContext>({
6380
brief: "Team slug to create the project under",
6481
optional: true,
6582
},
83+
directory: {
84+
kind: "parsed",
85+
parse: String,
86+
brief: "Project directory (default: current directory)",
87+
optional: true,
88+
},
6689
},
6790
aliases: {
6891
y: "yes",
6992
t: "team",
93+
d: "directory",
7094
},
7195
},
72-
async *func(this: SentryContext, flags: InitFlags, directory?: string) {
73-
const targetDir = directory ? path.resolve(this.cwd, directory) : this.cwd;
96+
async *func(this: SentryContext, flags: InitFlags, targetArg?: string) {
97+
const targetDir = flags.directory
98+
? path.resolve(this.cwd, flags.directory)
99+
: this.cwd;
100+
74101
const featuresList = flags.features
75102
?.flatMap((f) => f.split(FEATURE_DELIMITER))
76103
.map((f) => f.trim())
77104
.filter(Boolean);
78105

106+
// Parse the target arg to extract org and/or project
107+
const parsed = parseOrgProjectArg(targetArg);
108+
109+
let explicitOrg: string | undefined;
110+
let explicitProject: string | undefined;
111+
112+
switch (parsed.type) {
113+
case "explicit":
114+
explicitOrg = parsed.org;
115+
explicitProject = parsed.project;
116+
break;
117+
case "org-all":
118+
// "acme/" or bare "acme" — org only, no project name override
119+
explicitOrg = parsed.org;
120+
break;
121+
case "project-search":
122+
// Bare string without "/" — could be an org slug or a project name.
123+
// Treat it as an org slug since `sentry init <org>` is the primary use case.
124+
// Users who want to override the project name should use org/project syntax.
125+
explicitOrg = parsed.projectSlug;
126+
break;
127+
case "auto-detect":
128+
// No target provided — auto-detect everything
129+
break;
130+
default: {
131+
const _exhaustive: never = parsed;
132+
throw new ContextError("Target", String(_exhaustive));
133+
}
134+
}
135+
79136
await runWizard({
80137
directory: targetDir,
81138
yes: flags.yes,
82139
dryRun: flags["dry-run"],
83140
features: featuresList,
84141
team: flags.team,
142+
org: explicitOrg,
143+
project: explicitProject,
85144
});
86145
},
87146
});

src/lib/init/local-ops.ts

Lines changed: 14 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -706,7 +706,9 @@ async function createSentryProject(
706706
payload: CreateSentryProjectPayload,
707707
options: WizardOptions
708708
): Promise<LocalOpResult> {
709-
const { name, platform } = payload.params;
709+
// Use CLI-provided project name if available, otherwise use wizard-detected name
710+
const name = options.project ?? payload.params.name;
711+
const { platform } = payload.params;
710712
const slug = slugify(name);
711713
if (!slug) {
712714
return {
@@ -720,7 +722,7 @@ async function createSentryProject(
720722
return {
721723
ok: true,
722724
data: {
723-
orgSlug: "(dry-run)",
725+
orgSlug: options.org ?? "(dry-run)",
724726
projectSlug: slug,
725727
projectId: "(dry-run)",
726728
dsn: "(dry-run)",
@@ -730,12 +732,17 @@ async function createSentryProject(
730732
}
731733

732734
try {
733-
// 1. Resolve org
734-
const orgResult = await resolveOrgSlug(payload.cwd, options.yes);
735-
if (typeof orgResult !== "string") {
736-
return orgResult;
735+
// 1. Resolve org — skip interactive resolution if explicitly provided via CLI arg
736+
let orgSlug: string;
737+
if (options.org) {
738+
orgSlug = options.org;
739+
} else {
740+
const orgResult = await resolveOrgSlug(payload.cwd, options.yes);
741+
if (typeof orgResult !== "string") {
742+
return orgResult;
743+
}
744+
orgSlug = orgResult;
737745
}
738-
const orgSlug = orgResult;
739746

740747
// 2. Resolve or create team
741748
const team = await resolveOrCreateTeam(orgSlug, {

src/lib/init/types.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,10 @@ export type WizardOptions = {
1111
features?: string[];
1212
/** Explicit team slug to create the project under. Skips team resolution. */
1313
team?: string;
14+
/** Explicit org slug from CLI arg (e.g., "acme" from "acme/my-app"). Skips interactive org selection. */
15+
org?: string;
16+
/** Explicit project name from CLI arg (e.g., "my-app" from "acme/my-app"). Overrides wizard-detected name. */
17+
project?: string;
1418
};
1519

1620
// Local-op suspend payloads

test/commands/init.test.ts

Lines changed: 103 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -15,15 +15,15 @@ import * as wizardRunner from "../../src/lib/init/wizard-runner.js";
1515
let capturedArgs: Record<string, unknown> | undefined;
1616
let runWizardSpy: ReturnType<typeof spyOn>;
1717

18-
const func = (await initCommand.loader()) as (
18+
const func = (await initCommand.loader()) as unknown as (
1919
this: {
2020
cwd: string;
2121
stdout: { write: () => boolean };
2222
stderr: { write: () => boolean };
2323
stdin: typeof process.stdin;
2424
},
2525
flags: Record<string, unknown>,
26-
directory?: string
26+
target?: string
2727
) => Promise<void>;
2828

2929
function makeContext(cwd = "/projects/app") {
@@ -129,7 +129,7 @@ describe("init command func", () => {
129129
});
130130

131131
describe("directory resolution", () => {
132-
test("defaults to cwd when no directory provided", async () => {
132+
test("defaults to cwd when no --directory flag provided", async () => {
133133
const ctx = makeContext("/projects/app");
134134
await func.call(ctx, {
135135
yes: true,
@@ -139,16 +139,13 @@ describe("init command func", () => {
139139
expect(capturedArgs?.directory).toBe("/projects/app");
140140
});
141141

142-
test("resolves relative directory against cwd", async () => {
142+
test("resolves relative --directory flag against cwd", async () => {
143143
const ctx = makeContext("/projects/app");
144-
await func.call(
145-
ctx,
146-
{
147-
yes: true,
148-
"dry-run": false,
149-
},
150-
"sub/dir"
151-
);
144+
await func.call(ctx, {
145+
yes: true,
146+
"dry-run": false,
147+
directory: "sub/dir",
148+
});
152149

153150
expect(capturedArgs?.directory).toBe(
154151
path.resolve("/projects/app", "sub/dir")
@@ -168,4 +165,98 @@ describe("init command func", () => {
168165
expect(capturedArgs?.dryRun).toBe(true);
169166
});
170167
});
168+
169+
describe("org/project parsing", () => {
170+
test("passes undefined org/project when no target provided", async () => {
171+
const ctx = makeContext();
172+
await func.call(ctx, {
173+
yes: true,
174+
"dry-run": false,
175+
});
176+
177+
expect(capturedArgs?.org).toBeUndefined();
178+
expect(capturedArgs?.project).toBeUndefined();
179+
});
180+
181+
test("parses org/project from explicit target", async () => {
182+
const ctx = makeContext();
183+
await func.call(
184+
ctx,
185+
{
186+
yes: true,
187+
"dry-run": false,
188+
},
189+
"acme/my-app"
190+
);
191+
192+
expect(capturedArgs?.org).toBe("acme");
193+
expect(capturedArgs?.project).toBe("my-app");
194+
});
195+
196+
test("parses bare string as org (no project override)", async () => {
197+
const ctx = makeContext();
198+
await func.call(
199+
ctx,
200+
{
201+
yes: true,
202+
"dry-run": false,
203+
},
204+
"acme"
205+
);
206+
207+
expect(capturedArgs?.org).toBe("acme");
208+
expect(capturedArgs?.project).toBeUndefined();
209+
});
210+
211+
test("parses org/ as org-only (no project override)", async () => {
212+
const ctx = makeContext();
213+
await func.call(
214+
ctx,
215+
{
216+
yes: true,
217+
"dry-run": false,
218+
},
219+
"acme/"
220+
);
221+
222+
expect(capturedArgs?.org).toBe("acme");
223+
expect(capturedArgs?.project).toBeUndefined();
224+
});
225+
226+
test("combines target with --directory flag", async () => {
227+
const ctx = makeContext("/projects/app");
228+
await func.call(
229+
ctx,
230+
{
231+
yes: true,
232+
"dry-run": false,
233+
directory: "sub/dir",
234+
},
235+
"acme/my-app"
236+
);
237+
238+
expect(capturedArgs?.org).toBe("acme");
239+
expect(capturedArgs?.project).toBe("my-app");
240+
expect(capturedArgs?.directory).toBe(
241+
path.resolve("/projects/app", "sub/dir")
242+
);
243+
});
244+
245+
test("forwards team flag alongside org/project", async () => {
246+
const ctx = makeContext();
247+
await func.call(
248+
ctx,
249+
{
250+
yes: true,
251+
"dry-run": false,
252+
team: "backend",
253+
},
254+
"acme/my-app"
255+
);
256+
257+
expect(capturedArgs?.org).toBe("acme");
258+
expect(capturedArgs?.project).toBe("my-app");
259+
expect(capturedArgs?.team).toBe("backend");
260+
});
261+
});
171262
});

0 commit comments

Comments
 (0)