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

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.
@@ -14,149 +14,18 @@ import { detectPackageManager, getInstallCommand } from '../detect/package-manag
14
14
  import { detectBuildTools, resolveInjectionTarget } from '../detect/build-tool.js'
15
15
  import { detectFrameworks } from '../detect/framework.js'
16
16
  import { detectIDE } from '../detect/ide.js'
17
- import { detectAITools, type AIToolDetection } from '../detect/ai-tool.js'
17
+ 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, BuildToolDetection } from '../types.js'
22
-
23
- /**
24
- * Interactive prompt for IDE choice.
25
- */
26
- async function promptIDEChoice(
27
- detections: { ide: string; supported: boolean }[],
28
- ): Promise<{ ide: string; supported: boolean } | null> {
29
- if (!process.stdin.isTTY) {
30
- log.warn('Multiple IDEs detected but stdin is not interactive')
31
- log.hint(`Using: ${detections[0]!.ide} (first match)`)
32
- return detections[0]!
33
- }
34
-
35
- console.log()
36
- console.log(' ? Detected multiple IDEs:')
37
- detections.forEach((d, i) => {
38
- const status = d.supported ? ' (supported)' : ' (unsupported/limited)'
39
- console.log(` ${i + 1}. ${d.ide}${status}`)
40
- })
41
- console.log()
42
-
43
- return new Promise(resolve => {
44
- process.stdout.write(' > Your choice: ')
45
-
46
- // We must resume stdin in case it was paused by a previous prompt
47
- process.stdin.resume()
48
- process.stdin.setEncoding('utf-8')
49
-
50
- const onData = (data: Buffer) => {
51
- const choice = parseInt(String(data).trim(), 10)
52
-
53
- // Cleanup the listener to avoid memory leaks or multiple fires
54
- process.stdin.off('data', onData)
55
- process.stdin.pause() // Pause so the CLI can exit when done
56
-
57
- if (choice >= 1 && choice <= detections.length) {
58
- resolve(detections[choice - 1]!)
59
- } else {
60
- resolve(null)
61
- }
62
- }
63
-
64
- process.stdin.on('data', onData)
65
- })
66
- }
67
- /**
68
- * Interactive prompt for AI tool choice.
69
- */
70
- async function promptAIToolChoice(detections: AIToolDetection[]): Promise<AIToolDetection | null> {
71
- if (!process.stdin.isTTY) {
72
- log.warn('Multiple AI tools detected but stdin is not interactive')
73
- log.hint(`Using: ${detections[0]!.label} (first match)`)
74
- return detections[0]!
75
- }
76
-
77
- console.log()
78
- console.log(' ? Detected multiple AI tools:')
79
- detections.forEach((d, i) => {
80
- // Map toolModes array to human-readable labels
81
- const modeLabels = d.toolModes.map(mode =>
82
- mode === 'plugin' ? 'VS Code Extension' : 'Terminal CLI',
83
- )
84
- const modeStr = modeLabels.join(' & ')
85
-
86
- const status = d.supported ? ` (supported ${modeStr})` : ` (unsupported/limited)`
87
- console.log(` ${i + 1}. ${d.label}${status}`)
88
- })
89
- console.log()
90
-
91
- return new Promise(resolve => {
92
- process.stdout.write(' > Your choice: ')
93
-
94
- // We must resume stdin in case it was paused by a previous prompt
95
- process.stdin.resume()
96
- process.stdin.setEncoding('utf-8')
97
-
98
- const onData = (data: Buffer) => {
99
- const choice = parseInt(String(data).trim(), 10)
100
-
101
- // Cleanup the listener to avoid memory leaks or multiple fires
102
- process.stdin.off('data', onData)
103
- process.stdin.pause() // Pause so the CLI can exit when done
104
-
105
- if (choice >= 1 && choice <= detections.length) {
106
- resolve(detections[choice - 1]!)
107
- } else {
108
- resolve(null)
109
- }
110
- }
111
-
112
- process.stdin.on('data', onData)
113
- })
114
- }
115
-
116
- async function promptConfigChoice(
117
- detections: BuildToolDetection[],
118
- ): Promise<BuildToolDetection | null> {
119
- if (!process.stdin.isTTY) {
120
- log.warn('Multiple config files detected but stdin is not interactive')
121
- log.hint(`Using: ${detections[0]!.label} (first match)`)
122
- return detections[0]!
123
- }
124
-
125
- console.log()
126
- console.log(' ? Detected multiple build tool configs:')
127
- detections.forEach((d, i) => {
128
- // Determine recommendation based on whether it seems like the "main" one
129
- // We can infer Rsbuild > Rspack > Vite > Webpack as a priority if needed,
130
- // but without package.json scripts analysis, we just highlight them plainly.
131
- console.log(` ${i + 1}. ${d.label}`)
132
- })
133
- console.log(` ${detections.length + 1}. Skip (I'll configure manually)`)
134
- console.log()
135
-
136
- return new Promise(resolve => {
137
- process.stdout.write(' > Your choice: ')
138
-
139
- // We must resume stdin in case it was paused by a previous prompt
140
- process.stdin.resume()
141
- process.stdin.setEncoding('utf-8')
142
-
143
- const onData = (data: Buffer) => {
144
- const choice = parseInt(String(data).trim(), 10)
145
-
146
- // Cleanup the listener to avoid memory leaks or multiple fires
147
- process.stdin.off('data', onData)
148
- process.stdin.pause() // Pause so the CLI can exit when done
149
-
150
- if (choice >= 1 && choice <= detections.length) {
151
- resolve(detections[choice - 1]!)
152
- } else {
153
- resolve(null)
154
- }
155
- }
156
-
157
- process.stdin.on('data', onData)
158
- })
159
- }
21
+ import type { InitOptions, InstallLock, Mutation } from '../types.js'
22
+ import {
23
+ promptIDEChoice,
24
+ promptProviderChoice,
25
+ promptConfigChoice,
26
+ promptUnsupportedFrameworkContinue,
27
+ } from '../prompts.js'
28
+ import { printNextJsManualInstructions, printNuxtManualInstructions } from '../instructions.js'
160
29
 
161
30
  export async function init(options: InitOptions): Promise<void> {
162
31
  const root = process.cwd()
@@ -172,56 +41,75 @@ export async function init(options: InitOptions): Promise<void> {
172
41
  }
173
42
 
174
43
  // ---- Step 2: Detect environment ----
44
+ const [pm, frameworkResult, buildResult, ideProbe, providerProbe] = await Promise.all([
45
+ detectPackageManager(root),
46
+ detectFrameworks(root),
47
+ detectBuildTools(root),
48
+ detectIDE(root),
49
+ detectProviders(root),
50
+ ])
175
51
 
176
52
  // Package manager
177
- const pm = await detectPackageManager(root)
178
53
  log.success(`Detected package manager: ${pm}`)
179
54
 
180
- // Framework detection (supported + unsupported)
181
- const frameworkResult = await detectFrameworks(root)
55
+ // Framework verification
182
56
  if (frameworkResult.supported.length > 0) {
183
57
  log.success(`Detected framework: ${frameworkResult.supported.join(', ')}`)
184
58
  }
185
- if (frameworkResult.unsupported.length > 0) {
186
- const names = frameworkResult.unsupported.map(f => f.name).join(', ')
187
- log.warn(`Detected ${names} — not supported in v1 (React / Vue only)`)
188
- log.hint('Inspecto may still work but is not tested for this framework')
189
- }
190
- if (frameworkResult.supported.length === 0 && frameworkResult.unsupported.length === 0) {
191
- log.warn('No frontend framework detected')
192
- log.hint('Inspecto v1 supports React and Vue projects')
59
+
60
+ const hasUnsupportedFramework = frameworkResult.unsupported.length > 0
61
+ const hasNoFramework =
62
+ frameworkResult.supported.length === 0 && frameworkResult.unsupported.length === 0
63
+
64
+ if (hasUnsupportedFramework || hasNoFramework) {
65
+ if (hasUnsupportedFramework) {
66
+ const names = frameworkResult.unsupported.map(f => f.name).join(', ')
67
+ log.warn(`Detected ${names} — not supported in v1 (React / Vue only)`)
68
+ } else {
69
+ log.warn('No frontend framework detected')
70
+ log.hint('Inspecto current version supports React and Vue projects')
71
+ }
72
+
73
+ if (!options.force) {
74
+ const shouldContinue = await promptUnsupportedFrameworkContinue()
75
+ if (!shouldContinue) {
76
+ log.warn('Initialization aborted.')
77
+ return
78
+ }
79
+ } else {
80
+ log.warn('Continuing anyway (--force)')
81
+ }
193
82
  }
194
83
 
195
- // Build tool detection (supported + unsupported)
196
- const buildResult = await detectBuildTools(root)
84
+ // Build tool detection
85
+ let manualConfigRequiredFor = ''
197
86
  if (buildResult.supported.length > 0) {
198
87
  buildResult.supported.forEach(bt => log.success(`Detected: ${bt.label}`))
199
88
  }
200
89
  if (buildResult.unsupported.length > 0) {
201
90
  const names = buildResult.unsupported.join(', ')
202
- log.warn(`Detected ${names} not supported in v1`)
203
- log.hint('v1 supports: Vite, Webpack, Rspack, esbuild, Rollup')
204
- log.hint('Meta-framework support (Next.js, Nuxt, etc.) is planned for v2')
91
+ manualConfigRequiredFor = buildResult.unsupported[0] || ''
92
+ log.warn(`Detected ${names} automatic plugin injection is not supported in current version`)
93
+ log.hint('You can still manually configure it by modifying your configuration file')
205
94
  }
206
95
  if (buildResult.supported.length === 0 && buildResult.unsupported.length === 0) {
207
96
  log.warn('No recognized build tool detected')
208
- log.hint('v1 supports: Vite, Webpack, Rspack, esbuild, Rollup')
97
+ log.hint('current version supports: Vite, Webpack, Rspack, esbuild, Rollup')
209
98
  log.hint('Dependency will be installed but plugin injection will be skipped')
99
+ log.hint(
100
+ 'Please refer to the manual setup guide: https://inspecto.dev/docs/getting-started/manual-setup',
101
+ )
210
102
  }
211
103
 
212
104
  // IDE detection
213
- const ideProbe = await detectIDE(root)
214
105
  let selectedIDE: { ide: string; supported: boolean } | null = null
215
106
 
216
107
  if (ideProbe.detected.length === 0) {
217
108
  log.error('No IDE detected in current project')
218
109
  log.hint('Please open this project in a supported IDE (like VS Code)')
219
- // We could potentially block here, but we will allow the rest of the CLI to run
220
- // for settings generation and build config injection.
221
110
  } else if (ideProbe.detected.length === 1) {
222
111
  selectedIDE = ideProbe.detected[0]!
223
112
  } else {
224
- // Has multiple
225
113
  selectedIDE = await promptIDEChoice(ideProbe.detected)
226
114
  }
227
115
 
@@ -238,24 +126,21 @@ export async function init(options: InitOptions): Promise<void> {
238
126
  }
239
127
 
240
128
  // AI Tool detection
241
- const aiToolProbe = await detectAITools(root)
242
- let selectedAITool: AIToolDetection | null = null
129
+ let selectedProvider: ProviderDetection | null = null
243
130
 
244
- // If user passed --prefer, we trust it over probing, but if they didn't:
245
- if (!options.prefer) {
246
- if (aiToolProbe.detected.length === 0) {
131
+ if (!options.provider) {
132
+ if (providerProbe.detected.length === 0) {
247
133
  log.warn('No supported AI tools detected')
248
134
  log.hint('Inspecto works best with Claude Code, Trae CLI, or GitHub Copilot')
249
- } else if (aiToolProbe.detected.length === 1) {
250
- selectedAITool = aiToolProbe.detected[0]!
251
- if (selectedAITool.supported) {
252
- log.success(`Detected AI tool: ${selectedAITool.label}`)
135
+ } else if (providerProbe.detected.length === 1) {
136
+ selectedProvider = providerProbe.detected[0]!
137
+ if (selectedProvider.supported) {
138
+ log.success(`Detected AI tool: ${selectedProvider.label}`)
253
139
  }
254
140
  } else {
255
- // Has multiple AI Tools
256
- selectedAITool = await promptAIToolChoice(aiToolProbe.detected)
257
- if (selectedAITool) {
258
- log.success(`Selected AI tool: ${selectedAITool.label}`)
141
+ selectedProvider = await promptProviderChoice(providerProbe.detected)
142
+ if (selectedProvider) {
143
+ log.success(`Selected provider: ${selectedProvider.label}`)
259
144
  }
260
145
  }
261
146
  }
@@ -275,22 +160,12 @@ export async function init(options: InitOptions): Promise<void> {
275
160
  throw new Error(result.stderr)
276
161
  }
277
162
  log.success('Installed @inspecto-dev/plugin and @inspecto-dev/core as devDependencies')
278
- mutations.push({
279
- type: 'dependency_added',
280
- name: '@inspecto-dev/plugin',
281
- dev: true,
282
- })
283
- mutations.push({
284
- type: 'dependency_added',
285
- name: '@inspecto-dev/core',
286
- dev: true,
287
- })
163
+ mutations.push({ type: 'dependency_added', name: '@inspecto-dev/plugin', dev: true })
164
+ mutations.push({ type: 'dependency_added', name: '@inspecto-dev/core', dev: true })
288
165
  } catch (err: any) {
289
166
  installFailed = true
290
167
  log.error(`Failed to install dependency: ${err?.message || 'Unknown error'}`)
291
168
  log.hint(`Run manually: ${installCmd}`)
292
- // We do not return here to allow the rest of the setup to continue,
293
- // but we will show a warning at the end instead of the success message.
294
169
  }
295
170
  }
296
171
  }
@@ -319,68 +194,58 @@ export async function init(options: InitOptions): Promise<void> {
319
194
 
320
195
  // ---- Step 5: Generate default settings ----
321
196
  const settingsDir = path.join(root, '.inspecto')
322
- const settingsPath = path.join(settingsDir, 'settings.json')
323
- const promptsPath = path.join(settingsDir, 'prompts.json')
197
+ const settingsFileName = options.shared ? 'settings.json' : 'settings.local.json'
198
+ const promptsFileName = options.shared ? 'prompts.json' : 'prompts.local.json'
199
+
200
+ const settingsPath = path.join(settingsDir, settingsFileName)
201
+ const promptsPath = path.join(settingsDir, promptsFileName)
324
202
 
325
203
  if (await exists(settingsPath)) {
326
- // Attempt to read the existing settings file to see if it's valid JSON
327
204
  const existingSettings = await readJSON(settingsPath)
328
205
  if (existingSettings === null) {
329
- log.warn('.inspecto/settings.json exists but contains invalid JSON')
206
+ log.warn(`.inspecto/${settingsFileName} exists but contains invalid JSON`)
330
207
  log.hint('Please fix the syntax errors manually, or delete it and re-run init')
331
208
  } else {
332
- log.success('.inspecto/settings.json already exists (skipped)')
209
+ log.success(`.inspecto/${settingsFileName} already exists (skipped)`)
333
210
  }
334
211
  } else {
335
- // Only omit properties that can be auto-inferred
336
- // The schema allows empty objects or just specifying the IDE/prefer
337
212
  const defaultSettings: Record<string, unknown> = {}
338
213
 
339
214
  if (selectedIDE && selectedIDE.supported) {
340
- defaultSettings.ide = selectedIDE.ide === 'vscode' ? 'vscode' : selectedIDE.ide // Fallback string handling
215
+ defaultSettings.ide =
216
+ selectedIDE.ide.toLowerCase() === 'vscode' ? 'vscode' : selectedIDE.ide.toLowerCase()
341
217
  }
342
218
 
343
- if (options.prefer) {
344
- defaultSettings.prefer = options.prefer
345
- } else if (selectedAITool) {
346
- defaultSettings.prefer = selectedAITool.id
347
-
348
- // If the selected tool has a specific mode (like cli vs plugin), we can pre-configure the providers block
349
- // to ensure it uses the intended mode, providing an optimal out-of-the-box experience.
350
- if (selectedAITool.preferredMode) {
351
- defaultSettings.providers = {
352
- [selectedAITool.id]: {
353
- type: selectedAITool.preferredMode,
354
- },
355
- }
356
- }
219
+ if (options.provider) {
220
+ const tool = options.provider
221
+ const mode = tool === 'coco' ? 'cli' : 'extension'
222
+ defaultSettings['provider.default'] = `${tool}.${mode}`
223
+ } else if (selectedProvider) {
224
+ const toolId = selectedProvider.id as string
225
+ const mode = selectedProvider.preferredMode === 'cli' ? 'cli' : 'extension'
226
+ defaultSettings['provider.default'] = `${toolId}.${mode}`
357
227
  }
358
228
 
359
229
  if (options.dryRun) {
360
- log.dryRun('Would create .inspecto/settings.json')
230
+ log.dryRun(`Would create .inspecto/${settingsFileName}`)
361
231
  } else {
362
232
  await writeJSON(settingsPath, defaultSettings)
363
- log.success('Created .inspecto/settings.json')
364
- mutations.push({ type: 'file_created', path: '.inspecto/settings.json' })
233
+ log.success(`Created .inspecto/${settingsFileName}`)
234
+ mutations.push({ type: 'file_created', path: `.inspecto/${settingsFileName}` })
365
235
  }
366
236
  }
367
237
 
368
- // Generate prompts.json to disable low-frequency intents by default
369
238
  if (await exists(promptsPath)) {
370
- log.success('.inspecto/prompts.json already exists (skipped)')
239
+ log.success(`.inspecto/${promptsFileName} already exists (skipped)`)
371
240
  } else {
372
- const defaultPrompts = [
373
- { id: 'code-review', enabled: false },
374
- { id: 'generate-test', enabled: false },
375
- { id: 'performance', enabled: false },
376
- ]
241
+ const defaultPrompts: unknown[] = []
377
242
 
378
243
  if (options.dryRun) {
379
- log.dryRun('Would create .inspecto/prompts.json')
244
+ log.dryRun(`Would create .inspecto/${promptsFileName}`)
380
245
  } else {
381
246
  await writeJSON(promptsPath, defaultPrompts)
382
- log.success('Created .inspecto/prompts.json (disabling low-frequency intents)')
383
- mutations.push({ type: 'file_created', path: '.inspecto/prompts.json' })
247
+ log.success(`Created .inspecto/${promptsFileName}`)
248
+ mutations.push({ type: 'file_created', path: `.inspecto/${promptsFileName}` })
384
249
  }
385
250
  }
386
251
 
@@ -406,19 +271,17 @@ export async function init(options: InitOptions): Promise<void> {
406
271
  await writeJSON(path.join(settingsDir, 'install.lock'), lock)
407
272
  }
408
273
 
409
- // ---- Step 8: Install VS Code extension ----
410
- // Only attempt if IDE is VS Code or not detected (might be VS Code)
274
+ // ---- Step 8: Install IDE extension ----
411
275
  const shouldInstallExt =
412
276
  !options.noExtension && (!selectedIDE || (selectedIDE && selectedIDE.supported))
413
-
414
277
  let manualExtensionInstallNeeded = false
415
278
 
416
279
  if (options.noExtension) {
417
- log.warn('Skipping VS Code extension (--no-extension)')
280
+ log.warn('Skipping IDE extension (--no-extension)')
418
281
  } else if (!shouldInstallExt) {
419
- // Unsupported IDE detected — skip extension, already warned above
282
+ // Unsupported IDE detected — skip extension
420
283
  } else {
421
- const extMutation = await installExtension(options.dryRun)
284
+ const extMutation = await installExtension(options.dryRun, selectedIDE?.ide)
422
285
  if (extMutation && !options.dryRun) {
423
286
  mutations.push(extMutation)
424
287
 
@@ -441,10 +304,22 @@ export async function init(options: InitOptions): Promise<void> {
441
304
  if (options.dryRun) {
442
305
  log.blank()
443
306
  log.warn('Dry run complete. No files were modified.')
444
- } else if (installFailed || injectionFailed || manualExtensionInstallNeeded) {
307
+ } else if (
308
+ installFailed ||
309
+ injectionFailed ||
310
+ manualExtensionInstallNeeded ||
311
+ manualConfigRequiredFor
312
+ ) {
445
313
  log.blank()
446
314
  log.warn('Setup completed with some manual steps required.')
447
- log.hint('Please check the logs above and complete the manual steps.')
315
+
316
+ if (manualConfigRequiredFor === 'Nuxt') {
317
+ printNuxtManualInstructions()
318
+ } else if (manualConfigRequiredFor === 'Next.js') {
319
+ printNextJsManualInstructions()
320
+ } else {
321
+ log.hint('Please check the logs above and complete the manual steps.')
322
+ }
448
323
  log.blank()
449
324
  } else {
450
325
  log.ready('Ready! Hold Alt + Click any element to inspect.')
@@ -3,7 +3,7 @@
3
3
  // ============================================================
4
4
  import path from 'node:path'
5
5
  import { log } from '../utils/logger.js'
6
- import { exists, readJSON, copyFile, removeDir, removeFile } from '../utils/fs.js'
6
+ import { exists, readJSON, removeDir } from '../utils/fs.js'
7
7
  import { shell } from '../utils/exec.js'
8
8
  import { detectPackageManager, getUninstallCommand } from '../detect/package-manager.js'
9
9
  import { cleanGitignore } from '../inject/gitignore.js'
@@ -43,7 +43,7 @@ export async function teardown(): Promise<void> {
43
43
  log.success('Cleaned .gitignore entries')
44
44
 
45
45
  // Warn about config file
46
- log.warn('Cannot restore build config (no backup reference)')
46
+ log.warn('Cannot restore build config auto-magically')
47
47
  log.hint('Please manually remove the inspecto() plugin from your build config')
48
48
 
49
49
  log.blank()
@@ -57,27 +57,17 @@ export async function teardown(): Promise<void> {
57
57
  for (const mutation of lock.mutations) {
58
58
  switch (mutation.type) {
59
59
  case 'file_modified': {
60
- if (mutation.backup && mutation.path) {
61
- const backupPath = path.join(root, mutation.backup)
62
- const targetPath = path.join(root, mutation.path)
63
-
64
- if (mutation.path === '.gitignore') {
65
- // For .gitignore, use the clean function instead of backup restore
66
- await cleanGitignore(root)
67
- log.success('Cleaned .gitignore entries')
68
- // Remove the .gitignore backup if it exists
69
- await removeFile(backupPath)
70
- } else if (await exists(backupPath)) {
71
- await copyFile(backupPath, targetPath)
72
- await removeFile(backupPath)
73
- log.success(`Restored ${mutation.path} from backup`)
74
- } else {
75
- log.warn(`Backup not found: ${mutation.backup}`)
76
- log.hint(`Please manually remove inspecto from ${mutation.path}`)
77
- }
78
- } else if (mutation.path && mutation.path !== '.gitignore') {
79
- log.warn(`Cannot auto-restore ${mutation.path} (no backup recorded)`)
80
- log.hint(`Please manually remove the inspecto() plugin from ${mutation.path}`)
60
+ if (!mutation.path) break
61
+
62
+ if (mutation.path === '.gitignore') {
63
+ // gitignore is cleaned up at the end of teardown, so we can just log here
64
+ log.success('Cleaned .gitignore entries')
65
+ } else if (mutation.path) {
66
+ // We no longer create .bak files in the new AST approach.
67
+ // In a future update, we can use magicast to auto-remove the plugin from AST.
68
+ // For now, we print a manual warning since we did not backup the file.
69
+ log.warn(`Cannot auto-restore ${mutation.path}`)
70
+ log.hint(`Please manually remove the inspecto plugin from ${mutation.path}`)
81
71
  }
82
72
  break
83
73
  }
@@ -11,6 +11,7 @@ import type { BuildTool, BuildToolDetection } from '../types.js'
11
11
  interface PackageJSON {
12
12
  dependencies?: Record<string, string>
13
13
  devDependencies?: Record<string, string>
14
+ scripts?: Record<string, string>
14
15
  }
15
16
 
16
17
  /** Supported build tools in v1 */
@@ -37,7 +38,7 @@ const SUPPORTED_PATTERNS: { tool: BuildTool; files: string[]; label: string }[]
37
38
  },
38
39
  {
39
40
  tool: 'esbuild',
40
- files: ['esbuild.config.js', 'esbuild.config.ts', 'esbuild.config.mjs'],
41
+ files: ['esbuild.config.js', 'esbuild.config.ts', 'esbuild.config.mjs', 'build.js', 'build.ts'],
41
42
  label: 'esbuild',
42
43
  },
43
44
  {
@@ -73,39 +74,106 @@ export async function detectBuildTools(root: string): Promise<BuildToolResult> {
73
74
  const pkg = await readJSON<PackageJSON>(path.join(root, 'package.json'))
74
75
  const allDeps = { ...pkg?.dependencies, ...pkg?.devDependencies }
75
76
 
76
- for (const pattern of SUPPORTED_PATTERNS) {
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
+ }
95
+
77
96
  for (const file of pattern.files) {
78
97
  if (await exists(path.join(root, file))) {
79
- let isLegacyRspack = false
80
- if (pattern.tool === 'rspack') {
81
- const version = allDeps['@rspack/cli'] || allDeps['@rspack/core']
82
- if (
83
- version &&
84
- (version.includes('0.3.') || version.includes('0.2.') || version.includes('0.1.'))
85
- ) {
86
- isLegacyRspack = true
98
+ detectedFile = file
99
+ break
100
+ }
101
+ }
102
+
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
+ }
87
116
  }
117
+ } else if (cmd.includes(`${pattern.tool} `)) {
118
+ detectedFile = 'package.json (scripts)'
119
+ break
88
120
  }
121
+ }
122
+ }
123
+
124
+ if (detectedFile) {
125
+ let isLegacyRspack = false
126
+ let isLegacyWebpack = false
89
127
 
90
- supported.push({
91
- tool: pattern.tool,
92
- configPath: file,
93
- label: `${pattern.label} (${file})${isLegacyRspack ? ' [Legacy]' : ''}`,
94
- isLegacyRspack,
95
- })
96
- break // One match per tool
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
140
+ }
97
141
  }
142
+
143
+ return {
144
+ tool: pattern.tool,
145
+ configPath: detectedFile,
146
+ label: `${pattern.label} (${detectedFile})${isLegacyRspack ? ' [Legacy]' : ''}${isLegacyWebpack ? ' [Webpack 4]' : ''}`,
147
+ isLegacyRspack,
148
+ isLegacyWebpack,
149
+ }
150
+ }
151
+
152
+ return null
153
+ })
154
+
155
+ const supportedResults = await Promise.all(supportedChecks)
156
+ for (const result of supportedResults) {
157
+ if (result) {
158
+ supported.push(result)
98
159
  }
99
160
  }
100
161
 
101
- for (const meta of UNSUPPORTED_META) {
102
- if (!(meta.dep in allDeps)) continue
162
+ const unsupportedChecks = UNSUPPORTED_META.map(async meta => {
163
+ if (!(meta.dep in allDeps)) return null
103
164
  for (const file of meta.files) {
104
165
  if (await exists(path.join(root, file))) {
105
- unsupported.push(meta.name)
106
- break
166
+ return meta.name
107
167
  }
108
168
  }
169
+ return null
170
+ })
171
+
172
+ const unsupportedResults = await Promise.all(unsupportedChecks)
173
+ for (const result of unsupportedResults) {
174
+ if (result) {
175
+ unsupported.push(result)
176
+ }
109
177
  }
110
178
 
111
179
  return { supported, unsupported }