@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.
package/src/detect/ide.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  // ============================================================
2
- // src/detect/ide.ts — IDE detection (v1: VS Code only)
2
+ // src/detect/ide.ts — IDE detection
3
3
  //
4
- // Detects ALL known IDEs, but only VS Code is fully supported.
4
+ // Detects ALL known IDEs, but only VS Code, Cursor, and Trae are fully supported.
5
5
  // Others are recognized and surfaced as unsupported.
6
6
  // ============================================================
7
7
  import path from 'node:path'
@@ -28,7 +28,7 @@ export async function detectIDE(root: string): Promise<IDEProbeResult> {
28
28
 
29
29
  // Cursor
30
30
  if (process.env.CURSOR_TRACE_DIR || process.env.CURSOR_CHANNEL) {
31
- detected.set('Cursor', { ide: 'Cursor', supported: false })
31
+ detected.set('Cursor', { ide: 'cursor', supported: true })
32
32
  }
33
33
 
34
34
  // Trae
@@ -38,7 +38,7 @@ export async function detectIDE(root: string): Promise<IDEProbeResult> {
38
38
  process.env.COCO_IDE_PLUGIN_TYPE === 'Trae' ||
39
39
  (process.env.npm_config_user_agent && process.env.npm_config_user_agent.includes('trae'))
40
40
  ) {
41
- detected.set('Trae', { ide: 'Trae', supported: false })
41
+ detected.set('Trae', { ide: 'trae', supported: true })
42
42
  }
43
43
 
44
44
  // Zed
@@ -46,32 +46,53 @@ export async function detectIDE(root: string): Promise<IDEProbeResult> {
46
46
  detected.set('Zed', { ide: 'Zed', supported: false })
47
47
  }
48
48
 
49
- // VS Code
50
- // Must ensure we haven't already flagged it as Trae/Cursor since they share this variable
51
- if (process.env.TERM_PROGRAM === 'vscode') {
52
- if (!detected.has('Trae') && !detected.has('Cursor')) {
53
- detected.set('vscode', { ide: SUPPORTED_IDE, supported: true })
54
- }
49
+ // Windsurf
50
+ if (
51
+ process.env.WINDSURF_APP_DIR ||
52
+ process.env.WINDSURF_CHANNEL ||
53
+ process.env.__CFBundleIdentifier === 'com.codeium.windsurf' ||
54
+ (process.env.npm_config_user_agent && process.env.npm_config_user_agent.includes('windsurf'))
55
+ ) {
56
+ detected.set('Windsurf', { ide: 'Windsurf', supported: false })
55
57
  }
56
58
 
59
+ // VS Code
60
+ // We cannot rely purely on TERM_PROGRAM === 'vscode' because Cursor and Trae also use it.
61
+ // We should ONLY use it as a fallback if no other specific variables were found, and we'll do that at the end.
62
+ // if (process.env.TERM_PROGRAM === 'vscode') { ... }
63
+
57
64
  // 2. Check Directory Artifacts (Indicates project has been opened in these IDEs)
58
- if (await exists(path.join(root, '.trae'))) {
59
- detected.set('Trae', { ide: 'Trae', supported: false })
65
+ const [hasTrae, hasCursor, hasVscode, hasIdea] = await Promise.all([
66
+ exists(path.join(root, '.trae')),
67
+ exists(path.join(root, '.cursor')),
68
+ exists(path.join(root, '.vscode')),
69
+ exists(path.join(root, '.idea')),
70
+ ])
71
+
72
+ // If a directory artifact exists, add it to the detection list.
73
+ // This allows us to surface multiple options (e.g. if you are in Cursor but also have a .vscode folder).
74
+ if (hasTrae && !detected.has('Trae')) {
75
+ detected.set('Trae', { ide: 'trae', supported: true })
60
76
  }
61
- if (await exists(path.join(root, '.cursor'))) {
62
- detected.set('Cursor', { ide: 'Cursor', supported: false })
77
+ if (hasCursor && !detected.has('Cursor')) {
78
+ detected.set('Cursor', { ide: 'cursor', supported: true })
63
79
  }
64
- if (await exists(path.join(root, '.vscode'))) {
65
- // Only add vscode if it wasn't already caught by terminal,
66
- // or if the terminal is something else but .vscode exists.
67
- if (!detected.has('vscode')) {
68
- detected.set('vscode', { ide: SUPPORTED_IDE, supported: true })
69
- }
80
+
81
+ // Only add vscode based on .vscode directory if it wasn't already added.
82
+ if (hasVscode && !detected.has('vscode')) {
83
+ detected.set('vscode', { ide: SUPPORTED_IDE, supported: true })
70
84
  }
71
- if (await exists(path.join(root, '.idea'))) {
85
+
86
+ if (hasIdea && !detected.has('JetBrains IDE')) {
72
87
  detected.set('JetBrains IDE', { ide: 'JetBrains IDE', supported: false })
73
88
  }
74
89
 
90
+ // Fallback: If we still don't have ANY IDE detected, but we are in a terminal that identifies as 'vscode'
91
+ // (which could be Cursor without its specific env vars, though rare), we fallback to 'vscode'
92
+ if (detected.size === 0 && process.env.TERM_PROGRAM === 'vscode') {
93
+ detected.set('vscode', { ide: SUPPORTED_IDE, supported: true })
94
+ }
95
+
75
96
  return {
76
97
  detected: Array.from(detected.values()),
77
98
  }
@@ -18,9 +18,16 @@ export async function detectPackageManager(root: string): Promise<PackageManager
18
18
  ['package-lock.json', 'npm'],
19
19
  ]
20
20
 
21
- for (const [file, pm] of checks) {
22
- if (await exists(path.join(root, file))) {
23
- return pm
21
+ const results = await Promise.all(
22
+ checks.map(async ([file, pm]) => {
23
+ const isExist = await exists(path.join(root, file))
24
+ return { isExist, pm }
25
+ }),
26
+ )
27
+
28
+ for (const result of results) {
29
+ if (result.isExist) {
30
+ return result.pm
24
31
  }
25
32
  }
26
33
 
@@ -0,0 +1,151 @@
1
+ // ============================================================
2
+ // src/detect/provider.ts — AI Provider detection (v1)
3
+ //
4
+ // Detects installed AI providers via PATH (CLI) or IDE extensions (Extension).
5
+ // ============================================================
6
+ import path from 'node:path'
7
+ import { exists, readJSON } from '../utils/fs.js'
8
+ import { which } from '../utils/exec.js'
9
+ import type { Provider, ProviderMode } from '@inspecto-dev/types'
10
+
11
+ export interface ProviderDetection {
12
+ id: Provider
13
+ label: string
14
+ supported: boolean
15
+ providerModes: Array<'cli' | 'extension'>
16
+ // The primary mode that will be written to settings if selected
17
+ preferredMode: 'cli' | 'extension'
18
+ }
19
+
20
+ const KNOWN_CLI_TOOLS: { id: Provider; bin: string; label: string; supported: boolean }[] = [
21
+ { id: 'claude-code', bin: 'claude', label: 'Claude Code', supported: true },
22
+ { id: 'coco', bin: 'coco', label: 'Trae CLI (Coco)', supported: true },
23
+ { id: 'codex', bin: 'codex', label: 'Codex CLI', supported: true },
24
+ { id: 'gemini', bin: 'gemini', label: 'Gemini CLI', supported: true },
25
+ ]
26
+
27
+ const KNOWN_IDE_PLUGINS: { id: Provider; extId: string; label: string; supported: boolean }[] = [
28
+ { id: 'claude-code', extId: 'anthropic.claude-code', label: 'Claude Code', supported: true },
29
+ { id: 'copilot', extId: 'github.copilot', label: 'GitHub Copilot', supported: true },
30
+ { id: 'codex', extId: 'openai.chatgpt', label: 'Codex (ChatGPT)', supported: true },
31
+ { id: 'gemini', extId: 'google.geminicodeassist', label: 'Gemini Code Assist', supported: true },
32
+ ]
33
+
34
+ export interface ProviderProbeResult {
35
+ detected: ProviderDetection[]
36
+ }
37
+
38
+ /**
39
+ * Detect all installed AI tools by checking PATH binaries and IDE extensions.
40
+ */
41
+ export async function detectProviders(root: string): Promise<ProviderProbeResult> {
42
+ // Use a map to merge duplicate CLI/Plugin detections for the same AI tool
43
+ const detectedMap = new Map<Provider, ProviderDetection>()
44
+
45
+ // 1. Detect CLI tools concurrently
46
+ const cliChecks = KNOWN_CLI_TOOLS.map(async tool => {
47
+ if (await which(tool.bin)) {
48
+ detectedMap.set(tool.id, {
49
+ id: tool.id,
50
+ label: tool.label,
51
+ supported: tool.supported,
52
+ providerModes: ['cli'],
53
+ preferredMode: 'cli',
54
+ })
55
+ }
56
+ })
57
+ await Promise.all(cliChecks)
58
+
59
+ // 2. Detect IDE plugins (VS Code extensions)
60
+ // Check the local workspace .vscode/extensions.json first (recommendations)
61
+ const extensionsJsonPath = path.join(root, '.vscode', 'extensions.json')
62
+ let recommendedExts: string[] = []
63
+ if (await exists(extensionsJsonPath)) {
64
+ try {
65
+ const extData = await readJSON<{ recommendations?: string[] }>(extensionsJsonPath)
66
+ if (extData && Array.isArray(extData.recommendations)) {
67
+ recommendedExts = extData.recommendations.map(e => e.toLowerCase())
68
+ }
69
+ } catch {
70
+ // ignore JSON parse errors here
71
+ }
72
+ }
73
+
74
+ // Check user's global VS Code extensions folder once
75
+ const homeDir = process.env.HOME || process.env.USERPROFILE || ''
76
+ const globalExtDir = path.join(homeDir, '.vscode', 'extensions')
77
+ const globalExtExists = await exists(globalExtDir)
78
+ let installedExtensionFolders: string[] = []
79
+
80
+ if (globalExtExists) {
81
+ try {
82
+ const { readdir } = await import('node:fs/promises')
83
+ installedExtensionFolders = await readdir(globalExtDir)
84
+
85
+ // Filter out obsolete extensions
86
+ const obsoletePath = path.join(globalExtDir, '.obsolete')
87
+ if (await exists(obsoletePath)) {
88
+ try {
89
+ const obsoleteData = await readJSON<Record<string, boolean>>(obsoletePath)
90
+ if (obsoleteData) {
91
+ const obsoleteKeys = Object.keys(obsoleteData)
92
+ installedExtensionFolders = installedExtensionFolders.filter(folder => {
93
+ // The folder name usually contains the version, e.g., 'github.copilot-1.2.3'
94
+ // but the obsolete key might just be the exact folder name
95
+ return !obsoleteKeys.includes(folder)
96
+ })
97
+ }
98
+ } catch {
99
+ // Ignore parse errors for .obsolete
100
+ }
101
+ }
102
+ } catch {
103
+ // Fallback or ignore
104
+ }
105
+ }
106
+
107
+ // Check all IDE plugins
108
+ for (const plugin of KNOWN_IDE_PLUGINS) {
109
+ let isInstalled = false
110
+
111
+ // Check if it's explicitly recommended in the workspace
112
+ if (recommendedExts.includes(plugin.extId.toLowerCase())) {
113
+ isInstalled = true
114
+ }
115
+ // Otherwise check our pre-fetched global extensions list
116
+ else if (
117
+ installedExtensionFolders.some(f => {
118
+ const lower = f.toLowerCase()
119
+ return (
120
+ lower === plugin.extId.toLowerCase() || lower.startsWith(plugin.extId.toLowerCase() + '-')
121
+ )
122
+ })
123
+ ) {
124
+ // NOTE: We used to just check if the folder exists, but when a VS Code extension
125
+ // is uninstalled, the folder is not immediately removed. Instead, VS Code creates
126
+ // an `.obsolete` file in the `.vscode/extensions` directory containing the keys of
127
+ // uninstalled extensions, or it renames the extension folder.
128
+ isInstalled = true
129
+ }
130
+
131
+ if (isInstalled) {
132
+ // If we already detected the CLI version of this tool, we append 'extension' to the modes
133
+ // and set the preferredMode to 'extension' since extension integration is generally more seamless.
134
+ const existing = detectedMap.get(plugin.id)
135
+ if (existing) {
136
+ existing.providerModes.push('extension')
137
+ existing.preferredMode = 'extension'
138
+ } else {
139
+ detectedMap.set(plugin.id, {
140
+ id: plugin.id,
141
+ label: plugin.label,
142
+ supported: plugin.supported,
143
+ providerModes: ['extension'],
144
+ preferredMode: 'extension',
145
+ })
146
+ }
147
+ }
148
+ }
149
+
150
+ return { detected: Array.from(detectedMap.values()) }
151
+ }
@@ -1,181 +1,53 @@
1
1
  // ============================================================
2
2
  // src/inject/ast-injector.ts — Safe plugin injection (v1)
3
3
  //
4
- // v1 scope: Standard `plugins: [...]` array injection.
5
- // Covers Vite / Webpack / Rspack / esbuild / Rollup configs.
6
- // Meta-framework special strategies (Next.js, Nuxt) deferred.
4
+ // Implements the Strategy Pattern to delegate AST manipulation
5
+ // and manual instruction generation to specific build tool strategies.
7
6
  //
8
7
  // Safety protocol:
9
- // 1. Backup original file (.bak)
8
+ // 1. Parse config file safely
10
9
  // 2. Idempotency check (skip if already injected)
11
- // 3. Attempt regex-based injection
12
- // 4. Basic syntax validation
13
- // 5. Graceful degradation on any failure
10
+ // 3. Find matching strategy
11
+ // 4. Delegate injection to strategy
12
+ // 5. Write modified configuration back to file
13
+ // 6. Fallback to manual instructions if automatic modification fails
14
14
  // ============================================================
15
15
 
16
16
  import path from 'node:path'
17
- import { exists, readFile, writeFile, copyFile } from '../utils/fs.js'
17
+ import { loadFile, writeFile as writeAstFile } from 'magicast'
18
+ import { exists, readFile } from '../utils/fs.js'
18
19
  import { log } from '../utils/logger.js'
19
- import type { BuildTool, BuildToolDetection, Mutation } from '../types.js'
20
-
21
- // ---- Import & plugin expression per build tool ----
22
-
23
- const IMPORT_MAP: Record<BuildTool, string> = {
24
- vite: `import { vitePlugin as inspecto } from '@inspecto-dev/plugin'`,
25
- webpack: `import { webpackPlugin as inspecto } from '@inspecto-dev/plugin'`,
26
- rspack: `import { rspackPlugin as inspecto } from '@inspecto-dev/plugin'`,
27
- rsbuild: `import { rspackPlugin as inspecto } from '@inspecto-dev/plugin'`,
28
- esbuild: `import { esbuildPlugin as inspecto } from '@inspecto-dev/plugin'`,
29
- rollup: `import { rollupPlugin as inspecto } from '@inspecto-dev/plugin'`,
30
- }
31
-
32
- function getImportStatement(tool: BuildTool, isLegacyRspack?: boolean): string {
33
- if (tool === 'rspack' && isLegacyRspack) {
34
- return `import { rspackPlugin as inspecto } from '@inspecto-dev/plugin/legacy/rspack'`
35
- }
36
- return IMPORT_MAP[tool]
37
- }
38
-
39
- function getPluginExpression(isLegacyRspack?: boolean): string {
40
- if (isLegacyRspack) {
41
- return `process.env.NODE_ENV !== 'production' && inspecto({
42
- pathType: 'absolute',
43
- escapeTags: ['Transition', 'AnimatePresence'],
44
- }) as any`
45
- }
46
- return `process.env.NODE_ENV !== 'production' && inspecto()`
47
- }
48
-
49
- // ---- Manual fallback instructions ----
20
+ import type { BuildToolDetection, Mutation } from '../types.js'
21
+ import { STRATEGIES } from './strategies/index.js'
22
+ import type { InjectStrategy } from './strategies/types.js'
50
23
 
51
24
  function printManualInstructions(
52
- tool: BuildTool,
53
- configPath: string,
25
+ strategy: InjectStrategy | undefined,
26
+ detection: BuildToolDetection,
54
27
  reason: string,
55
- isLegacyRspack?: boolean,
56
28
  ) {
57
- const isRsbuild = tool === 'rsbuild'
58
-
59
- log.warn(`Could not safely auto-inject into ${configPath}`)
29
+ log.warn(`Could not automatically configure ${detection.configPath}`)
60
30
  log.hint(`(reason: ${reason})`)
61
31
  log.blank()
62
32
  log.hint('Please add the following manually:')
63
33
 
64
- if (isRsbuild) {
65
- log.codeBlock([
66
- getImportStatement(tool, isLegacyRspack),
67
- '',
68
- '// Add to tools.rspack:',
69
- `tools: {`,
70
- ` rspack: {`,
71
- ` plugins: [`,
72
- ` ${getPluginExpression(isLegacyRspack)},`,
73
- ` ]`,
74
- ` }`,
75
- `}`,
76
- ])
34
+ if (strategy) {
35
+ const instructions = strategy.getManualInstructions(detection, reason)
36
+ log.codeBlock(instructions)
77
37
  } else {
78
- log.codeBlock([
79
- getImportStatement(tool, isLegacyRspack),
80
- '',
81
- '// Add to your plugins array:',
82
- `plugins: [`,
83
- ` ${getPluginExpression(isLegacyRspack)},`,
84
- ` ...otherPlugins`,
85
- `].filter(Boolean)`,
86
- ])
38
+ log.error(`Unsupported build tool: ${detection.tool}`)
87
39
  }
88
40
  }
89
41
 
90
- // ---- Injection helpers ----
91
-
92
42
  /** Check if inspecto is already injected (idempotency). */
93
43
  function isAlreadyInjected(content: string): boolean {
44
+ // Use regex to avoid false positives in comments or variables
94
45
  return (
95
- content.includes('@inspecto-dev/plugin') ||
96
- content.includes('inspecto()') ||
97
- content.includes('aiDevInspector')
98
- ) // Legacy support
99
- }
100
-
101
- /** Inject import statement after the last existing import. */
102
- function injectImport(content: string, importStmt: string): string {
103
- const importRegex = /^import\s.+$/gm
104
- let lastImportEnd = 0
105
- let match: RegExpExecArray | null
106
-
107
- while ((match = importRegex.exec(content)) !== null) {
108
- const lineEnd = content.indexOf('\n', match.index)
109
- if (lineEnd > lastImportEnd) {
110
- lastImportEnd = lineEnd
111
- }
112
- }
113
-
114
- // Also handle CJS require patterns
115
- if (lastImportEnd === 0) {
116
- const requireRegex = /^(?:const|let|var)\s.+=\s*require\(.+\).*$/gm
117
- while ((match = requireRegex.exec(content)) !== null) {
118
- const lineEnd = content.indexOf('\n', match.index)
119
- if (lineEnd > lastImportEnd) {
120
- lastImportEnd = lineEnd
121
- }
122
- }
123
- }
124
-
125
- if (lastImportEnd > 0) {
126
- return content.slice(0, lastImportEnd) + '\n' + importStmt + content.slice(lastImportEnd)
127
- }
128
-
129
- // No imports found, add at the beginning
130
- return importStmt + '\n\n' + content
131
- }
132
-
133
- /**
134
- * Core injection strategy.
135
- * For most tools: find `plugins: [` and insert after `[`.
136
- * For rsbuild: find `tools: { rspack: { plugins: [` or try to add it.
137
- */
138
- function injectIntoPluginsArray(content: string, detection: BuildToolDetection): string | null {
139
- const tool = detection.tool
140
- if (tool === 'rsbuild') {
141
- // rsbuild needs to inject into tools.rspack.plugins or tools.rspack(config, { appendPlugins })
142
- // Due to the complexity of rsbuild config, we'll try a simple regex for `tools: { rspack: { plugins: [`
143
- // If that fails, we fallback to manual instructions which are explicitly for rsbuild.
144
-
145
- // Check if tools.rspack exists
146
- if (!content.includes('tools:') && !content.includes('rspack:')) {
147
- // Very basic config, we can try to inject before the closing brace of defineConfig
148
- const exportRegex = /(export default defineConfig\(\{)/
149
- const match = exportRegex.exec(content)
150
- if (match) {
151
- const insertPos = match.index + match[0].length
152
- const pluginExpr = `\n tools: {\n rspack: {\n plugins: [\n ${getPluginExpression(detection.isLegacyRspack)}\n ]\n }\n },`
153
- return content.slice(0, insertPos) + pluginExpr + content.slice(insertPos)
154
- }
155
- }
156
-
157
- // Otherwise it's too complex for simple regex, force manual degradation
158
- return null
159
- }
160
-
161
- const pluginsRegex = /(plugins\s*:\s*\[)/
162
- const match = pluginsRegex.exec(content)
163
- if (!match) return null
164
-
165
- const insertPos = match.index + match[0].length
166
- const pluginExpr = `\n ${getPluginExpression(detection.isLegacyRspack)},`
167
-
168
- return content.slice(0, insertPos) + pluginExpr + content.slice(insertPos)
169
- }
170
-
171
- /** Basic bracket-balance validation. */
172
- function validateBrackets(content: string): boolean {
173
- const openBraces = (content.match(/\{/g) || []).length
174
- const closeBraces = (content.match(/\}/g) || []).length
175
- const openBrackets = (content.match(/\[/g) || []).length
176
- const closeBrackets = (content.match(/\]/g) || []).length
177
-
178
- return Math.abs(openBraces - closeBraces) <= 1 && Math.abs(openBrackets - closeBrackets) <= 1
46
+ /import\s+.*@inspecto-dev\/plugin/.test(content) ||
47
+ /require\(['"]@inspecto-dev\/plugin['"]\)/.test(content) ||
48
+ /import\s+.*ai-dev-inspector/.test(content) || // Legacy support
49
+ /require\(['"]ai-dev-inspector['"]\)/.test(content)
50
+ )
179
51
  }
180
52
 
181
53
  // ---- Main injection orchestrator ----
@@ -192,109 +64,76 @@ export async function injectPlugin(
192
64
  dryRun: boolean,
193
65
  ): Promise<InjectionResult> {
194
66
  const configPath = path.join(root, detection.configPath)
195
- const backupPath = configPath + '.bak'
196
67
  const mutations: Mutation[] = []
197
68
 
198
- // Step 1: Read config file
69
+ const strategy = STRATEGIES.find(s => s.supports(detection.tool))
70
+
71
+ // Step 1: Read config file to check existence and idempotency
199
72
  const content = await readFile(configPath)
200
73
  if (!content) {
201
- printManualInstructions(
202
- detection.tool,
203
- detection.configPath,
204
- 'config file not readable',
205
- detection.isLegacyRspack,
206
- )
74
+ printManualInstructions(strategy, detection, 'config file not readable')
207
75
  return { success: false, mutations, failureReason: 'config file not readable' }
208
76
  }
209
77
 
210
78
  // Step 2: Idempotency check
211
79
  if (isAlreadyInjected(content)) {
212
- log.success(`Plugin already injected in ${detection.configPath} (skipped)`)
213
-
214
- // We still want to record the mutation so teardown knows how to clean it up
215
- // However, if the user manually added it, we shouldn't overwrite their code with a .bak on teardown.
216
- // To handle this, we register a special 'plugin_injected' mutation instead of 'file_modified',
217
- // which tells teardown to use AST-removal instead of .bak restoration (if we implement it)
218
- // For now, we will assume if it's already there and a .bak exists, we track it for restoration.
219
- if (await exists(backupPath)) {
220
- mutations.push({
221
- type: 'file_modified',
222
- path: detection.configPath,
223
- backup: detection.configPath + '.bak',
224
- description: 'Previously injected inspecto() plugin',
225
- })
226
- } else {
227
- // If no backup exists, we just mark it as modified but without backup
228
- // This tells teardown it was touched but cannot be safely rolled back
229
- mutations.push({
230
- type: 'file_modified',
231
- path: detection.configPath,
232
- description: 'Previously injected inspecto() plugin (no backup)',
233
- })
234
- }
235
-
236
- return { success: true, mutations }
237
- }
80
+ log.success(`Plugin already configured in ${detection.configPath} (skipped)`)
238
81
 
239
- // Step 3: Backup
240
- if (!dryRun) {
241
- await copyFile(configPath, backupPath)
242
82
  mutations.push({
243
83
  type: 'file_modified',
244
84
  path: detection.configPath,
245
- backup: detection.configPath + '.bak',
246
- description: 'Injected inspecto() plugin',
85
+ description: 'Previously configured inspecto() plugin',
247
86
  })
87
+
88
+ return { success: true, mutations }
248
89
  }
249
- log.success(`Backed up ${detection.configPath} → ${detection.configPath}.bak`)
250
90
 
251
- // Step 4: Inject into plugins array
252
- const injected = injectIntoPluginsArray(content, detection)
253
- if (!injected) {
91
+ if (!strategy) {
254
92
  printManualInstructions(
255
- detection.tool,
256
- detection.configPath,
257
- 'could not locate plugins array — file may use dynamic config, function wrapper, or non-standard export',
258
- detection.isLegacyRspack,
93
+ strategy,
94
+ detection,
95
+ `No injection strategy found for ${detection.tool}`,
259
96
  )
260
- return {
261
- success: false,
262
- mutations,
263
- failureReason: 'could not locate plugins array',
264
- }
97
+ return { success: false, mutations, failureReason: 'No strategy found' }
98
+ }
99
+
100
+ // Step 3: Automatic configuration
101
+ if (dryRun) {
102
+ log.dryRun(`Would automatically configure plugin in ${detection.configPath}`)
103
+ return { success: true, mutations: [] }
265
104
  }
266
105
 
267
- // Step 5: Inject import statement
268
- const importStmt = getImportStatement(detection.tool, detection.isLegacyRspack)
269
- const modifiedContent = injectImport(injected, importStmt)
106
+ try {
107
+ const mod = await loadFile(configPath)
270
108
 
271
- // Step 6: Bracket-balance validation
272
- if (!validateBrackets(modifiedContent)) {
273
- log.error('Syntax validation failed after injection')
274
- if (!dryRun) {
275
- await copyFile(backupPath, configPath)
276
- log.success(`Restored ${detection.configPath} from backup`)
277
- }
109
+ // Delegate to strategy
110
+ strategy.inject({
111
+ mod,
112
+ detection,
113
+ })
114
+
115
+ // Step 4: Write modified config back to file
116
+ await writeAstFile(mod, configPath)
117
+
118
+ mutations.push({
119
+ type: 'file_modified',
120
+ path: detection.configPath,
121
+ description: 'Automatically configured inspecto() plugin',
122
+ })
123
+
124
+ log.success(`Configured plugin in ${detection.configPath}`)
125
+ return { success: true, mutations }
126
+ } catch (err) {
127
+ // Graceback degradation
278
128
  printManualInstructions(
279
- detection.tool,
280
- detection.configPath,
281
- 'injection produced unbalanced brackets',
282
- detection.isLegacyRspack,
129
+ strategy,
130
+ detection,
131
+ `Automatic configuration unavailable: ${err instanceof Error ? err.message : String(err)}`,
283
132
  )
284
133
  return {
285
134
  success: false,
286
- mutations: [], // Rolled back
287
- failureReason: 'injection produced invalid syntax',
135
+ mutations,
136
+ failureReason: 'Automatic configuration unavailable',
288
137
  }
289
138
  }
290
-
291
- // Step 7: Write
292
- if (dryRun) {
293
- log.dryRun(`Would inject plugin into ${detection.configPath}`)
294
- } else {
295
- await writeFile(configPath, modifiedContent)
296
- log.success(`Injected plugin into ${detection.configPath}`)
297
- }
298
-
299
- return { success: true, mutations }
300
139
  }