|
5 | 5 | * Communicates with the Mastra API via suspend/resume to perform |
6 | 6 | * local filesystem operations and interactive prompts. |
7 | 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 |
| 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 |
13 | 18 | */ |
14 | 19 |
|
15 | 20 | import path from "node:path"; |
16 | 21 | import type { SentryContext } from "../context.js"; |
17 | | -import { parseOrgProjectArg } from "../lib/arg-parsing.js"; |
| 22 | +import { looksLikePath, parseOrgProjectArg } from "../lib/arg-parsing.js"; |
18 | 23 | import { buildCommand } from "../lib/command.js"; |
19 | 24 | import { ContextError } from "../lib/errors.js"; |
20 | 25 | import { runWizard } from "../lib/init/wizard-runner.js"; |
21 | 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"); |
22 | 31 |
|
23 | 32 | const FEATURE_DELIMITER = /[,+ ]+/; |
24 | 33 |
|
| 34 | +const USAGE_HINT = "sentry init <org>/<project> [directory]"; |
| 35 | + |
25 | 36 | type InitFlags = { |
26 | 37 | readonly yes: boolean; |
27 | 38 | readonly "dry-run": boolean; |
28 | 39 | readonly features?: string[]; |
29 | 40 | readonly team?: string; |
30 | | - readonly directory?: string; |
31 | 41 | }; |
32 | 42 |
|
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 | +>({ |
34 | 136 | docs: { |
35 | 137 | brief: "Initialize Sentry in your project", |
36 | 138 | fullDescription: |
37 | 139 | "Runs the Sentry setup wizard to detect your project's framework, " + |
38 | 140 | "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" + |
41 | 144 | "Examples:\n" + |
42 | 145 | " sentry init\n" + |
43 | 146 | " sentry init acme/\n" + |
44 | 147 | " 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", |
47 | 151 | }, |
48 | 152 | parameters: { |
49 | 153 | positional: { |
50 | 154 | kind: "tuple", |
51 | 155 | parameters: [ |
52 | 156 | { |
53 | 157 | 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)", |
55 | 165 | parse: String, |
56 | 166 | optional: true, |
57 | 167 | }, |
@@ -81,66 +191,46 @@ export const initCommand = buildCommand<InitFlags, [string?], SentryContext>({ |
81 | 191 | brief: "Team slug to create the project under", |
82 | 192 | optional: true, |
83 | 193 | }, |
84 | | - directory: { |
85 | | - kind: "parsed", |
86 | | - parse: String, |
87 | | - brief: "Project directory (default: current directory)", |
88 | | - optional: true, |
89 | | - }, |
90 | 194 | }, |
91 | 195 | aliases: { |
92 | 196 | y: "yes", |
93 | 197 | t: "team", |
94 | | - d: "directory", |
95 | 198 | }, |
96 | 199 | }, |
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; |
101 | 214 |
|
| 215 | + // 3. Parse features |
102 | 216 | const featuresList = flags.features |
103 | 217 | ?.flatMap((f) => f.split(FEATURE_DELIMITER)) |
104 | 218 | .map((f) => f.trim()) |
105 | 219 | .filter(Boolean); |
106 | 220 |
|
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); |
135 | 224 |
|
136 | | - // Validate explicit org slug format before passing to API calls |
| 225 | + // 5. Validate explicit slugs before passing to API calls |
137 | 226 | if (explicitOrg) { |
138 | 227 | validateResourceId(explicitOrg, "organization slug"); |
139 | 228 | } |
140 | 229 | if (explicitProject) { |
141 | 230 | validateResourceId(explicitProject, "project name"); |
142 | 231 | } |
143 | 232 |
|
| 233 | + // 6. Run the wizard |
144 | 234 | await runWizard({ |
145 | 235 | directory: targetDir, |
146 | 236 | yes: flags.yes, |
|
0 commit comments