@inspecto-dev/cli 0.2.0-alpha.0
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/.turbo/turbo-build.log +19 -0
- package/.turbo/turbo-test.log +15 -0
- package/CHANGELOG.md +14 -0
- package/LICENSE +21 -0
- package/README.md +78 -0
- package/TESTING.md +109 -0
- package/bin/inspecto.js +3 -0
- package/dist/bin.d.ts +2 -0
- package/dist/bin.js +83 -0
- package/dist/chunk-4RR7PTRN.js +1306 -0
- package/dist/index.d.ts +40 -0
- package/dist/index.js +10 -0
- package/package.json +38 -0
- package/src/bin.ts +89 -0
- package/src/commands/doctor.ts +185 -0
- package/src/commands/init.ts +447 -0
- package/src/commands/teardown.ts +124 -0
- package/src/detect/ai-tool.ts +127 -0
- package/src/detect/build-tool.ts +123 -0
- package/src/detect/framework.ts +65 -0
- package/src/detect/ide.ts +78 -0
- package/src/detect/package-manager.ts +56 -0
- package/src/index.ts +5 -0
- package/src/inject/ast-injector.ts +300 -0
- package/src/inject/extension.ts +140 -0
- package/src/inject/gitignore.ts +76 -0
- package/src/types.ts +48 -0
- package/src/utils/exec.ts +44 -0
- package/src/utils/fs.ts +69 -0
- package/src/utils/logger.ts +64 -0
- package/tests/framework.test.ts +65 -0
- package/tests/ide.test.ts +94 -0
- package/tsconfig.json +13 -0
- package/tsup.config.ts +10 -0
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
// ============================================================
|
|
2
|
+
// src/inject/extension.ts — VS Code extension auto-installer
|
|
3
|
+
// ============================================================
|
|
4
|
+
//
|
|
5
|
+
// Waterfall degradation strategy:
|
|
6
|
+
// Level 1: `code --install-extension` (code in PATH)
|
|
7
|
+
// Level 2: Find VS Code binary at known paths
|
|
8
|
+
// Level 3: Open `vscode:extension/` URI scheme
|
|
9
|
+
// Level 4: Print manual installation instructions
|
|
10
|
+
// ============================================================
|
|
11
|
+
|
|
12
|
+
import path from 'node:path'
|
|
13
|
+
import { which, run, shell } from '../utils/exec.js'
|
|
14
|
+
import { exists } from '../utils/fs.js'
|
|
15
|
+
import { log } from '../utils/logger.js'
|
|
16
|
+
import type { Mutation } from '../types.js'
|
|
17
|
+
|
|
18
|
+
const EXTENSION_ID = 'inspecto.inspecto'
|
|
19
|
+
|
|
20
|
+
/** Known VS Code binary locations by platform */
|
|
21
|
+
const VSCODE_PATHS: Record<string, string[]> = {
|
|
22
|
+
darwin: [
|
|
23
|
+
'/Applications/Visual Studio Code.app/Contents/Resources/app/bin/code',
|
|
24
|
+
'/Applications/Visual Studio Code - Insiders.app/Contents/Resources/app/bin/code-insiders',
|
|
25
|
+
`${process.env.HOME}/Applications/Visual Studio Code.app/Contents/Resources/app/bin/code`,
|
|
26
|
+
],
|
|
27
|
+
linux: ['/usr/bin/code', '/usr/share/code/bin/code', '/snap/bin/code', '/usr/bin/code-insiders'],
|
|
28
|
+
win32: [
|
|
29
|
+
`${process.env.LOCALAPPDATA}\\Programs\\Microsoft VS Code\\bin\\code.cmd`,
|
|
30
|
+
`${process.env.LOCALAPPDATA}\\Programs\\Microsoft VS Code Insiders\\bin\\code-insiders.cmd`,
|
|
31
|
+
`${process.env.PROGRAMFILES}\\Microsoft VS Code\\bin\\code.cmd`,
|
|
32
|
+
],
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Try to find the VS Code binary at known filesystem paths */
|
|
36
|
+
async function findVSCodeBinary(): Promise<string | null> {
|
|
37
|
+
const platform = process.platform
|
|
38
|
+
const candidates = VSCODE_PATHS[platform] || []
|
|
39
|
+
|
|
40
|
+
for (const candidate of candidates) {
|
|
41
|
+
if (await exists(candidate)) {
|
|
42
|
+
return candidate
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Also check for code-insiders in PATH
|
|
47
|
+
if (await which('code-insiders')) {
|
|
48
|
+
return 'code-insiders'
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
return null
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Try to open a URI using the system default handler */
|
|
55
|
+
async function tryOpenURI(uri: string): Promise<boolean> {
|
|
56
|
+
try {
|
|
57
|
+
const platform = process.platform
|
|
58
|
+
const openCmd = platform === 'darwin' ? 'open' : platform === 'win32' ? 'start' : 'xdg-open'
|
|
59
|
+
|
|
60
|
+
await shell(`${openCmd} "${uri}"`)
|
|
61
|
+
return true
|
|
62
|
+
} catch {
|
|
63
|
+
return false
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Attempt to install the VS Code extension using waterfall degradation.
|
|
69
|
+
*/
|
|
70
|
+
export async function installExtension(dryRun: boolean): Promise<Mutation | null> {
|
|
71
|
+
if (dryRun) {
|
|
72
|
+
log.dryRun('Would attempt to install VS Code extension')
|
|
73
|
+
return null
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Level 1: Direct `code` command
|
|
77
|
+
if (await which('code')) {
|
|
78
|
+
try {
|
|
79
|
+
await run('code', ['--install-extension', EXTENSION_ID])
|
|
80
|
+
log.success('VS Code extension installed via CLI')
|
|
81
|
+
return { type: 'extension_installed', id: EXTENSION_ID }
|
|
82
|
+
} catch {
|
|
83
|
+
// Fall through to next level
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Level 2: Find VS Code binary at known paths
|
|
88
|
+
const codePath = await findVSCodeBinary()
|
|
89
|
+
if (codePath) {
|
|
90
|
+
try {
|
|
91
|
+
await run(codePath, ['--install-extension', EXTENSION_ID])
|
|
92
|
+
log.success('VS Code extension installed via binary path')
|
|
93
|
+
log.info('Tip: Add "code" to your PATH to help Inspecto detect other AI tools in the future')
|
|
94
|
+
return { type: 'extension_installed', id: EXTENSION_ID }
|
|
95
|
+
} catch {
|
|
96
|
+
// Fall through to next level
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Level 3: URI scheme
|
|
101
|
+
const uri = `vscode:extension/${EXTENSION_ID}`
|
|
102
|
+
if (await tryOpenURI(uri)) {
|
|
103
|
+
log.warn('Opened extension page in VS Code')
|
|
104
|
+
log.hint('Please click "Install" in the opened VS Code window to complete setup.')
|
|
105
|
+
return { type: 'extension_installed', id: EXTENSION_ID, manual_action_required: true }
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Level 4: Manual fallback
|
|
109
|
+
log.warn('Could not auto-install VS Code extension')
|
|
110
|
+
log.hint('Please install it manually to enable Inspector features:')
|
|
111
|
+
log.hint(' 1. Open VS Code')
|
|
112
|
+
log.hint(' 2. Press Ctrl+Shift+X (or Cmd+Shift+X)')
|
|
113
|
+
log.hint(' 3. Search for "Inspecto"')
|
|
114
|
+
log.hint(` Or visit: https://marketplace.visualstudio.com/items?itemName=${EXTENSION_ID}`)
|
|
115
|
+
return null
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Check if the extension is already installed.
|
|
120
|
+
*/
|
|
121
|
+
export async function isExtensionInstalled(): Promise<boolean> {
|
|
122
|
+
try {
|
|
123
|
+
// Try `code --list-extensions` first
|
|
124
|
+
if (await which('code')) {
|
|
125
|
+
const { stdout } = await run('code', ['--list-extensions'])
|
|
126
|
+
return stdout.toLowerCase().includes(EXTENSION_ID)
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// Try known binary path
|
|
130
|
+
const codePath = await findVSCodeBinary()
|
|
131
|
+
if (codePath) {
|
|
132
|
+
const { stdout } = await run(codePath, ['--list-extensions'])
|
|
133
|
+
return stdout.toLowerCase().includes(EXTENSION_ID)
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
return false
|
|
137
|
+
} catch {
|
|
138
|
+
return false
|
|
139
|
+
}
|
|
140
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
// ============================================================
|
|
2
|
+
// src/inject/gitignore.ts — .gitignore management
|
|
3
|
+
// ============================================================
|
|
4
|
+
import path from 'node:path'
|
|
5
|
+
import { readFile, writeFile } from '../utils/fs.js'
|
|
6
|
+
import { log } from '../utils/logger.js'
|
|
7
|
+
|
|
8
|
+
/** Rules for default mode: fine-grained rules only */
|
|
9
|
+
const DEFAULT_RULES = ['.inspecto/install.lock', '.inspecto/cache.json', '.inspecto/*.local.json']
|
|
10
|
+
|
|
11
|
+
/** Rules for shared mode: same as default in current design to allow settings.json */
|
|
12
|
+
const SHARED_RULES = ['.inspecto/install.lock', '.inspecto/cache.json', '.inspecto/*.local.json']
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Update .gitignore based on the init mode.
|
|
16
|
+
*
|
|
17
|
+
* Handles mode switching: if switching from default → shared,
|
|
18
|
+
* replaces the broad `.inspecto/` rule with fine-grained rules.
|
|
19
|
+
*/
|
|
20
|
+
export async function updateGitignore(
|
|
21
|
+
root: string,
|
|
22
|
+
shared: boolean,
|
|
23
|
+
dryRun: boolean,
|
|
24
|
+
): Promise<void> {
|
|
25
|
+
const gitignorePath = path.join(root, '.gitignore')
|
|
26
|
+
let content = (await readFile(gitignorePath)) ?? ''
|
|
27
|
+
|
|
28
|
+
const desiredRules = shared ? SHARED_RULES : DEFAULT_RULES
|
|
29
|
+
const hasGlobalRule = content.match(/^\.inspecto\/\s*$/m) !== null
|
|
30
|
+
|
|
31
|
+
// Mode switch: If the user previously had the broad `.inspecto/` rule, replace it
|
|
32
|
+
if (hasGlobalRule) {
|
|
33
|
+
content = content.replace(/^\.inspecto\/\s*$/gm, SHARED_RULES.join('\n'))
|
|
34
|
+
if (!dryRun) {
|
|
35
|
+
await writeFile(gitignorePath, content)
|
|
36
|
+
}
|
|
37
|
+
log.success('Updated .gitignore: .inspecto/settings.json is now trackable')
|
|
38
|
+
return
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Check if rules already exist
|
|
42
|
+
const missingRules = desiredRules.filter(rule => !content.includes(rule))
|
|
43
|
+
if (missingRules.length === 0) {
|
|
44
|
+
return // Already configured
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Append rules
|
|
48
|
+
const section = '\n# Inspecto\n' + missingRules.join('\n') + '\n'
|
|
49
|
+
content = content.trimEnd() + '\n' + section
|
|
50
|
+
|
|
51
|
+
if (dryRun) {
|
|
52
|
+
log.dryRun(`Would update .gitignore with: ${missingRules.join(', ')}`)
|
|
53
|
+
} else {
|
|
54
|
+
await writeFile(gitignorePath, content)
|
|
55
|
+
log.success('Updated .gitignore')
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Remove all Inspecto-related entries from .gitignore.
|
|
61
|
+
*/
|
|
62
|
+
export async function cleanGitignore(root: string): Promise<void> {
|
|
63
|
+
const gitignorePath = path.join(root, '.gitignore')
|
|
64
|
+
const content = await readFile(gitignorePath)
|
|
65
|
+
if (!content) return
|
|
66
|
+
|
|
67
|
+
const cleaned = content
|
|
68
|
+
.replace(/^# Inspecto\s*$/m, '')
|
|
69
|
+
.replace(/^\.inspecto\/?\s*$/gm, '')
|
|
70
|
+
.replace(/^\.inspecto\/install\.lock\s*$/gm, '')
|
|
71
|
+
.replace(/^\.inspecto\/cache\.json\s*$/gm, '')
|
|
72
|
+
.replace(/^\.inspecto\/\*\.local\.json\s*$/gm, '')
|
|
73
|
+
.replace(/\n{3,}/g, '\n\n') // Collapse excess blank lines
|
|
74
|
+
|
|
75
|
+
await writeFile(gitignorePath, cleaned)
|
|
76
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
// ============================================================
|
|
2
|
+
// src/types.ts — Shared type definitions
|
|
3
|
+
// ============================================================
|
|
4
|
+
|
|
5
|
+
/** Package manager detection result */
|
|
6
|
+
export type PackageManager = 'bun' | 'pnpm' | 'yarn' | 'npm'
|
|
7
|
+
|
|
8
|
+
/** Supported build tools (v1) */
|
|
9
|
+
export type BuildTool = 'vite' | 'webpack' | 'rspack' | 'rsbuild' | 'esbuild' | 'rollup'
|
|
10
|
+
|
|
11
|
+
/** Detected build tool with its config path */
|
|
12
|
+
export interface BuildToolDetection {
|
|
13
|
+
tool: BuildTool
|
|
14
|
+
configPath: string
|
|
15
|
+
/** Human-readable label like "Vite (vite.config.ts)" */
|
|
16
|
+
label: string
|
|
17
|
+
/** Whether this is a legacy rspack version (< 0.4.0) */
|
|
18
|
+
isLegacyRspack?: boolean
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Options passed to `inspecto init` */
|
|
22
|
+
export interface InitOptions {
|
|
23
|
+
shared: boolean
|
|
24
|
+
skipInstall: boolean
|
|
25
|
+
dryRun: boolean
|
|
26
|
+
prefer?: string
|
|
27
|
+
noExtension: boolean
|
|
28
|
+
packages?: string[]
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** A single mutation recorded in install.lock */
|
|
32
|
+
export interface Mutation {
|
|
33
|
+
type: 'file_modified' | 'file_created' | 'dependency_added' | 'extension_installed'
|
|
34
|
+
path?: string
|
|
35
|
+
backup?: string
|
|
36
|
+
name?: string
|
|
37
|
+
id?: string
|
|
38
|
+
dev?: boolean
|
|
39
|
+
description?: string
|
|
40
|
+
manual_action_required?: boolean
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Structure of .inspecto/install.lock */
|
|
44
|
+
export interface InstallLock {
|
|
45
|
+
version: string
|
|
46
|
+
created_at: string
|
|
47
|
+
mutations: Mutation[]
|
|
48
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
// ============================================================
|
|
2
|
+
// src/utils/exec.ts — Shell execution helpers
|
|
3
|
+
// ============================================================
|
|
4
|
+
import { execFile, exec as execCb } from 'node:child_process'
|
|
5
|
+
import { promisify } from 'node:util'
|
|
6
|
+
|
|
7
|
+
const execFileAsync = promisify(execFile)
|
|
8
|
+
const execAsync = promisify(execCb)
|
|
9
|
+
|
|
10
|
+
export interface ExecResult {
|
|
11
|
+
stdout: string
|
|
12
|
+
stderr: string
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** Run a command and return stdout/stderr. Throws on non-zero exit. */
|
|
16
|
+
export async function run(command: string, args: string[], cwd?: string): Promise<ExecResult> {
|
|
17
|
+
const result = await execFileAsync(command, args, {
|
|
18
|
+
cwd,
|
|
19
|
+
timeout: 60_000,
|
|
20
|
+
env: { ...process.env },
|
|
21
|
+
})
|
|
22
|
+
return { stdout: result.stdout ?? '', stderr: result.stderr ?? '' }
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Run a shell command string. Throws on non-zero exit. */
|
|
26
|
+
export async function shell(command: string, cwd?: string): Promise<ExecResult> {
|
|
27
|
+
const result = await execAsync(command, {
|
|
28
|
+
cwd,
|
|
29
|
+
timeout: 60_000,
|
|
30
|
+
env: { ...process.env },
|
|
31
|
+
})
|
|
32
|
+
return { stdout: result.stdout ?? '', stderr: result.stderr ?? '' }
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Check if a binary exists in PATH */
|
|
36
|
+
export async function which(bin: string): Promise<boolean> {
|
|
37
|
+
try {
|
|
38
|
+
const cmd = process.platform === 'win32' ? 'where' : 'which'
|
|
39
|
+
await run(cmd, [bin])
|
|
40
|
+
return true
|
|
41
|
+
} catch {
|
|
42
|
+
return false
|
|
43
|
+
}
|
|
44
|
+
}
|
package/src/utils/fs.ts
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
// ============================================================
|
|
2
|
+
// src/utils/fs.ts — File system helpers
|
|
3
|
+
// ============================================================
|
|
4
|
+
import fs from 'node:fs/promises'
|
|
5
|
+
import path from 'node:path'
|
|
6
|
+
|
|
7
|
+
/** Check if a file or directory exists */
|
|
8
|
+
export async function exists(filePath: string): Promise<boolean> {
|
|
9
|
+
try {
|
|
10
|
+
await fs.access(filePath)
|
|
11
|
+
return true
|
|
12
|
+
} catch {
|
|
13
|
+
return false
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** Read file as UTF-8 text, return null if not found */
|
|
18
|
+
export async function readFile(filePath: string): Promise<string | null> {
|
|
19
|
+
try {
|
|
20
|
+
return await fs.readFile(filePath, 'utf-8')
|
|
21
|
+
} catch {
|
|
22
|
+
return null
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Write file with auto-created parent directories */
|
|
27
|
+
export async function writeFile(filePath: string, content: string): Promise<void> {
|
|
28
|
+
await fs.mkdir(path.dirname(filePath), { recursive: true })
|
|
29
|
+
await fs.writeFile(filePath, content, 'utf-8')
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Copy file (for backup) */
|
|
33
|
+
export async function copyFile(src: string, dest: string): Promise<void> {
|
|
34
|
+
await fs.copyFile(src, dest)
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Delete file, no error if missing */
|
|
38
|
+
export async function removeFile(filePath: string): Promise<void> {
|
|
39
|
+
try {
|
|
40
|
+
await fs.unlink(filePath)
|
|
41
|
+
} catch {
|
|
42
|
+
// ignore
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Delete directory recursively, no error if missing */
|
|
47
|
+
export async function removeDir(dirPath: string): Promise<void> {
|
|
48
|
+
try {
|
|
49
|
+
await fs.rm(dirPath, { recursive: true, force: true })
|
|
50
|
+
} catch {
|
|
51
|
+
// ignore
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Read and parse JSON file */
|
|
56
|
+
export async function readJSON<T = unknown>(filePath: string): Promise<T | null> {
|
|
57
|
+
const text = await readFile(filePath)
|
|
58
|
+
if (!text) return null
|
|
59
|
+
try {
|
|
60
|
+
return JSON.parse(text) as T
|
|
61
|
+
} catch {
|
|
62
|
+
return null
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Write JSON file with 2-space indent */
|
|
67
|
+
export async function writeJSON(filePath: string, data: unknown): Promise<void> {
|
|
68
|
+
await writeFile(filePath, JSON.stringify(data, null, 2) + '\n')
|
|
69
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
// ============================================================
|
|
2
|
+
// src/utils/logger.ts — Colored terminal output
|
|
3
|
+
// ============================================================
|
|
4
|
+
import pc from 'picocolors'
|
|
5
|
+
|
|
6
|
+
export const log = {
|
|
7
|
+
/** Section header */
|
|
8
|
+
header(text: string) {
|
|
9
|
+
console.log()
|
|
10
|
+
console.log(` ${pc.bold(pc.cyan('✦'))} ${pc.bold(text)}`)
|
|
11
|
+
console.log()
|
|
12
|
+
},
|
|
13
|
+
|
|
14
|
+
/** Info item (used for actionable but not fully successful states) */
|
|
15
|
+
info(text: string) {
|
|
16
|
+
console.log(` ${pc.blue('ℹ')} ${text}`)
|
|
17
|
+
},
|
|
18
|
+
success(text: string) {
|
|
19
|
+
console.log(` ${pc.green('✔')} ${text}`)
|
|
20
|
+
},
|
|
21
|
+
|
|
22
|
+
/** Warning item */
|
|
23
|
+
warn(text: string) {
|
|
24
|
+
console.log(` ${pc.yellow('⚠')} ${text}`)
|
|
25
|
+
},
|
|
26
|
+
|
|
27
|
+
/** Error item */
|
|
28
|
+
error(text: string) {
|
|
29
|
+
console.log(` ${pc.red('✘')} ${text}`)
|
|
30
|
+
},
|
|
31
|
+
|
|
32
|
+
/** Indented hint line */
|
|
33
|
+
hint(text: string) {
|
|
34
|
+
console.log(` ${pc.dim('→')} ${text}`)
|
|
35
|
+
},
|
|
36
|
+
|
|
37
|
+
/** Blank line */
|
|
38
|
+
blank() {
|
|
39
|
+
console.log()
|
|
40
|
+
},
|
|
41
|
+
|
|
42
|
+
/** Final ready message */
|
|
43
|
+
ready(text: string) {
|
|
44
|
+
console.log()
|
|
45
|
+
console.log(` ${pc.bold(pc.green('⚡'))} ${pc.bold(text)}`)
|
|
46
|
+
console.log()
|
|
47
|
+
},
|
|
48
|
+
|
|
49
|
+
/** Code block for manual instructions */
|
|
50
|
+
codeBlock(lines: string[]) {
|
|
51
|
+
console.log()
|
|
52
|
+
console.log(` ${pc.dim('┌─────────────────────────────────────────────────┐')}`)
|
|
53
|
+
for (const line of lines) {
|
|
54
|
+
console.log(` ${pc.dim('│')} ${line.padEnd(48)}${pc.dim('│')}`)
|
|
55
|
+
}
|
|
56
|
+
console.log(` ${pc.dim('└─────────────────────────────────────────────────┘')}`)
|
|
57
|
+
console.log()
|
|
58
|
+
},
|
|
59
|
+
|
|
60
|
+
/** Dry-run prefix */
|
|
61
|
+
dryRun(text: string) {
|
|
62
|
+
console.log(` ${pc.blue('[dry-run]')} ${text}`)
|
|
63
|
+
},
|
|
64
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from 'vitest'
|
|
2
|
+
import { detectFrameworks } from '../src/detect/framework.js'
|
|
3
|
+
import * as fsUtils from '../src/utils/fs.js'
|
|
4
|
+
|
|
5
|
+
vi.mock('../src/utils/fs.js', () => ({
|
|
6
|
+
readJSON: vi.fn(),
|
|
7
|
+
}))
|
|
8
|
+
|
|
9
|
+
describe('detectFrameworks', () => {
|
|
10
|
+
it('detects React based on dependencies', async () => {
|
|
11
|
+
vi.mocked(fsUtils.readJSON).mockResolvedValue({
|
|
12
|
+
dependencies: {
|
|
13
|
+
react: '^18.0.0',
|
|
14
|
+
},
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
const result = await detectFrameworks('/mock/root')
|
|
18
|
+
expect(result.supported).toContain('react')
|
|
19
|
+
expect(result.unsupported).toHaveLength(0)
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
it('detects Vue based on dependencies', async () => {
|
|
23
|
+
vi.mocked(fsUtils.readJSON).mockResolvedValue({
|
|
24
|
+
devDependencies: {
|
|
25
|
+
vue: '^3.0.0',
|
|
26
|
+
},
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
const result = await detectFrameworks('/mock/root')
|
|
30
|
+
expect(result.supported).toContain('vue')
|
|
31
|
+
expect(result.unsupported).toHaveLength(0)
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
it('detects Svelte as unsupported framework', async () => {
|
|
35
|
+
vi.mocked(fsUtils.readJSON).mockResolvedValue({
|
|
36
|
+
devDependencies: {
|
|
37
|
+
svelte: '^4.0.0',
|
|
38
|
+
},
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
const result = await detectFrameworks('/mock/root')
|
|
42
|
+
expect(result.supported).toHaveLength(0)
|
|
43
|
+
expect(result.unsupported).toContainEqual({ name: 'Svelte', dep: 'svelte' })
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
it('returns empty if no framework is matched', async () => {
|
|
47
|
+
vi.mocked(fsUtils.readJSON).mockResolvedValue({
|
|
48
|
+
dependencies: {
|
|
49
|
+
lodash: '^4.0.0',
|
|
50
|
+
},
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
const result = await detectFrameworks('/mock/root')
|
|
54
|
+
expect(result.supported).toHaveLength(0)
|
|
55
|
+
expect(result.unsupported).toHaveLength(0)
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
it('returns empty if package.json does not exist', async () => {
|
|
59
|
+
vi.mocked(fsUtils.readJSON).mockResolvedValue(null)
|
|
60
|
+
|
|
61
|
+
const result = await detectFrameworks('/mock/root')
|
|
62
|
+
expect(result.supported).toHaveLength(0)
|
|
63
|
+
expect(result.unsupported).toHaveLength(0)
|
|
64
|
+
})
|
|
65
|
+
})
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
|
2
|
+
import { detectIDE } from '../src/detect/ide.js'
|
|
3
|
+
import * as fsUtils from '../src/utils/fs.js'
|
|
4
|
+
|
|
5
|
+
vi.mock('../src/utils/fs.js', () => ({
|
|
6
|
+
exists: vi.fn(),
|
|
7
|
+
}))
|
|
8
|
+
|
|
9
|
+
describe('detectIDE', () => {
|
|
10
|
+
const originalEnv = process.env
|
|
11
|
+
|
|
12
|
+
beforeEach(() => {
|
|
13
|
+
vi.resetAllMocks()
|
|
14
|
+
process.env = { ...originalEnv } // Make a copy
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
afterEach(() => {
|
|
18
|
+
process.env = originalEnv
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
it('detects Trae from environment variables', async () => {
|
|
22
|
+
process.env.TRAE_APP_DIR = '/Applications/Trae.app'
|
|
23
|
+
vi.mocked(fsUtils.exists).mockResolvedValue(false)
|
|
24
|
+
|
|
25
|
+
const result = await detectIDE('/mock/root')
|
|
26
|
+
expect(result.detected).toEqual([{ ide: 'Trae', supported: false }])
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
it('detects Cursor from environment variables', async () => {
|
|
30
|
+
process.env.CURSOR_CHANNEL = 'stable'
|
|
31
|
+
vi.mocked(fsUtils.exists).mockResolvedValue(false)
|
|
32
|
+
|
|
33
|
+
const result = await detectIDE('/mock/root')
|
|
34
|
+
expect(result.detected).toEqual([{ ide: 'Cursor', supported: false }])
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
it('detects VS Code from environment variables without false positives', async () => {
|
|
38
|
+
process.env.TERM_PROGRAM = 'vscode'
|
|
39
|
+
// Ensure it's not Trae
|
|
40
|
+
delete process.env.TRAE_APP_DIR
|
|
41
|
+
delete process.env.CURSOR_CHANNEL
|
|
42
|
+
delete process.env.__CFBundleIdentifier
|
|
43
|
+
delete process.env.COCO_IDE_PLUGIN_TYPE
|
|
44
|
+
process.env.npm_config_user_agent = 'npm'
|
|
45
|
+
|
|
46
|
+
vi.mocked(fsUtils.exists).mockResolvedValue(false)
|
|
47
|
+
|
|
48
|
+
const result = await detectIDE('/mock/root')
|
|
49
|
+
expect(result.detected).toEqual([{ ide: 'vscode', supported: true }])
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
it('detects VS Code from directory when environment is clean', async () => {
|
|
53
|
+
// Clear all related env vars
|
|
54
|
+
delete process.env.TERM_PROGRAM
|
|
55
|
+
delete process.env.TRAE_APP_DIR
|
|
56
|
+
delete process.env.CURSOR_CHANNEL
|
|
57
|
+
delete process.env.__CFBundleIdentifier
|
|
58
|
+
delete process.env.COCO_IDE_PLUGIN_TYPE
|
|
59
|
+
delete process.env.npm_config_user_agent
|
|
60
|
+
|
|
61
|
+
vi.mocked(fsUtils.exists).mockImplementation(async path => {
|
|
62
|
+
return path.endsWith('.vscode')
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
const result = await detectIDE('/mock/root')
|
|
66
|
+
expect(result.detected).toEqual([{ ide: 'vscode', supported: true }])
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
it('detects JetBrains from directory', async () => {
|
|
70
|
+
delete process.env.TERM_PROGRAM
|
|
71
|
+
delete process.env.TRAE_APP_DIR
|
|
72
|
+
delete process.env.CURSOR_CHANNEL
|
|
73
|
+
delete process.env.__CFBundleIdentifier
|
|
74
|
+
delete process.env.COCO_IDE_PLUGIN_TYPE
|
|
75
|
+
|
|
76
|
+
vi.mocked(fsUtils.exists).mockImplementation(async path => {
|
|
77
|
+
return path.endsWith('.idea')
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
const result = await detectIDE('/mock/root')
|
|
81
|
+
expect(result.detected).toEqual([{ ide: 'JetBrains IDE', supported: false }])
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
it('favors Trae if both VS Code TERM_PROGRAM and Trae env are present', async () => {
|
|
85
|
+
process.env.TERM_PROGRAM = 'vscode'
|
|
86
|
+
process.env.TRAE_APP_DIR = '/Applications/Trae.app'
|
|
87
|
+
|
|
88
|
+
vi.mocked(fsUtils.exists).mockResolvedValue(false)
|
|
89
|
+
|
|
90
|
+
const result = await detectIDE('/mock/root')
|
|
91
|
+
// VS Code shouldn't be added since it shares TERM_PROGRAM with Trae
|
|
92
|
+
expect(result.detected).toEqual([{ ide: 'Trae', supported: false }])
|
|
93
|
+
})
|
|
94
|
+
})
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
{
|
|
2
|
+
"extends": "../../tsconfig.base.json",
|
|
3
|
+
"compilerOptions": {
|
|
4
|
+
"outDir": "dist",
|
|
5
|
+
"rootDir": "src",
|
|
6
|
+
"target": "ESNext",
|
|
7
|
+
"module": "NodeNext",
|
|
8
|
+
"moduleResolution": "NodeNext",
|
|
9
|
+
"esModuleInterop": true,
|
|
10
|
+
"strict": true
|
|
11
|
+
},
|
|
12
|
+
"include": ["src/**/*", "bin/**/*"]
|
|
13
|
+
}
|