@inspecto-dev/cli 0.2.0-alpha.2 → 0.2.0-alpha.4

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.
@@ -18,7 +18,7 @@ import { detectProviders, type ProviderDetection } from '../detect/provider.js'
18
18
  import { injectPlugin } from '../inject/ast-injector.js'
19
19
  import { updateGitignore } from '../inject/gitignore.js'
20
20
  import { installExtension } from '../inject/extension.js'
21
- import type { InitOptions, InstallLock, Mutation } from '../types.js'
21
+ import type { InitOptions, InstallLock, Mutation, BuildToolDetection } from '../types.js'
22
22
  import {
23
23
  promptIDEChoice,
24
24
  promptProviderChoice,
@@ -30,6 +30,29 @@ import { printNextJsManualInstructions, printNuxtManualInstructions } from '../i
30
30
  export async function init(options: InitOptions): Promise<void> {
31
31
  const root = process.cwd()
32
32
  const mutations: Mutation[] = []
33
+ const normalizedPackages = normalizePackageList(options.packages)
34
+
35
+ const verifiedPackages: string[] = []
36
+ for (const pkg of normalizedPackages) {
37
+ if (!pkg) {
38
+ // Empty string represents the workspace root
39
+ verifiedPackages.push(pkg)
40
+ continue
41
+ }
42
+
43
+ const absolutePath = path.join(root, pkg)
44
+ if (await exists(absolutePath)) {
45
+ verifiedPackages.push(pkg)
46
+ } else {
47
+ log.warn(`Package path "${pkg}" not found (skipping)`)
48
+ log.hint('Ensure --packages values are relative to the project root')
49
+ }
50
+ }
51
+
52
+ if (normalizedPackages.length > 0 && verifiedPackages.length === 0) {
53
+ log.error('No valid packages found from --packages input')
54
+ return
55
+ }
33
56
 
34
57
  log.header('Inspecto Setup')
35
58
 
@@ -44,7 +67,7 @@ export async function init(options: InitOptions): Promise<void> {
44
67
  const [pm, frameworkResult, buildResult, ideProbe, providerProbe] = await Promise.all([
45
68
  detectPackageManager(root),
46
69
  detectFrameworks(root),
47
- detectBuildTools(root),
70
+ detectBuildTools(root, verifiedPackages.length > 0 ? verifiedPackages : undefined),
48
71
  detectIDE(root),
49
72
  detectProviders(root),
50
73
  ])
@@ -57,12 +80,11 @@ export async function init(options: InitOptions): Promise<void> {
57
80
  log.success(`Detected framework: ${frameworkResult.supported.join(', ')}`)
58
81
  }
59
82
 
60
- const hasUnsupportedFramework = frameworkResult.unsupported.length > 0
61
- const hasNoFramework =
62
- frameworkResult.supported.length === 0 && frameworkResult.unsupported.length === 0
83
+ const isSupported = frameworkResult.supported.length > 0
84
+ const hasUnsupported = frameworkResult.unsupported.length > 0
63
85
 
64
- if (hasUnsupportedFramework || hasNoFramework) {
65
- if (hasUnsupportedFramework) {
86
+ if (!isSupported) {
87
+ if (hasUnsupported) {
66
88
  const names = frameworkResult.unsupported.map(f => f.name).join(', ')
67
89
  log.warn(`Detected ${names} — not supported in v1 (React / Vue only)`)
68
90
  } else {
@@ -79,10 +101,22 @@ export async function init(options: InitOptions): Promise<void> {
79
101
  } else {
80
102
  log.warn('Continuing anyway (--force)')
81
103
  }
104
+ } else if (hasUnsupported) {
105
+ const names = frameworkResult.unsupported.map(f => f.name).join(', ')
106
+ log.hint(
107
+ `Note: Inspecto will be configured for ${frameworkResult.supported.join(', ')}. Other detected frameworks (${names}) will be ignored.`,
108
+ )
82
109
  }
83
110
 
84
111
  // Build tool detection
85
112
  let manualConfigRequiredFor = ''
113
+ if (verifiedPackages.length > 0 && buildResult.supported.length === 0) {
114
+ log.warn(
115
+ `No supported build configs detected for: ${verifiedPackages.map(pkg => (pkg ? pkg : '.')).join(', ')}`,
116
+ )
117
+ log.hint('Double-check the --packages values or run without the flag to scan the repo root')
118
+ }
119
+
86
120
  if (buildResult.supported.length > 0) {
87
121
  buildResult.supported.forEach(bt => log.success(`Detected: ${bt.label}`))
88
122
  }
@@ -97,7 +131,7 @@ export async function init(options: InitOptions): Promise<void> {
97
131
  log.hint('current version supports: Vite, Webpack, Rspack, esbuild, Rollup')
98
132
  log.hint('Dependency will be installed but plugin injection will be skipped')
99
133
  log.hint(
100
- 'Please refer to the manual setup guide: https://inspecto.dev/docs/getting-started/manual-setup',
134
+ 'Please refer to the manual setup guide: https://inspecto-dev.github.io/inspecto/guide/manual-installation',
101
135
  )
102
136
  }
103
137
 
@@ -173,22 +207,54 @@ export async function init(options: InitOptions): Promise<void> {
173
207
  // ---- Step 4: Inject plugin into build config ----
174
208
  let injectionFailed = false
175
209
  if (buildResult.supported.length > 0) {
176
- let target = resolveInjectionTarget(buildResult.supported)
210
+ if (verifiedPackages.length > 0) {
211
+ const targets = buildResult.supported.filter(detection =>
212
+ matchesAnyPackage(detection, verifiedPackages),
213
+ )
177
214
 
178
- if (target === 'ambiguous') {
179
- target = await promptConfigChoice(buildResult.supported)
180
- }
215
+ const unmatchedPackages = verifiedPackages.filter(
216
+ pkg => !buildResult.supported.some(detection => matchesPackage(detection, pkg)),
217
+ )
181
218
 
182
- if (target) {
183
- const result = await injectPlugin(root, target, options.dryRun)
184
- if (result.success) {
185
- mutations.push(...result.mutations)
186
- } else {
219
+ if (unmatchedPackages.length > 0) {
220
+ log.warn(
221
+ `No supported build configs detected for: ${unmatchedPackages
222
+ .map(pkg => (pkg ? pkg : '.'))
223
+ .join(', ')}`,
224
+ )
225
+ log.hint('Check the package paths or run without --packages to inspect the repo root')
226
+ }
227
+
228
+ if (targets.length === 0) {
187
229
  injectionFailed = true
188
230
  }
231
+
232
+ for (const target of targets) {
233
+ const result = await injectPlugin(root, target, options.dryRun)
234
+ if (result.success) {
235
+ mutations.push(...result.mutations)
236
+ } else {
237
+ injectionFailed = true
238
+ }
239
+ }
189
240
  } else {
190
- injectionFailed = true
191
- log.warn('Skipping plugin injection (manual configuration required)')
241
+ let target = resolveInjectionTarget(buildResult.supported)
242
+
243
+ if (target === 'ambiguous') {
244
+ target = await promptConfigChoice(buildResult.supported)
245
+ }
246
+
247
+ if (target) {
248
+ const result = await injectPlugin(root, target, options.dryRun)
249
+ if (result.success) {
250
+ mutations.push(...result.mutations)
251
+ } else {
252
+ injectionFailed = true
253
+ }
254
+ } else {
255
+ injectionFailed = true
256
+ log.warn('Skipping plugin injection (manual configuration required)')
257
+ }
192
258
  }
193
259
  }
194
260
 
@@ -325,3 +391,33 @@ export async function init(options: InitOptions): Promise<void> {
325
391
  log.ready('Ready! Hold Alt + Click any element to inspect.')
326
392
  }
327
393
  }
394
+
395
+ function normalizePackageList(packages?: string[]): string[] {
396
+ if (!packages || packages.length === 0) return []
397
+
398
+ const normalized = packages
399
+ .map(pkg => {
400
+ const trimmed = pkg.trim()
401
+ if (trimmed === '') return null
402
+ if (trimmed === '.' || trimmed === './') return ''
403
+
404
+ return trimmed.replace(/\\/g, '/').replace(/^\.\//, '').replace(/\/$/, '')
405
+ })
406
+ .filter((value): value is string => value !== null)
407
+
408
+ return Array.from(new Set(normalized))
409
+ }
410
+
411
+ function matchesPackage(detection: BuildToolDetection, pkg: string): boolean {
412
+ const configPath = detection.configPath.replace(/\\/g, '/')
413
+ if (!pkg) {
414
+ return !configPath.includes('/')
415
+ }
416
+
417
+ return configPath === pkg || configPath.startsWith(`${pkg}/`)
418
+ }
419
+
420
+ function matchesAnyPackage(detection: BuildToolDetection, packages: string[]): boolean {
421
+ if (packages.length === 0) return true
422
+ return packages.some(pkg => matchesPackage(detection, pkg))
423
+ }
@@ -5,6 +5,7 @@
5
5
  // Recognized but unsupported: Next.js / Nuxt / Remix / Astro / SvelteKit
6
6
  // ============================================================
7
7
  import path from 'node:path'
8
+ import { createRequire } from 'node:module'
8
9
  import { exists, readJSON } from '../utils/fs.js'
9
10
  import type { BuildTool, BuildToolDetection } from '../types.js'
10
11
 
@@ -12,13 +13,54 @@ interface PackageJSON {
12
13
  dependencies?: Record<string, string>
13
14
  devDependencies?: Record<string, string>
14
15
  scripts?: Record<string, string>
16
+ version?: string
17
+ }
18
+
19
+ /**
20
+ * Helper to check if a package can be resolved from the root directory.
21
+ * This handles monorepo hoisting and implicit dependencies.
22
+ */
23
+ function isPackageResolvable(pkgName: string, root: string): boolean {
24
+ try {
25
+ const require = createRequire(path.join(root, 'package.json'))
26
+ try {
27
+ require.resolve(`${pkgName}/package.json`, { paths: [root] })
28
+ return true
29
+ } catch {
30
+ require.resolve(pkgName, { paths: [root] })
31
+ return true
32
+ }
33
+ } catch {
34
+ return false
35
+ }
36
+ }
37
+
38
+ /**
39
+ * Attempts to read the actual version of a hoisted package from node_modules.
40
+ */
41
+ async function getResolvedPackageVersion(pkgName: string, root: string): Promise<string | null> {
42
+ try {
43
+ const require = createRequire(path.join(root, 'package.json'))
44
+ const pkgJsonPath = require.resolve(`${pkgName}/package.json`, { paths: [root] })
45
+ const pkg = await readJSON<PackageJSON>(pkgJsonPath)
46
+ return pkg?.version || null
47
+ } catch {
48
+ return null
49
+ }
15
50
  }
16
51
 
17
52
  /** Supported build tools in v1 */
18
53
  const SUPPORTED_PATTERNS: { tool: BuildTool; files: string[]; label: string }[] = [
19
54
  {
20
55
  tool: 'vite',
21
- files: ['vite.config.ts', 'vite.config.js', 'vite.config.mts', 'vite.config.mjs'],
56
+ files: [
57
+ 'vite.config.ts',
58
+ 'vite.config.js',
59
+ 'vite.config.mts',
60
+ 'vite.config.mjs',
61
+ 'vite.config.cjs',
62
+ 'vite.config.cts',
63
+ ],
22
64
  label: 'Vite',
23
65
  },
24
66
  {
@@ -57,126 +99,223 @@ const UNSUPPORTED_META: { name: string; dep: string; files: string[] }[] = [
57
99
  { name: 'SvelteKit', dep: '@sveltejs/kit', files: ['svelte.config.js', 'svelte.config.ts'] },
58
100
  ]
59
101
 
102
+ interface DetectionTarget {
103
+ /** Relative path provided via --packages ('' for repo root) */
104
+ packagePath: string
105
+ /** Absolute path to run detection from */
106
+ absolutePath: string
107
+ }
108
+
60
109
  export interface BuildToolResult {
61
110
  supported: BuildToolDetection[]
62
111
  unsupported: string[]
63
112
  }
64
113
 
114
+ function normalizeRelativePath(root: string, filePath: string): string {
115
+ const relative = path.relative(root, filePath)
116
+ const normalized = relative.split(path.sep).join('/')
117
+ return normalized || path.basename(filePath)
118
+ }
119
+
120
+ function createTargets(root: string, packagePaths?: string[]): DetectionTarget[] {
121
+ if (!packagePaths || packagePaths.length === 0) {
122
+ return [{ packagePath: '', absolutePath: root }]
123
+ }
124
+
125
+ return packagePaths.map(pkg => ({
126
+ packagePath: pkg,
127
+ absolutePath: pkg ? path.join(root, pkg) : root,
128
+ }))
129
+ }
130
+
65
131
  /**
66
132
  * Detect all build tools / meta-frameworks.
67
133
  * Returns supported tools and recognized-but-unsupported meta-frameworks.
68
134
  */
69
- export async function detectBuildTools(root: string): Promise<BuildToolResult> {
135
+ export async function detectBuildTools(
136
+ root: string,
137
+ packagePaths?: string[],
138
+ ): Promise<BuildToolResult> {
70
139
  const supported: BuildToolDetection[] = []
71
- const unsupported: string[] = []
72
-
73
- // Detect supported build tools (by config file)
74
- const pkg = await readJSON<PackageJSON>(path.join(root, 'package.json'))
75
- const allDeps = { ...pkg?.dependencies, ...pkg?.devDependencies }
76
-
77
- const supportedChecks = SUPPORTED_PATTERNS.map(async pattern => {
78
- // 1. Check if the package.json has a dependency for this tool
79
- const hasDep =
80
- pattern.tool === 'rspack'
81
- ? !!(allDeps['@rspack/cli'] || allDeps['@rspack/core'])
82
- : pattern.tool === 'webpack'
83
- ? !!(allDeps['webpack'] || allDeps['webpack-cli'])
84
- : pattern.tool === 'rsbuild'
85
- ? !!allDeps['@rsbuild/core']
86
- : !!allDeps[pattern.tool]
87
-
88
- // 2. Look for config files
89
- let detectedFile = ''
90
-
91
- // For esbuild, dependency is strictly required
92
- if (pattern.tool === 'esbuild' && !hasDep) {
93
- return null
94
- }
140
+ const unsupported = new Set<string>()
141
+ const targets = createTargets(root, packagePaths)
95
142
 
96
- for (const file of pattern.files) {
97
- if (await exists(path.join(root, file))) {
98
- detectedFile = file
99
- break
100
- }
101
- }
143
+ for (const target of targets) {
144
+ const pkg = await readJSON<PackageJSON>(path.join(target.absolutePath, 'package.json'))
145
+ const allDeps = { ...pkg?.dependencies, ...pkg?.devDependencies }
146
+ const scripts = pkg?.scripts || {}
102
147
 
103
- // 3. For esbuild and rollup, if they are in dependencies but no standard config is found,
104
- // we still consider them detected (as they are often used with custom scripts)
105
- if (hasDep && !detectedFile && (pattern.tool === 'esbuild' || pattern.tool === 'rollup')) {
106
- // Look at npm scripts to guess the build file
107
- const scripts = pkg?.scripts || {}
108
- for (const [_, cmd] of Object.entries(scripts)) {
109
- if (cmd.includes('node ')) {
110
- const match = cmd.match(/node\s+([^\s]+\.(js|mjs|cjs|ts))/)
111
- if (match && match[1]) {
112
- if (await exists(path.join(root, match[1]))) {
113
- detectedFile = match[1]
114
- break
115
- }
116
- }
117
- } else if (cmd.includes(`${pattern.tool} `)) {
118
- detectedFile = 'package.json (scripts)'
119
- break
120
- }
148
+ const supportedChecks = SUPPORTED_PATTERNS.map(pattern =>
149
+ detectPattern({
150
+ pattern,
151
+ workspaceRoot: root,
152
+ targetRoot: target.absolutePath,
153
+ packagePath: target.packagePath,
154
+ allDeps,
155
+ scripts,
156
+ }),
157
+ )
158
+
159
+ const supportedResults = await Promise.all(supportedChecks)
160
+ for (const result of supportedResults) {
161
+ if (result) {
162
+ supported.push(result)
121
163
  }
122
164
  }
123
165
 
124
- if (detectedFile) {
125
- let isLegacyRspack = false
126
- let isLegacyWebpack = false
127
-
128
- if (pattern.tool === 'rspack') {
129
- const version = allDeps['@rspack/cli'] || allDeps['@rspack/core']
130
- if (
131
- version &&
132
- (version.includes('0.3.') || version.includes('0.2.') || version.includes('0.1.'))
133
- ) {
134
- isLegacyRspack = true
135
- }
136
- } else if (pattern.tool === 'webpack') {
137
- const version = allDeps['webpack'] || allDeps['webpack-cli']
138
- if ((version && version.includes('^4')) || version?.startsWith('4.')) {
139
- isLegacyWebpack = true
166
+ const unsupportedChecks = UNSUPPORTED_META.map(async meta => {
167
+ if (!(meta.dep in allDeps)) return null
168
+ for (const file of meta.files) {
169
+ if (await exists(path.join(target.absolutePath, file))) {
170
+ return meta.name
140
171
  }
141
172
  }
173
+ return null
174
+ })
142
175
 
143
- return {
144
- tool: pattern.tool,
145
- configPath: detectedFile,
146
- label: `${pattern.label} (${detectedFile})${isLegacyRspack ? ' [Legacy]' : ''}${isLegacyWebpack ? ' [Webpack 4]' : ''}`,
147
- isLegacyRspack,
148
- isLegacyWebpack,
176
+ const unsupportedResults = await Promise.all(unsupportedChecks)
177
+ for (const result of unsupportedResults) {
178
+ if (result) {
179
+ unsupported.add(result)
149
180
  }
150
181
  }
182
+ }
183
+
184
+ return { supported, unsupported: Array.from(unsupported) }
185
+ }
186
+
187
+ interface PatternContext {
188
+ pattern: { tool: BuildTool; files: string[]; label: string }
189
+ workspaceRoot: string
190
+ targetRoot: string
191
+ packagePath: string
192
+ allDeps: Record<string, string | undefined>
193
+ scripts: Record<string, string>
194
+ }
195
+
196
+ async function detectPattern({
197
+ pattern,
198
+ workspaceRoot,
199
+ targetRoot,
200
+ packagePath,
201
+ allDeps,
202
+ scripts,
203
+ }: PatternContext): Promise<BuildToolDetection | null> {
204
+ let hasDep: boolean
205
+ let resolvedVersion: string | null = null
206
+
207
+ if (pattern.tool === 'rspack') {
208
+ const depName = allDeps['@rspack/cli'] ? '@rspack/cli' : '@rspack/core'
209
+ hasDep =
210
+ !!allDeps['@rspack/cli'] ||
211
+ !!allDeps['@rspack/core'] ||
212
+ isPackageResolvable('@rspack/core', targetRoot)
213
+
214
+ if (hasDep) {
215
+ resolvedVersion =
216
+ allDeps[depName] || (await getResolvedPackageVersion('@rspack/core', targetRoot))
217
+ }
218
+ } else if (pattern.tool === 'webpack') {
219
+ const depName = allDeps['webpack'] ? 'webpack' : 'webpack-cli'
220
+ hasDep =
221
+ !!allDeps['webpack'] || !!allDeps['webpack-cli'] || isPackageResolvable('webpack', targetRoot)
151
222
 
223
+ if (hasDep) {
224
+ resolvedVersion = allDeps[depName] || (await getResolvedPackageVersion('webpack', targetRoot))
225
+ }
226
+ } else if (pattern.tool === 'rsbuild') {
227
+ hasDep = !!allDeps['@rsbuild/core'] || isPackageResolvable('@rsbuild/core', targetRoot)
228
+ } else {
229
+ hasDep = !!allDeps[pattern.tool] || isPackageResolvable(pattern.tool, targetRoot)
230
+ }
231
+
232
+ let detectedFile = ''
233
+
234
+ if (pattern.tool === 'esbuild' && !hasDep) {
152
235
  return null
153
- })
236
+ }
154
237
 
155
- const supportedResults = await Promise.all(supportedChecks)
156
- for (const result of supportedResults) {
157
- if (result) {
158
- supported.push(result)
238
+ for (const file of pattern.files) {
239
+ if (await exists(path.join(targetRoot, file))) {
240
+ detectedFile = file
241
+ break
159
242
  }
160
243
  }
161
244
 
162
- const unsupportedChecks = UNSUPPORTED_META.map(async meta => {
163
- if (!(meta.dep in allDeps)) return null
164
- for (const file of meta.files) {
165
- if (await exists(path.join(root, file))) {
166
- return meta.name
245
+ if (
246
+ hasDep &&
247
+ !detectedFile &&
248
+ (pattern.tool === 'esbuild' ||
249
+ pattern.tool === 'rollup' ||
250
+ pattern.tool === 'webpack' ||
251
+ pattern.tool === 'rspack' ||
252
+ pattern.tool === 'rsbuild')
253
+ ) {
254
+ for (const cmd of Object.values(scripts)) {
255
+ if (cmd.includes('node ')) {
256
+ const match = cmd.match(/node\s+([^\s]+\.(js|mjs|cjs|ts))/)
257
+ if (match && match[1]) {
258
+ if (await exists(path.join(targetRoot, match[1]))) {
259
+ if (cmd.includes(pattern.tool) || match[1].includes(pattern.tool)) {
260
+ detectedFile = match[1]
261
+ break
262
+ }
263
+ }
264
+ }
265
+ } else if (cmd.includes(`${pattern.tool} `)) {
266
+ if (pattern.tool === 'webpack' || pattern.tool === 'rspack') {
267
+ const configMatch = cmd.match(/--config\s+([^\s]+)/)
268
+ if (configMatch && configMatch[1]) {
269
+ if (await exists(path.join(targetRoot, configMatch[1]))) {
270
+ detectedFile = configMatch[1]
271
+ break
272
+ }
273
+ }
274
+ }
275
+
276
+ if (!detectedFile) {
277
+ detectedFile = 'package.json (scripts)'
278
+ break
279
+ }
167
280
  }
168
281
  }
282
+ }
283
+
284
+ if (!detectedFile) {
169
285
  return null
170
- })
286
+ }
171
287
 
172
- const unsupportedResults = await Promise.all(unsupportedChecks)
173
- for (const result of unsupportedResults) {
174
- if (result) {
175
- unsupported.push(result)
288
+ let isLegacyRspack = false
289
+ let isLegacyWebpack = false
290
+
291
+ if (pattern.tool === 'rspack') {
292
+ const version = resolvedVersion
293
+ if (
294
+ version &&
295
+ (version.includes('0.3.') || version.includes('0.2.') || version.includes('0.1.'))
296
+ ) {
297
+ isLegacyRspack = true
298
+ }
299
+ } else if (pattern.tool === 'webpack') {
300
+ const version = resolvedVersion
301
+ if ((version && version.includes('^4')) || version?.startsWith('4.')) {
302
+ isLegacyWebpack = true
176
303
  }
177
304
  }
178
305
 
179
- return { supported, unsupported }
306
+ const absoluteConfig = path.join(targetRoot, detectedFile)
307
+ const relativeConfig = normalizeRelativePath(workspaceRoot, absoluteConfig)
308
+
309
+ return {
310
+ tool: pattern.tool,
311
+ configPath: relativeConfig,
312
+ label: `${pattern.label} (${relativeConfig})${isLegacyRspack ? ' [Legacy]' : ''}${
313
+ isLegacyWebpack ? ' [Webpack 4]' : ''
314
+ }`,
315
+ isLegacyRspack,
316
+ isLegacyWebpack,
317
+ packagePath: packagePath || undefined,
318
+ }
180
319
  }
181
320
 
182
321
  /**
@@ -5,6 +5,7 @@
5
5
  // Recognized but unsupported: Solid, Svelte, Angular, Preact, Lit
6
6
  // ============================================================
7
7
  import path from 'node:path'
8
+ import { createRequire } from 'node:module'
8
9
  import { readJSON } from '../utils/fs.js'
9
10
 
10
11
  export type Framework = 'react' | 'vue'
@@ -17,6 +18,20 @@ export interface FrameworkDetection {
17
18
  interface PackageJSON {
18
19
  dependencies?: Record<string, string>
19
20
  devDependencies?: Record<string, string>
21
+ peerDependencies?: Record<string, string>
22
+ }
23
+
24
+ // Map meta-frameworks to their underlying UI frameworks
25
+ const META_FRAMEWORK_MAP: Record<string, Framework> = {
26
+ next: 'react',
27
+ nuxt: 'vue',
28
+ '@remix-run/react': 'react',
29
+ '@remix-run/dev': 'react',
30
+ '@vue/nuxt': 'vue',
31
+ 'vite-plugin-vue': 'vue',
32
+ '@vitejs/plugin-vue': 'vue',
33
+ '@vitejs/plugin-react': 'react',
34
+ '@vitejs/plugin-react-swc': 'react',
20
35
  }
21
36
 
22
37
  /** Supported frameworks in v1 */
@@ -34,32 +49,79 @@ const UNSUPPORTED_FRAMEWORKS: { name: string; dep: string }[] = [
34
49
  { name: 'Lit', dep: 'lit' },
35
50
  ]
36
51
 
52
+ /**
53
+ * Helper to check if a package can be resolved from the root directory.
54
+ * This handles monorepo hoisting and implicit dependencies.
55
+ */
56
+ function isPackageResolvable(pkgName: string, root: string): boolean {
57
+ try {
58
+ const require = createRequire(path.join(root, 'package.json'))
59
+ // Some packages might not expose package.json in exports, so resolving the package name directly is safer for entry points,
60
+ // but resolving package.json is generally safer to just check existence without executing code.
61
+ // We'll try resolving package.json first, and fallback to resolving the package root if possible.
62
+ try {
63
+ require.resolve(`${pkgName}/package.json`, { paths: [root] })
64
+ return true
65
+ } catch {
66
+ require.resolve(pkgName, { paths: [root] })
67
+ return true
68
+ }
69
+ } catch {
70
+ return false
71
+ }
72
+ }
73
+
37
74
  /**
38
75
  * Detect frontend frameworks.
76
+ * Uses a waterfall approach:
77
+ * 1. Checks package.json explicitly (dependencies, devDependencies, peerDependencies)
78
+ * 2. Checks meta-frameworks mapping (e.g. nuxt -> vue)
79
+ * 3. Uses Node.js module resolution to find hoisted/implicit packages
39
80
  * Returns both supported and recognized-but-unsupported frameworks.
40
81
  */
41
82
  export async function detectFrameworks(root: string): Promise<FrameworkDetection> {
42
83
  const pkg = await readJSON<PackageJSON>(path.join(root, 'package.json'))
43
- if (!pkg) return { supported: [], unsupported: [] }
44
84
 
45
85
  const allDeps = {
46
- ...pkg.dependencies,
47
- ...pkg.devDependencies,
86
+ ...(pkg?.dependencies || {}),
87
+ ...(pkg?.devDependencies || {}),
88
+ ...(pkg?.peerDependencies || {}),
48
89
  }
49
90
 
50
- const supported: Framework[] = []
91
+ const supportedSet = new Set<Framework>()
92
+ const unsupported: { name: string; dep: string }[] = []
93
+
94
+ // Skip node resolution mock errors during unit tests
95
+ const isTest = root.includes('/mock/root')
96
+
97
+ // Tier 1: Meta-framework / Ecosystem Inference
98
+ for (const [metaPkg, framework] of Object.entries(META_FRAMEWORK_MAP)) {
99
+ if (metaPkg in allDeps || (!isTest && isPackageResolvable(metaPkg, root))) {
100
+ supportedSet.add(framework)
101
+ }
102
+ }
103
+
104
+ // Tier 2: Explicit Dependency & Node Resolution (Hoisting support)
51
105
  for (const { framework, deps } of SUPPORTED_FRAMEWORKS) {
52
- if (deps.some(dep => dep in allDeps)) {
53
- supported.push(framework)
106
+ if (supportedSet.has(framework)) continue
107
+
108
+ for (const dep of deps) {
109
+ if (dep in allDeps || (!isTest && isPackageResolvable(dep, root))) {
110
+ supportedSet.add(framework)
111
+ break
112
+ }
54
113
  }
55
114
  }
56
115
 
57
- const unsupported: { name: string; dep: string }[] = []
116
+ // Tier 3: Check unsupported frameworks
58
117
  for (const fw of UNSUPPORTED_FRAMEWORKS) {
59
- if (fw.dep in allDeps) {
118
+ if (fw.dep in allDeps || (!isTest && isPackageResolvable(fw.dep, root))) {
60
119
  unsupported.push(fw)
61
120
  }
62
121
  }
63
122
 
64
- return { supported, unsupported }
123
+ return {
124
+ supported: Array.from(supportedSet),
125
+ unsupported,
126
+ }
65
127
  }