@inspecto-dev/cli 0.2.0-alpha.5 → 0.3.0-alpha.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/.turbo/turbo-build.log +19 -20
  2. package/CHANGELOG.md +22 -0
  3. package/README.md +93 -11
  4. package/bin/inspecto.js +5 -1
  5. package/dist/bin.d.ts +5 -1
  6. package/dist/bin.js +530 -49
  7. package/dist/chunk-FZS2TLXQ.js +3140 -0
  8. package/dist/index.d.ts +233 -2
  9. package/dist/index.js +17 -3
  10. package/package.json +3 -2
  11. package/src/bin.ts +286 -66
  12. package/src/commands/apply.ts +118 -0
  13. package/src/commands/detect.ts +59 -0
  14. package/src/commands/doctor.ts +225 -72
  15. package/src/commands/init.ts +143 -183
  16. package/src/commands/integration-install.ts +452 -0
  17. package/src/commands/onboard.ts +50 -0
  18. package/src/commands/plan.ts +41 -0
  19. package/src/detect/build-tool.ts +107 -3
  20. package/src/index.ts +17 -2
  21. package/src/inject/ast-injector.ts +17 -6
  22. package/src/inject/extension.ts +40 -22
  23. package/src/inject/gitignore.ts +10 -3
  24. package/src/instructions.ts +60 -46
  25. package/src/onboarding/apply.ts +364 -0
  26. package/src/onboarding/context.ts +36 -0
  27. package/src/onboarding/planner.ts +284 -0
  28. package/src/onboarding/session.ts +434 -0
  29. package/src/onboarding/target-resolution.ts +116 -0
  30. package/src/prompts.ts +54 -11
  31. package/src/types.ts +184 -0
  32. package/src/utils/fs.ts +2 -1
  33. package/src/utils/logger.ts +9 -0
  34. package/src/utils/output.ts +40 -0
  35. package/tests/apply.test.ts +583 -0
  36. package/tests/ast-injector.test.ts +50 -0
  37. package/tests/build-tool.test.ts +3 -5
  38. package/tests/detect.test.ts +94 -0
  39. package/tests/doctor.test.ts +224 -0
  40. package/tests/init.test.ts +364 -0
  41. package/tests/install-wrapper.test.ts +76 -0
  42. package/tests/instructions.test.ts +61 -0
  43. package/tests/integration-install.test.ts +294 -0
  44. package/tests/logger.test.ts +100 -0
  45. package/tests/onboard.test.ts +258 -0
  46. package/tests/plan.test.ts +713 -0
  47. package/tests/workspace-build-tool.test.ts +75 -0
  48. package/.turbo/turbo-test.log +0 -16
  49. package/dist/chunk-MIHQGC3L.js +0 -1720
@@ -0,0 +1,434 @@
1
+ import path from 'node:path'
2
+ import { detectFrameworks } from '../detect/framework.js'
3
+ import { detectIDE } from '../detect/ide.js'
4
+ import { detectProviders } from '../detect/provider.js'
5
+ import { applyOnboardingPlan, type ApplyOnboardingResult } from './apply.js'
6
+ import { buildOnboardingContext } from './context.js'
7
+ import { createPlanResult, planManualFollowUp } from './planner.js'
8
+ import { resolveOnboardingTarget } from './target-resolution.js'
9
+ import { readJSON } from '../utils/fs.js'
10
+ import type {
11
+ OnboardCommandResult,
12
+ OnboardingContext,
13
+ OnboardingDiagnostics,
14
+ OnboardingExecutionResult,
15
+ OnboardingIdeExtensionStatus,
16
+ OnboardingSummary,
17
+ OnboardingVerification,
18
+ PlanResult,
19
+ ResolvedOnboardingSession,
20
+ } from '../types.js'
21
+
22
+ export interface ResolveOnboardingSessionOptions {
23
+ json?: boolean
24
+ target?: string
25
+ yes?: boolean
26
+ shared?: boolean
27
+ skipInstall?: boolean
28
+ dryRun?: boolean
29
+ noExtension?: boolean
30
+ }
31
+
32
+ function normalizePackagePath(packagePath?: string): string {
33
+ if (!packagePath || packagePath === '.') return ''
34
+ return packagePath.replace(/\\/g, '/').replace(/^\.\//, '').replace(/\/$/, '')
35
+ }
36
+
37
+ function getProviderDefault(
38
+ providerId?: string,
39
+ preferredMode?: 'cli' | 'extension',
40
+ ): string | undefined {
41
+ if (!providerId) return undefined
42
+ const mode = preferredMode ?? (providerId === 'coco' ? 'cli' : 'extension')
43
+ return `${providerId}.${mode}`
44
+ }
45
+
46
+ function getVerificationCommand(packageManager: OnboardingContext['packageManager']): string {
47
+ switch (packageManager) {
48
+ case 'pnpm':
49
+ return 'pnpm dev'
50
+ case 'yarn':
51
+ return 'yarn dev'
52
+ case 'bun':
53
+ return 'bun run dev'
54
+ case 'npm':
55
+ default:
56
+ return 'npm run dev'
57
+ }
58
+ }
59
+
60
+ async function buildVerification(
61
+ projectRoot: string,
62
+ packageManager: OnboardingContext['packageManager'],
63
+ ): Promise<OnboardingVerification> {
64
+ const packageJson = await readJSON<{ scripts?: Record<string, string> }>(
65
+ path.join(projectRoot, 'package.json'),
66
+ )
67
+
68
+ if (packageJson?.scripts?.dev) {
69
+ const devCommand = getVerificationCommand(packageManager)
70
+ return {
71
+ available: true,
72
+ devCommand,
73
+ message: `Start the local dev server with \`${devCommand}\` to verify Inspecto in the browser.`,
74
+ }
75
+ }
76
+
77
+ return {
78
+ available: false,
79
+ message: 'Start your normal local dev server command to verify Inspecto in the browser.',
80
+ }
81
+ }
82
+
83
+ function buildIdeExtensionStatus(input: {
84
+ required: boolean
85
+ installed: boolean
86
+ manualRequired: boolean
87
+ }): OnboardingIdeExtensionStatus {
88
+ if (!input.required) {
89
+ return {
90
+ required: false,
91
+ installed: false,
92
+ manualRequired: false,
93
+ }
94
+ }
95
+
96
+ return {
97
+ required: true,
98
+ installed: input.installed,
99
+ manualRequired: input.manualRequired,
100
+ installCommand: 'code --install-extension inspecto.inspecto',
101
+ marketplaceUrl: 'https://marketplace.visualstudio.com/items?itemName=inspecto.inspecto',
102
+ openVsxUrl: 'https://open-vsx.org/extension/inspecto/inspecto',
103
+ }
104
+ }
105
+
106
+ async function detectFrameworkSupportByPackage(
107
+ repoRoot: string,
108
+ context: OnboardingContext,
109
+ ): Promise<Record<string, string[]>> {
110
+ const packagePaths = new Set(
111
+ context.buildTools.supported.map(item => normalizePackagePath(item.packagePath)),
112
+ )
113
+ const supportByPackage: Record<string, string[]> = {}
114
+
115
+ await Promise.all(
116
+ Array.from(packagePaths).map(async packagePath => {
117
+ const frameworkResult = await detectFrameworks(
118
+ packagePath ? path.join(repoRoot, packagePath) : repoRoot,
119
+ )
120
+ supportByPackage[packagePath] = frameworkResult.supported
121
+ }),
122
+ )
123
+
124
+ return supportByPackage
125
+ }
126
+
127
+ async function buildTargetedContext(
128
+ rootContext: OnboardingContext,
129
+ packagePath: string,
130
+ ): Promise<OnboardingContext> {
131
+ const projectRoot = packagePath ? path.join(rootContext.root, packagePath) : rootContext.root
132
+ const [frameworks, ides, providers] = await Promise.all([
133
+ detectFrameworks(projectRoot),
134
+ detectIDE(projectRoot),
135
+ detectProviders(projectRoot),
136
+ ])
137
+
138
+ return {
139
+ root: projectRoot,
140
+ packageManager: rootContext.packageManager,
141
+ buildTools: {
142
+ supported: rootContext.buildTools.supported.filter(item => {
143
+ return normalizePackagePath(item.packagePath) === packagePath
144
+ }),
145
+ unsupported: [],
146
+ },
147
+ frameworks: {
148
+ supported: frameworks.supported,
149
+ unsupported: frameworks.unsupported.map(item => item.name),
150
+ },
151
+ ides: ides.detected.map(({ ide, supported }) => ({ ide, supported })),
152
+ providers: providers.detected.map(({ id, label, supported, preferredMode }) => ({
153
+ id,
154
+ label,
155
+ supported,
156
+ preferredMode,
157
+ })),
158
+ }
159
+ }
160
+
161
+ function buildOnboardingSummary(plan: PlanResult, projectRoot: string): OnboardingSummary {
162
+ const changes = plan.actions
163
+ .filter(action => action.type !== 'manual_step')
164
+ .map(action => action.description)
165
+ const risks = [...plan.warnings.map(item => item.message)]
166
+ const manualFollowUp = planManualFollowUp(plan)
167
+
168
+ let headline = `Inspecto is ready to onboard ${projectRoot}.`
169
+ if (manualFollowUp.length > 0) {
170
+ headline = `Inspecto can partially onboard ${projectRoot}, but manual follow-up remains.`
171
+ } else if (plan.status === 'blocked') {
172
+ headline = `Inspecto could not build an automatic onboarding path for ${projectRoot}.`
173
+ }
174
+
175
+ return {
176
+ headline,
177
+ changes,
178
+ risks,
179
+ manualFollowUp,
180
+ }
181
+ }
182
+
183
+ function buildConfirmation(
184
+ plan: PlanResult,
185
+ summary: OnboardingSummary,
186
+ session: {
187
+ targetWasHeuristic: boolean
188
+ supportedIdeCount: number
189
+ supportedProviderCount: number
190
+ },
191
+ options: ResolveOnboardingSessionOptions,
192
+ ): { required: boolean; reason?: string; question?: string } {
193
+ if (options.yes) {
194
+ return { required: false }
195
+ }
196
+
197
+ const reasons: string[] = []
198
+ if (session.targetWasHeuristic) reasons.push('a monorepo target was preselected')
199
+ if (summary.manualFollowUp.length > 0) reasons.push('manual follow-up will remain after setup')
200
+ if (options.noExtension || options.skipInstall) reasons.push('non-core setup steps are being skipped')
201
+ if (session.supportedIdeCount > 1 || session.supportedProviderCount > 1) {
202
+ reasons.push('multiple IDE or provider choices are still relevant')
203
+ }
204
+ if (plan.warnings.length > 0) reasons.push('the CLI detected non-blocking risk')
205
+
206
+ if (reasons.length === 0) {
207
+ return { required: false }
208
+ }
209
+
210
+ return {
211
+ required: true,
212
+ reason: reasons.join('; '),
213
+ question: 'Proceed with Inspecto onboarding using the proposed default target and settings?',
214
+ }
215
+ }
216
+
217
+ function buildPreApplyResult(
218
+ status: ResolvedOnboardingSession['status'],
219
+ session: ResolvedOnboardingSession,
220
+ ): OnboardCommandResult {
221
+ const diagnostics: OnboardingDiagnostics | undefined =
222
+ session.summary.risks.length > 0 || session.summary.manualFollowUp.length > 0 || session.plan.blockers.length > 0
223
+ ? {
224
+ warnings: session.summary.risks,
225
+ errors: session.plan.blockers.map(item => item.message),
226
+ nextSteps: session.summary.manualFollowUp,
227
+ }
228
+ : undefined
229
+
230
+ return {
231
+ status,
232
+ target: session.target,
233
+ summary: session.summary,
234
+ confirmation: session.confirmation,
235
+ ideExtension: buildIdeExtensionStatus({
236
+ required: session.plan.defaults.extension,
237
+ installed: false,
238
+ manualRequired: session.plan.defaults.extension,
239
+ }),
240
+ verification: session.verification,
241
+ diagnostics,
242
+ }
243
+ }
244
+
245
+ function buildExecutionResult(
246
+ session: ResolvedOnboardingSession,
247
+ applyResult: ApplyOnboardingResult,
248
+ ): OnboardingExecutionResult {
249
+ return {
250
+ changedFiles: Array.from(
251
+ new Set(applyResult.mutations.map(item => item.path).filter((value): value is string => !!value)),
252
+ ),
253
+ installedDependencies: applyResult.mutations
254
+ .map(item => item.name)
255
+ .filter((value): value is string => !!value),
256
+ selectedProviderDefault: session.providerDefault,
257
+ selectedIDE: session.selectedIDE?.ide,
258
+ mutations: applyResult.mutations,
259
+ }
260
+ }
261
+
262
+ function buildExecutionDiagnostics(
263
+ session: ResolvedOnboardingSession,
264
+ applyResult: ApplyOnboardingResult,
265
+ ): OnboardingDiagnostics | undefined {
266
+ const warnings = [...session.plan.warnings.map(item => item.message)]
267
+ const errors: string[] = []
268
+
269
+ if (applyResult.postInstall.installFailed) {
270
+ errors.push('Dependency installation failed during onboarding.')
271
+ }
272
+ if (applyResult.postInstall.injectionFailed) {
273
+ warnings.push('Automatic plugin injection did not finish cleanly.')
274
+ }
275
+ if (applyResult.postInstall.manualExtensionInstallNeeded) {
276
+ warnings.push('IDE extension installation still needs manual completion.')
277
+ }
278
+
279
+ if (warnings.length === 0 && errors.length === 0 && applyResult.postInstall.nextSteps.length === 0) {
280
+ return undefined
281
+ }
282
+
283
+ return {
284
+ warnings,
285
+ errors,
286
+ nextSteps: applyResult.postInstall.nextSteps,
287
+ }
288
+ }
289
+
290
+ export async function resolveOnboardingSession(
291
+ root: string,
292
+ options: ResolveOnboardingSessionOptions = {},
293
+ ): Promise<ResolvedOnboardingSession> {
294
+ const rootContext = await buildOnboardingContext(root)
295
+ const rootVerification = await buildVerification(root, rootContext.packageManager)
296
+ const frameworkSupportByPackage = await detectFrameworkSupportByPackage(root, rootContext)
297
+ const target = resolveOnboardingTarget({
298
+ repoRoot: root,
299
+ buildTools: rootContext.buildTools.supported,
300
+ frameworkSupportByPackage,
301
+ selectedPackagePath: options.target,
302
+ })
303
+
304
+ if (target.candidates.length === 0) {
305
+ const plan = createPlanResult(rootContext)
306
+ return {
307
+ status: 'error',
308
+ target,
309
+ summary: buildOnboardingSummary(plan, root),
310
+ confirmation: { required: false },
311
+ verification: rootVerification,
312
+ context: rootContext,
313
+ plan,
314
+ projectRoot: root,
315
+ }
316
+ }
317
+
318
+ if (target.status === 'needs_selection') {
319
+ const plan = createPlanResult(rootContext)
320
+ const summary: OnboardingSummary = {
321
+ headline: 'Inspecto found multiple plausible app targets and needs one selection.',
322
+ changes: [],
323
+ risks: [],
324
+ manualFollowUp: [],
325
+ }
326
+ return {
327
+ status: 'needs_target_selection',
328
+ target,
329
+ summary,
330
+ confirmation: { required: false },
331
+ verification: rootVerification,
332
+ context: rootContext,
333
+ plan,
334
+ projectRoot: root,
335
+ }
336
+ }
337
+
338
+ const packagePath = normalizePackagePath(target.selected?.packagePath)
339
+ const context = await buildTargetedContext(rootContext, packagePath)
340
+ const verification = await buildVerification(context.root, context.packageManager)
341
+ const plan = createPlanResult(context)
342
+ const summary = buildOnboardingSummary(plan, context.root)
343
+ const confirmation = buildConfirmation(
344
+ plan,
345
+ summary,
346
+ {
347
+ targetWasHeuristic: target.candidates.length > 1 && options.target === undefined,
348
+ supportedIdeCount: context.ides.filter(item => item.supported).length,
349
+ supportedProviderCount: context.providers.filter(item => item.supported).length,
350
+ },
351
+ options,
352
+ )
353
+ const selectedProvider =
354
+ context.providers.find(provider => provider.id === plan.defaults.provider) ?? null
355
+ const selectedIDE =
356
+ context.ides.find(ide => ide.ide === plan.defaults.ide) ??
357
+ context.ides.find(ide => ide.supported) ??
358
+ null
359
+
360
+ let status: ResolvedOnboardingSession['status'] = 'success'
361
+ if (plan.status === 'blocked') {
362
+ status = 'error'
363
+ } else if (confirmation.required) {
364
+ status = 'needs_confirmation'
365
+ } else if (summary.manualFollowUp.length > 0 || plan.warnings.length > 0) {
366
+ status = 'partial_success'
367
+ }
368
+
369
+ return {
370
+ status,
371
+ target,
372
+ summary,
373
+ confirmation,
374
+ verification,
375
+ context,
376
+ plan,
377
+ projectRoot: context.root,
378
+ selectedIDE,
379
+ providerDefault: getProviderDefault(plan.defaults.provider, selectedProvider?.preferredMode),
380
+ }
381
+ }
382
+
383
+ export async function applyResolvedOnboardingSession(
384
+ session: ResolvedOnboardingSession,
385
+ options: ResolveOnboardingSessionOptions = {},
386
+ ): Promise<OnboardCommandResult> {
387
+ const verification = await buildVerification(session.projectRoot, session.context.packageManager)
388
+ const applyResult = await applyOnboardingPlan({
389
+ repoRoot: process.cwd(),
390
+ projectRoot: session.projectRoot,
391
+ packageManager: session.context.packageManager,
392
+ supportedBuildTargets: session.context.buildTools.supported,
393
+ options: {
394
+ shared: options.shared ?? session.plan.defaults.shared,
395
+ skipInstall: options.skipInstall ?? false,
396
+ dryRun: options.dryRun ?? false,
397
+ noExtension: options.noExtension ?? !session.plan.defaults.extension,
398
+ quiet: options.json ?? false,
399
+ },
400
+ selectedIDE: session.selectedIDE,
401
+ providerDefault: session.providerDefault,
402
+ plan: session.plan,
403
+ })
404
+
405
+ const diagnostics = buildExecutionDiagnostics(session, applyResult)
406
+ const status =
407
+ applyResult.postInstall.installFailed && session.context.buildTools.supported.length === 0
408
+ ? 'error'
409
+ : diagnostics?.nextSteps.length || diagnostics?.errors.length || diagnostics?.warnings.length
410
+ ? 'partial_success'
411
+ : 'success'
412
+
413
+ return {
414
+ status,
415
+ target: session.target,
416
+ summary: session.summary,
417
+ confirmation: session.confirmation,
418
+ ideExtension: buildIdeExtensionStatus({
419
+ required: session.plan.defaults.extension,
420
+ installed:
421
+ session.plan.defaults.extension && !applyResult.postInstall.manualExtensionInstallNeeded,
422
+ manualRequired: applyResult.postInstall.manualExtensionInstallNeeded,
423
+ }),
424
+ verification,
425
+ result: buildExecutionResult(session, applyResult),
426
+ diagnostics,
427
+ }
428
+ }
429
+
430
+ export function buildDeferredOnboardResult(
431
+ session: ResolvedOnboardingSession,
432
+ ): OnboardCommandResult {
433
+ return buildPreApplyResult(session.status, session)
434
+ }
@@ -0,0 +1,116 @@
1
+ import type {
2
+ BuildToolDetection,
3
+ OnboardingTargetCandidate,
4
+ OnboardingTargetResolution,
5
+ } from '../types.js'
6
+
7
+ export interface ResolveOnboardingTargetInput {
8
+ repoRoot: string
9
+ buildTools: BuildToolDetection[]
10
+ frameworkSupportByPackage: Record<string, string[]>
11
+ selectedPackagePath?: string
12
+ }
13
+
14
+ interface RankedCandidate {
15
+ candidate: OnboardingTargetCandidate
16
+ score: number
17
+ }
18
+
19
+ function normalizePackagePath(packagePath?: string): string {
20
+ if (!packagePath || packagePath === '.') return ''
21
+ return packagePath.replace(/\\/g, '/').replace(/^\.\//, '').replace(/\/$/, '')
22
+ }
23
+
24
+ function looksLikeAppPackage(packagePath: string): boolean {
25
+ if (!packagePath) return true
26
+ return /(^|\/)(app|apps|web|client|frontend|site)(\/|$)/i.test(packagePath)
27
+ }
28
+
29
+ function looksLikeAuxiliaryPackage(packagePath: string): boolean {
30
+ return /(^|\/)(docs?|example|examples|playground|storybook|demo)(\/|$)/i.test(packagePath)
31
+ }
32
+
33
+ function buildCandidates(input: ResolveOnboardingTargetInput): OnboardingTargetCandidate[] {
34
+ return input.buildTools.map(buildTool => {
35
+ const packagePath = normalizePackagePath(buildTool.packagePath)
36
+ return {
37
+ packagePath,
38
+ configPath: buildTool.configPath,
39
+ buildTool: buildTool.tool,
40
+ frameworks: input.frameworkSupportByPackage[packagePath] ?? [],
41
+ automaticInjection: true,
42
+ }
43
+ })
44
+ }
45
+
46
+ function rankCandidate(candidate: OnboardingTargetCandidate): number {
47
+ let score = 0
48
+
49
+ if (candidate.frameworks.length > 0) score += 4
50
+ if (candidate.automaticInjection) score += 2
51
+ if (looksLikeAppPackage(candidate.packagePath)) score += 1
52
+ if (looksLikeAuxiliaryPackage(candidate.packagePath)) score -= 2
53
+
54
+ return score
55
+ }
56
+
57
+ function rankCandidates(candidates: OnboardingTargetCandidate[]): RankedCandidate[] {
58
+ return candidates
59
+ .map(candidate => ({
60
+ candidate,
61
+ score: rankCandidate(candidate),
62
+ }))
63
+ .sort((left, right) => right.score - left.score)
64
+ }
65
+
66
+ export function resolveOnboardingTarget(
67
+ input: ResolveOnboardingTargetInput,
68
+ ): OnboardingTargetResolution {
69
+ const candidates = buildCandidates(input)
70
+
71
+ if (candidates.length === 0) {
72
+ return {
73
+ status: 'needs_selection',
74
+ candidates,
75
+ reason: 'No supported targets were detected.',
76
+ }
77
+ }
78
+
79
+ const explicitlySelected = normalizePackagePath(input.selectedPackagePath)
80
+ if (input.selectedPackagePath !== undefined) {
81
+ const selected = candidates.find(candidate => candidate.packagePath === explicitlySelected)
82
+ if (selected) {
83
+ return {
84
+ status: 'resolved',
85
+ selected,
86
+ candidates,
87
+ reason: `Using the explicitly selected target: ${selected.packagePath || '.'}.`,
88
+ }
89
+ }
90
+ }
91
+
92
+ if (candidates.length === 1) {
93
+ return {
94
+ status: 'resolved',
95
+ selected: candidates[0],
96
+ candidates,
97
+ reason: 'Only one supported target was detected.',
98
+ }
99
+ }
100
+
101
+ const ranked = rankCandidates(candidates)
102
+ if (ranked.length > 1 && ranked[0]!.score === ranked[1]!.score) {
103
+ return {
104
+ status: 'needs_selection',
105
+ candidates,
106
+ reason: 'Multiple supported targets look equally plausible.',
107
+ }
108
+ }
109
+
110
+ return {
111
+ status: 'resolved',
112
+ selected: ranked[0]!.candidate,
113
+ candidates,
114
+ reason: `Preselected ${ranked[0]!.candidate.packagePath || '.'} because it has the strongest supported app signal.`,
115
+ }
116
+ }
package/src/prompts.ts CHANGED
@@ -45,19 +45,21 @@ export async function promptProviderChoice(
45
45
  type: 'select',
46
46
  name: 'choice',
47
47
  message: 'Detected multiple providers, please choose one:',
48
- choices: detections.map((d, i) => {
49
- const modeLabels = d.providerModes.map(mode =>
50
- mode === 'extension' ? 'VS Code Extension' : 'Terminal CLI',
51
- )
52
- const modeStr = modeLabels.join(' & ')
53
- return {
54
- title: `${d.label} ${d.supported ? `(supported ${modeStr})` : '(unsupported/limited)'}`,
55
- value: i,
56
- }
57
- }),
48
+ choices: detections
49
+ .map((d, i) => {
50
+ const modeLabels = d.providerModes.map(mode =>
51
+ mode === 'extension' ? 'VS Code Extension' : 'Terminal CLI',
52
+ )
53
+ const modeStr = modeLabels.join(' & ')
54
+ return {
55
+ title: `${d.label} ${d.supported ? `(supported ${modeStr})` : '(unsupported/limited)'}`,
56
+ value: i,
57
+ }
58
+ })
59
+ .concat({ title: 'Skip (configure later)', value: -1 }),
58
60
  })
59
61
 
60
- if (choice === undefined) return null
62
+ if (choice === undefined || choice === -1) return null
61
63
  return detections[choice]!
62
64
  }
63
65
 
@@ -94,6 +96,47 @@ export async function promptConfigChoice(
94
96
  return detections[choice]!
95
97
  }
96
98
 
99
+ /**
100
+ * Interactive prompt for choosing the target app when init is run from a monorepo root.
101
+ */
102
+ export async function promptMonorepoPackageChoice(
103
+ detections: BuildToolDetection[],
104
+ ): Promise<string | null> {
105
+ const uniquePackages = Array.from(
106
+ new Map(
107
+ detections
108
+ .filter((d): d is BuildToolDetection & { packagePath: string } => !!d.packagePath)
109
+ .map(d => [d.packagePath, d]),
110
+ ).values(),
111
+ )
112
+
113
+ if (uniquePackages.length === 0) {
114
+ return null
115
+ }
116
+
117
+ if (!process.stdin.isTTY) {
118
+ log.error('Monorepo root detected, but stdin is not interactive.')
119
+ log.hint(
120
+ 'Re-run `inspecto init` inside a specific app directory, or pass --packages <app-path>.',
121
+ )
122
+ return null
123
+ }
124
+
125
+ const { choice } = await prompts({
126
+ type: 'select',
127
+ name: 'choice',
128
+ message: 'Monorepo root detected. Choose the app to initialize:',
129
+ choices: uniquePackages.map((d, i) => ({
130
+ title: `${d.packagePath} (${d.tool})`,
131
+ description: d.configPath,
132
+ value: i,
133
+ })),
134
+ })
135
+
136
+ if (choice === undefined) return null
137
+ return uniquePackages[choice]!.packagePath
138
+ }
139
+
97
140
  /**
98
141
  * Interactive prompt for continuing with unsupported frameworks.
99
142
  */