Skip to content

Commit 12151fd

Browse files
feat(init): two positionals with smart path/target disambiguation
Redesign init command to accept two optional positionals: sentry init [target] [directory] Path-like args (starting with . / ~) are treated as the directory; everything else is treated as the org/project target. When args are in the 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., 'sentry init my-app') are resolved via resolveProjectBySlug to search across all accessible orgs, setting both org and project from the match. Add looksLikePath() to arg-parsing.ts for syntactic path detection (no filesystem I/O). Remove the --directory flag in favor of the second positional.
1 parent 0acb1f6 commit 12151fd

File tree

3 files changed

+354
-195
lines changed

3 files changed

+354
-195
lines changed

src/commands/init.ts

Lines changed: 143 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -5,53 +5,163 @@
55
* Communicates with the Mastra API via suspend/resume to perform
66
* local filesystem operations and interactive prompts.
77
*
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
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
1318
*/
1419

1520
import path from "node:path";
1621
import type { SentryContext } from "../context.js";
17-
import { parseOrgProjectArg } from "../lib/arg-parsing.js";
22+
import { looksLikePath, parseOrgProjectArg } from "../lib/arg-parsing.js";
1823
import { buildCommand } from "../lib/command.js";
1924
import { ContextError } from "../lib/errors.js";
2025
import { runWizard } from "../lib/init/wizard-runner.js";
2126
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");
2231

2332
const FEATURE_DELIMITER = /[,+ ]+/;
2433

34+
const USAGE_HINT = "sentry init <org>/<project> [directory]";
35+
2536
type InitFlags = {
2637
readonly yes: boolean;
2738
readonly "dry-run": boolean;
2839
readonly features?: string[];
2940
readonly team?: string;
30-
readonly directory?: string;
3141
};
3242

33-
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+
return { org: parsed.org, project: parsed.project };
110+
case "org-all":
111+
return { org: parsed.org, project: undefined };
112+
case "project-search": {
113+
// Bare slug — search for a project with this name across all orgs.
114+
// resolveProjectBySlug handles not-found, ambiguity, and org-name-collision errors.
115+
const resolved = await resolveProjectBySlug(
116+
parsed.projectSlug,
117+
USAGE_HINT,
118+
`sentry init ${parsed.projectSlug}/ (if '${parsed.projectSlug}' is an org)`
119+
);
120+
return { org: resolved.org, project: resolved.project };
121+
}
122+
case "auto-detect":
123+
return { org: undefined, project: undefined };
124+
default: {
125+
const _exhaustive: never = parsed;
126+
throw new ContextError("Target", String(_exhaustive));
127+
}
128+
}
129+
}
130+
131+
export const initCommand = buildCommand<
132+
InitFlags,
133+
[string?, string?],
134+
SentryContext
135+
>({
34136
docs: {
35137
brief: "Initialize Sentry in your project",
36138
fullDescription:
37139
"Runs the Sentry setup wizard to detect your project's framework, " +
38140
"install the SDK, and configure Sentry.\n\n" +
39-
"The target supports org/project syntax to specify context explicitly.\n" +
40-
"If omitted, the org is auto-detected from config defaults.\n\n" +
141+
"Supports org/project syntax and a directory positional. Path-like\n" +
142+
"arguments (starting with . / ~) are treated as the directory;\n" +
143+
"everything else is treated as the target.\n\n" +
41144
"Examples:\n" +
42145
" sentry init\n" +
43146
" sentry init acme/\n" +
44147
" sentry init acme/my-app\n" +
45-
" sentry init acme/my-app --directory ./my-project\n" +
46-
" sentry init --directory ./my-project",
148+
" sentry init my-app\n" +
149+
" sentry init acme/my-app ./my-project\n" +
150+
" sentry init ./my-project",
47151
},
48152
parameters: {
49153
positional: {
50154
kind: "tuple",
51155
parameters: [
52156
{
53157
placeholder: "target",
54-
brief: "<org>/<project>, <org>/, or omit for auto-detect",
158+
brief: "<org>/<project>, <org>/, <project>, or a directory path",
159+
parse: String,
160+
optional: true,
161+
},
162+
{
163+
placeholder: "directory",
164+
brief: "Project directory (default: current directory)",
55165
parse: String,
56166
optional: true,
57167
},
@@ -81,66 +191,46 @@ export const initCommand = buildCommand<InitFlags, [string?], SentryContext>({
81191
brief: "Team slug to create the project under",
82192
optional: true,
83193
},
84-
directory: {
85-
kind: "parsed",
86-
parse: String,
87-
brief: "Project directory (default: current directory)",
88-
optional: true,
89-
},
90194
},
91195
aliases: {
92196
y: "yes",
93197
t: "team",
94-
d: "directory",
95198
},
96199
},
97-
async *func(this: SentryContext, flags: InitFlags, targetArg?: string) {
98-
const targetDir = flags.directory
99-
? path.resolve(this.cwd, flags.directory)
100-
: this.cwd;
200+
async *func(
201+
this: SentryContext,
202+
flags: InitFlags,
203+
first?: string,
204+
second?: string
205+
) {
206+
// 1. Classify positionals into target vs directory
207+
const { target: targetArg, directory: dirArg } = classifyArgs(
208+
first,
209+
second
210+
);
211+
212+
// 2. Resolve directory
213+
const targetDir = dirArg ? path.resolve(this.cwd, dirArg) : this.cwd;
101214

215+
// 3. Parse features
102216
const featuresList = flags.features
103217
?.flatMap((f) => f.split(FEATURE_DELIMITER))
104218
.map((f) => f.trim())
105219
.filter(Boolean);
106220

107-
// Parse the target arg to extract org and/or project
108-
const parsed = parseOrgProjectArg(targetArg);
109-
110-
let explicitOrg: string | undefined;
111-
let explicitProject: string | undefined;
112-
113-
switch (parsed.type) {
114-
case "explicit":
115-
explicitOrg = parsed.org;
116-
explicitProject = parsed.project;
117-
break;
118-
case "org-all":
119-
explicitOrg = parsed.org;
120-
break;
121-
case "project-search":
122-
// Bare string without "/" is ambiguous — could be an org or project slug.
123-
// Require the trailing slash to disambiguate (consistent with other commands).
124-
throw new ContextError("Target", `sentry init ${parsed.projectSlug}/`, [
125-
`'${parsed.projectSlug}' is ambiguous. Use '${parsed.projectSlug}/' for org or '${parsed.projectSlug}/<project>' for org + project.`,
126-
]);
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-
}
221+
// 4. Resolve target → org + project
222+
const { org: explicitOrg, project: explicitProject } =
223+
await resolveTarget(targetArg);
135224

136-
// Validate explicit org slug format before passing to API calls
225+
// 5. Validate explicit slugs before passing to API calls
137226
if (explicitOrg) {
138227
validateResourceId(explicitOrg, "organization slug");
139228
}
140229
if (explicitProject) {
141230
validateResourceId(explicitProject, "project name");
142231
}
143232

233+
// 6. Run the wizard
144234
await runWizard({
145235
directory: targetDir,
146236
yes: flags.yes,

src/lib/arg-parsing.ts

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,44 @@ export function looksLikeIssueShortId(str: string): boolean {
7979
}
8080

8181
// ---------------------------------------------------------------------------
82+
// Path detection
83+
// ---------------------------------------------------------------------------
84+
85+
/**
86+
* Check if a string looks like a filesystem path rather than a slug/identifier.
87+
*
88+
* Uses purely syntactic checks — no filesystem I/O. Detects:
89+
* - `.` (current directory)
90+
* - `./foo`, `../foo` (relative paths)
91+
* - `/foo` (absolute paths)
92+
* - `~/foo` (home directory paths)
93+
*
94+
* Bare names like `my-org` or `my-project` never match, which is what makes
95+
* this useful for disambiguating positional arguments that could be either
96+
* a filesystem path or an org/project target.
97+
*
98+
* @param arg - CLI argument string to check
99+
* @returns true if the string looks like a filesystem path
100+
*
101+
* @example
102+
* looksLikePath(".") // true
103+
* looksLikePath("./subdir") // true
104+
* looksLikePath("../parent") // true
105+
* looksLikePath("/absolute") // true
106+
* looksLikePath("~/home") // true
107+
* looksLikePath("my-project") // false
108+
* looksLikePath("acme/app") // false
109+
*/
110+
export function looksLikePath(arg: string): boolean {
111+
return (
112+
arg === "." ||
113+
arg.startsWith("./") ||
114+
arg.startsWith("../") ||
115+
arg.startsWith("/") ||
116+
arg.startsWith("~")
117+
);
118+
}
119+
82120
// Argument swap detection for view commands
83121
// ---------------------------------------------------------------------------
84122

0 commit comments

Comments
 (0)