@inspecto-dev/cli 0.2.0-alpha.0 → 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
  }
@@ -265,7 +150,7 @@ export async function init(options: InitOptions): Promise<void> {
265
150
  if (options.skipInstall) {
266
151
  log.warn('Skipping dependency installation (--skip-install)')
267
152
  } else {
268
- const installCmd = getInstallCommand(pm, '@inspecto-dev/plugin')
153
+ const installCmd = getInstallCommand(pm, '@inspecto-dev/plugin @inspecto-dev/core')
269
154
  if (options.dryRun) {
270
155
  log.dryRun(`Would run: ${installCmd}`)
271
156
  } else {
@@ -274,18 +159,13 @@ export async function init(options: InitOptions): Promise<void> {
274
159
  if (result.stderr && result.stderr.toLowerCase().includes('error')) {
275
160
  throw new Error(result.stderr)
276
161
  }
277
- log.success('Installed @inspecto-dev/plugin as devDependency')
278
- mutations.push({
279
- type: 'dependency_added',
280
- name: '@inspecto-dev/plugin',
281
- dev: true,
282
- })
162
+ log.success('Installed @inspecto-dev/plugin and @inspecto-dev/core as devDependencies')
163
+ mutations.push({ type: 'dependency_added', name: '@inspecto-dev/plugin', dev: true })
164
+ mutations.push({ type: 'dependency_added', name: '@inspecto-dev/core', dev: true })
283
165
  } catch (err: any) {
284
166
  installFailed = true
285
167
  log.error(`Failed to install dependency: ${err?.message || 'Unknown error'}`)
286
168
  log.hint(`Run manually: ${installCmd}`)
287
- // We do not return here to allow the rest of the setup to continue,
288
- // but we will show a warning at the end instead of the success message.
289
169
  }
290
170
  }
291
171
  }
@@ -314,68 +194,58 @@ export async function init(options: InitOptions): Promise<void> {
314
194
 
315
195
  // ---- Step 5: Generate default settings ----
316
196
  const settingsDir = path.join(root, '.inspecto')
317
- const settingsPath = path.join(settingsDir, 'settings.json')
318
- 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)
319
202
 
320
203
  if (await exists(settingsPath)) {
321
- // Attempt to read the existing settings file to see if it's valid JSON
322
204
  const existingSettings = await readJSON(settingsPath)
323
205
  if (existingSettings === null) {
324
- log.warn('.inspecto/settings.json exists but contains invalid JSON')
206
+ log.warn(`.inspecto/${settingsFileName} exists but contains invalid JSON`)
325
207
  log.hint('Please fix the syntax errors manually, or delete it and re-run init')
326
208
  } else {
327
- log.success('.inspecto/settings.json already exists (skipped)')
209
+ log.success(`.inspecto/${settingsFileName} already exists (skipped)`)
328
210
  }
329
211
  } else {
330
- // Only omit properties that can be auto-inferred
331
- // The schema allows empty objects or just specifying the IDE/prefer
332
212
  const defaultSettings: Record<string, unknown> = {}
333
213
 
334
214
  if (selectedIDE && selectedIDE.supported) {
335
- defaultSettings.ide = selectedIDE.ide === 'vscode' ? 'vscode' : selectedIDE.ide // Fallback string handling
215
+ defaultSettings.ide =
216
+ selectedIDE.ide.toLowerCase() === 'vscode' ? 'vscode' : selectedIDE.ide.toLowerCase()
336
217
  }
337
218
 
338
- if (options.prefer) {
339
- defaultSettings.prefer = options.prefer
340
- } else if (selectedAITool) {
341
- defaultSettings.prefer = selectedAITool.id
342
-
343
- // If the selected tool has a specific mode (like cli vs plugin), we can pre-configure the providers block
344
- // to ensure it uses the intended mode, providing an optimal out-of-the-box experience.
345
- if (selectedAITool.preferredMode) {
346
- defaultSettings.providers = {
347
- [selectedAITool.id]: {
348
- type: selectedAITool.preferredMode,
349
- },
350
- }
351
- }
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}`
352
227
  }
353
228
 
354
229
  if (options.dryRun) {
355
- log.dryRun('Would create .inspecto/settings.json')
230
+ log.dryRun(`Would create .inspecto/${settingsFileName}`)
356
231
  } else {
357
232
  await writeJSON(settingsPath, defaultSettings)
358
- log.success('Created .inspecto/settings.json')
359
- mutations.push({ type: 'file_created', path: '.inspecto/settings.json' })
233
+ log.success(`Created .inspecto/${settingsFileName}`)
234
+ mutations.push({ type: 'file_created', path: `.inspecto/${settingsFileName}` })
360
235
  }
361
236
  }
362
237
 
363
- // Generate prompts.json to disable low-frequency intents by default
364
238
  if (await exists(promptsPath)) {
365
- log.success('.inspecto/prompts.json already exists (skipped)')
239
+ log.success(`.inspecto/${promptsFileName} already exists (skipped)`)
366
240
  } else {
367
- const defaultPrompts = [
368
- { id: 'code-review', enabled: false },
369
- { id: 'generate-test', enabled: false },
370
- { id: 'performance', enabled: false },
371
- ]
241
+ const defaultPrompts: unknown[] = []
372
242
 
373
243
  if (options.dryRun) {
374
- log.dryRun('Would create .inspecto/prompts.json')
244
+ log.dryRun(`Would create .inspecto/${promptsFileName}`)
375
245
  } else {
376
246
  await writeJSON(promptsPath, defaultPrompts)
377
- log.success('Created .inspecto/prompts.json (disabling low-frequency intents)')
378
- mutations.push({ type: 'file_created', path: '.inspecto/prompts.json' })
247
+ log.success(`Created .inspecto/${promptsFileName}`)
248
+ mutations.push({ type: 'file_created', path: `.inspecto/${promptsFileName}` })
379
249
  }
380
250
  }
381
251
 
@@ -401,19 +271,17 @@ export async function init(options: InitOptions): Promise<void> {
401
271
  await writeJSON(path.join(settingsDir, 'install.lock'), lock)
402
272
  }
403
273
 
404
- // ---- Step 8: Install VS Code extension ----
405
- // Only attempt if IDE is VS Code or not detected (might be VS Code)
274
+ // ---- Step 8: Install IDE extension ----
406
275
  const shouldInstallExt =
407
276
  !options.noExtension && (!selectedIDE || (selectedIDE && selectedIDE.supported))
408
-
409
277
  let manualExtensionInstallNeeded = false
410
278
 
411
279
  if (options.noExtension) {
412
- log.warn('Skipping VS Code extension (--no-extension)')
280
+ log.warn('Skipping IDE extension (--no-extension)')
413
281
  } else if (!shouldInstallExt) {
414
- // Unsupported IDE detected — skip extension, already warned above
282
+ // Unsupported IDE detected — skip extension
415
283
  } else {
416
- const extMutation = await installExtension(options.dryRun)
284
+ const extMutation = await installExtension(options.dryRun, selectedIDE?.ide)
417
285
  if (extMutation && !options.dryRun) {
418
286
  mutations.push(extMutation)
419
287
 
@@ -436,10 +304,22 @@ export async function init(options: InitOptions): Promise<void> {
436
304
  if (options.dryRun) {
437
305
  log.blank()
438
306
  log.warn('Dry run complete. No files were modified.')
439
- } else if (installFailed || injectionFailed || manualExtensionInstallNeeded) {
307
+ } else if (
308
+ installFailed ||
309
+ injectionFailed ||
310
+ manualExtensionInstallNeeded ||
311
+ manualConfigRequiredFor
312
+ ) {
440
313
  log.blank()
441
314
  log.warn('Setup completed with some manual steps required.')
442
- 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
+ }
443
323
  log.blank()
444
324
  } else {
445
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 }