prjct-cli 0.61.0 → 0.62.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/CHANGELOG.md +37 -0
- package/core/__tests__/services/dependency-validator.test.ts +175 -0
- package/core/infrastructure/setup.ts +24 -1
- package/core/services/dependency-validator.ts +318 -0
- package/core/services/git-analyzer.ts +28 -11
- package/core/services/skill-installer.ts +14 -1
- package/dist/bin/prjct.mjs +281 -17
- package/dist/core/infrastructure/setup.js +274 -7
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,42 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [0.62.0] - 2026-02-05
|
|
4
|
+
|
|
5
|
+
### Features
|
|
6
|
+
|
|
7
|
+
- implement graceful degradation for missing dependencies (PRJ-114) (#103)
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
## [0.62.0] - 2026-02-05
|
|
11
|
+
|
|
12
|
+
### Improved
|
|
13
|
+
|
|
14
|
+
- **Graceful degradation (PRJ-114)**: prjct now handles missing dependencies with helpful recovery hints instead of crashing
|
|
15
|
+
|
|
16
|
+
### Implementation Details
|
|
17
|
+
|
|
18
|
+
Created `DependencyValidator` service with `checkTool()`, `ensureTool()`, and caching. Integrated into GitAnalyzer, SkillInstaller, and setup.ts. Replaced shell pipes (`wc -l`, `sed`) with JS string operations for cross-platform compatibility. Added alternative installation suggestions (yarn, pnpm, brew) when npm fails.
|
|
19
|
+
|
|
20
|
+
### Learnings
|
|
21
|
+
|
|
22
|
+
- Shell pipes like `wc -l` and `sed` aren't cross-platform - use JS string operations instead
|
|
23
|
+
- execSync calls are expensive - cache results with TTL
|
|
24
|
+
- npm may not be available even when node is - check separately
|
|
25
|
+
|
|
26
|
+
### Test Plan
|
|
27
|
+
|
|
28
|
+
#### For QA
|
|
29
|
+
1. Run `prjct sync` on a machine without git - verify helpful error message instead of crash
|
|
30
|
+
2. Run `prjct skill install owner/repo` without git - verify error suggests install methods
|
|
31
|
+
3. Run `prjct start` without npm - verify suggests alternatives (yarn, pnpm, brew)
|
|
32
|
+
4. Run `prjct doctor` - verify all tool checks display correctly
|
|
33
|
+
|
|
34
|
+
#### For Users
|
|
35
|
+
- prjct now gracefully handles missing dependencies with helpful recovery hints
|
|
36
|
+
- Automatic - errors include installation suggestions
|
|
37
|
+
- No breaking changes
|
|
38
|
+
|
|
39
|
+
|
|
3
40
|
## [0.61.0] - 2026-02-05
|
|
4
41
|
|
|
5
42
|
### Features
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Dependency Validator Tests
|
|
3
|
+
* Tests for graceful degradation when system dependencies are missing
|
|
4
|
+
*
|
|
5
|
+
* @see PRJ-114
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { beforeEach, describe, expect, it } from 'bun:test'
|
|
9
|
+
import { DependencyError, dependencyValidator, TOOLS } from '../../services/dependency-validator'
|
|
10
|
+
|
|
11
|
+
describe('DependencyValidator', () => {
|
|
12
|
+
beforeEach(() => {
|
|
13
|
+
// Clear cache before each test
|
|
14
|
+
dependencyValidator.clearCache()
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
describe('checkTool', () => {
|
|
18
|
+
it('should return available: true for installed tools (git)', () => {
|
|
19
|
+
const result = dependencyValidator.checkTool('git')
|
|
20
|
+
expect(result.available).toBe(true)
|
|
21
|
+
expect(result.version).toBeDefined()
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
it('should return available: true for installed tools (node)', () => {
|
|
25
|
+
const result = dependencyValidator.checkTool('node')
|
|
26
|
+
expect(result.available).toBe(true)
|
|
27
|
+
expect(result.version).toBeDefined()
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
it('should return available: false for non-existent tools', () => {
|
|
31
|
+
const result = dependencyValidator.checkTool('definitely-not-a-real-tool-xyz123')
|
|
32
|
+
expect(result.available).toBe(false)
|
|
33
|
+
expect(result.error).toBeDefined()
|
|
34
|
+
expect(result.error?.message).toContain('not installed')
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
it('should cache results', () => {
|
|
38
|
+
const result1 = dependencyValidator.checkTool('git')
|
|
39
|
+
const result2 = dependencyValidator.checkTool('git')
|
|
40
|
+
// Same object reference means cached
|
|
41
|
+
expect(result1).toBe(result2)
|
|
42
|
+
})
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
describe('isAvailable', () => {
|
|
46
|
+
it('should return true for available tools', () => {
|
|
47
|
+
expect(dependencyValidator.isAvailable('git')).toBe(true)
|
|
48
|
+
expect(dependencyValidator.isAvailable('node')).toBe(true)
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
it('should return false for unavailable tools', () => {
|
|
52
|
+
expect(dependencyValidator.isAvailable('fake-tool-abc')).toBe(false)
|
|
53
|
+
})
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
describe('getVersion', () => {
|
|
57
|
+
it('should return version string for available tools', () => {
|
|
58
|
+
const version = dependencyValidator.getVersion('git')
|
|
59
|
+
expect(version).toBeDefined()
|
|
60
|
+
expect(typeof version).toBe('string')
|
|
61
|
+
// Git version is like "2.39.0" or similar
|
|
62
|
+
expect(version).toMatch(/^\d+\.\d+/)
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
it('should return undefined for unavailable tools', () => {
|
|
66
|
+
const version = dependencyValidator.getVersion('fake-tool-xyz')
|
|
67
|
+
expect(version).toBeUndefined()
|
|
68
|
+
})
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
describe('ensureTool', () => {
|
|
72
|
+
it('should not throw for available tools', () => {
|
|
73
|
+
expect(() => dependencyValidator.ensureTool('git')).not.toThrow()
|
|
74
|
+
expect(() => dependencyValidator.ensureTool('node')).not.toThrow()
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
it('should throw DependencyError for unavailable tools', () => {
|
|
78
|
+
expect(() => dependencyValidator.ensureTool('fake-tool-xyz')).toThrow(DependencyError)
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
it('should include helpful hint in error', () => {
|
|
82
|
+
try {
|
|
83
|
+
dependencyValidator.ensureTool('fake-tool-xyz')
|
|
84
|
+
} catch (error) {
|
|
85
|
+
expect(error).toBeInstanceOf(DependencyError)
|
|
86
|
+
expect((error as DependencyError).hint).toBeDefined()
|
|
87
|
+
}
|
|
88
|
+
})
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
describe('ensureTools', () => {
|
|
92
|
+
it('should not throw when all tools are available', () => {
|
|
93
|
+
expect(() => dependencyValidator.ensureTools(['git', 'node'])).not.toThrow()
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
it('should throw when any tool is unavailable', () => {
|
|
97
|
+
expect(() => dependencyValidator.ensureTools(['git', 'fake-tool-xyz'])).toThrow(
|
|
98
|
+
DependencyError
|
|
99
|
+
)
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
it('should list all missing tools in error', () => {
|
|
103
|
+
try {
|
|
104
|
+
dependencyValidator.ensureTools(['fake-tool-1', 'fake-tool-2'])
|
|
105
|
+
} catch (error) {
|
|
106
|
+
expect(error).toBeInstanceOf(DependencyError)
|
|
107
|
+
expect((error as DependencyError).message).toContain('fake-tool-1')
|
|
108
|
+
expect((error as DependencyError).message).toContain('fake-tool-2')
|
|
109
|
+
}
|
|
110
|
+
})
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
describe('checkAll', () => {
|
|
114
|
+
it('should return status for all default tools', () => {
|
|
115
|
+
const results = dependencyValidator.checkAll()
|
|
116
|
+
expect(results.size).toBeGreaterThan(0)
|
|
117
|
+
expect(results.has('git')).toBe(true)
|
|
118
|
+
expect(results.has('node')).toBe(true)
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
it('should return status for specified tools', () => {
|
|
122
|
+
const results = dependencyValidator.checkAll(['git', 'node'])
|
|
123
|
+
expect(results.size).toBe(2)
|
|
124
|
+
expect(results.get('git')?.available).toBe(true)
|
|
125
|
+
expect(results.get('node')?.available).toBe(true)
|
|
126
|
+
})
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
describe('clearCache', () => {
|
|
130
|
+
it('should clear cached results', () => {
|
|
131
|
+
const result1 = dependencyValidator.checkTool('git')
|
|
132
|
+
dependencyValidator.clearCache()
|
|
133
|
+
const result2 = dependencyValidator.checkTool('git')
|
|
134
|
+
// Different object reference after cache clear
|
|
135
|
+
expect(result1).not.toBe(result2)
|
|
136
|
+
})
|
|
137
|
+
})
|
|
138
|
+
|
|
139
|
+
describe('TOOLS definitions', () => {
|
|
140
|
+
it('should have required tools marked as required', () => {
|
|
141
|
+
expect(TOOLS.git.required).toBe(true)
|
|
142
|
+
expect(TOOLS.node.required).toBe(true)
|
|
143
|
+
})
|
|
144
|
+
|
|
145
|
+
it('should have optional tools marked as not required', () => {
|
|
146
|
+
expect(TOOLS.bun.required).toBe(false)
|
|
147
|
+
expect(TOOLS.gh.required).toBe(false)
|
|
148
|
+
})
|
|
149
|
+
|
|
150
|
+
it('should have install hints for all tools', () => {
|
|
151
|
+
for (const tool of Object.values(TOOLS)) {
|
|
152
|
+
expect(tool.installHint).toBeDefined()
|
|
153
|
+
expect(tool.installHint.length).toBeGreaterThan(0)
|
|
154
|
+
}
|
|
155
|
+
})
|
|
156
|
+
})
|
|
157
|
+
|
|
158
|
+
describe('DependencyError', () => {
|
|
159
|
+
it('should have correct name', () => {
|
|
160
|
+
const error = new DependencyError({ message: 'test' })
|
|
161
|
+
expect(error.name).toBe('DependencyError')
|
|
162
|
+
})
|
|
163
|
+
|
|
164
|
+
it('should preserve hint and docs', () => {
|
|
165
|
+
const error = new DependencyError({
|
|
166
|
+
message: 'Tool not found',
|
|
167
|
+
hint: 'Install it',
|
|
168
|
+
docs: 'https://example.com',
|
|
169
|
+
})
|
|
170
|
+
expect(error.message).toBe('Tool not found')
|
|
171
|
+
expect(error.hint).toBe('Install it')
|
|
172
|
+
expect(error.docs).toBe('https://example.com')
|
|
173
|
+
})
|
|
174
|
+
})
|
|
175
|
+
})
|
|
@@ -21,6 +21,7 @@ import { execSync } from 'node:child_process'
|
|
|
21
21
|
import fs from 'node:fs'
|
|
22
22
|
import os from 'node:os'
|
|
23
23
|
import path from 'node:path'
|
|
24
|
+
import { dependencyValidator } from '../services/dependency-validator'
|
|
24
25
|
import { isNotFoundError } from '../types/fs'
|
|
25
26
|
import type { AIProviderConfig, AIProviderName } from '../types/provider'
|
|
26
27
|
import { getPackageRoot, VERSION } from '../utils/version'
|
|
@@ -67,11 +68,26 @@ async function _hasAICLI(provider: AIProviderConfig): Promise<boolean> {
|
|
|
67
68
|
|
|
68
69
|
/**
|
|
69
70
|
* Install AI CLI for the specified provider
|
|
71
|
+
* PRJ-114: Enhanced with graceful degradation and alternative install suggestions
|
|
70
72
|
*/
|
|
71
73
|
async function installAICLI(provider: AIProviderConfig): Promise<boolean> {
|
|
72
74
|
const packageName =
|
|
73
75
|
provider.name === 'claude' ? '@anthropic-ai/claude-code' : '@google/gemini-cli'
|
|
74
76
|
|
|
77
|
+
// PRJ-114: Check npm availability first
|
|
78
|
+
if (!dependencyValidator.isAvailable('npm')) {
|
|
79
|
+
console.log(`${YELLOW}⚠️ npm is not available${NC}`)
|
|
80
|
+
console.log('')
|
|
81
|
+
console.log(`${DIM}Install ${provider.displayName} using one of:${NC}`)
|
|
82
|
+
console.log(`${DIM} • Install Node.js: https://nodejs.org${NC}`)
|
|
83
|
+
console.log(
|
|
84
|
+
`${DIM} • Use Homebrew: brew install ${provider.name === 'claude' ? 'claude' : 'gemini'}${NC}`
|
|
85
|
+
)
|
|
86
|
+
console.log(`${DIM} • Use npx directly: npx ${packageName}${NC}`)
|
|
87
|
+
console.log('')
|
|
88
|
+
return false
|
|
89
|
+
}
|
|
90
|
+
|
|
75
91
|
try {
|
|
76
92
|
console.log(`${YELLOW}📦 ${provider.displayName} not found. Installing...${NC}`)
|
|
77
93
|
console.log('')
|
|
@@ -84,7 +100,14 @@ async function installAICLI(provider: AIProviderConfig): Promise<boolean> {
|
|
|
84
100
|
console.log(
|
|
85
101
|
`${YELLOW}⚠️ Failed to install ${provider.displayName}: ${(error as Error).message}${NC}`
|
|
86
102
|
)
|
|
87
|
-
console.log(
|
|
103
|
+
console.log('')
|
|
104
|
+
console.log(`${DIM}Alternative installation methods:${NC}`)
|
|
105
|
+
console.log(`${DIM} • npm: npm install -g ${packageName}${NC}`)
|
|
106
|
+
console.log(`${DIM} • yarn: yarn global add ${packageName}${NC}`)
|
|
107
|
+
console.log(`${DIM} • pnpm: pnpm add -g ${packageName}${NC}`)
|
|
108
|
+
console.log(
|
|
109
|
+
`${DIM} • brew: brew install ${provider.name === 'claude' ? 'claude' : 'gemini'}${NC}`
|
|
110
|
+
)
|
|
88
111
|
console.log('')
|
|
89
112
|
return false
|
|
90
113
|
}
|
|
@@ -0,0 +1,318 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Dependency Validator
|
|
3
|
+
*
|
|
4
|
+
* Provides graceful degradation for missing system dependencies.
|
|
5
|
+
* Checks tool availability before operations and provides helpful
|
|
6
|
+
* recovery hints when tools are missing.
|
|
7
|
+
*
|
|
8
|
+
* Pattern from Google Gemini CLI: "Never assumes library availability.
|
|
9
|
+
* Checks --help before using unknown flags."
|
|
10
|
+
*
|
|
11
|
+
* @see PRJ-114
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { execSync } from 'node:child_process'
|
|
15
|
+
import { createError, type ErrorWithHint } from '../utils/error-messages'
|
|
16
|
+
|
|
17
|
+
// ============================================================================
|
|
18
|
+
// TYPES
|
|
19
|
+
// ============================================================================
|
|
20
|
+
|
|
21
|
+
export interface ToolDefinition {
|
|
22
|
+
name: string
|
|
23
|
+
command: string // Command to check availability (e.g., 'git --version')
|
|
24
|
+
versionRegex?: RegExp // Regex to extract version from output
|
|
25
|
+
required: boolean // If false, missing tool is a warning, not error
|
|
26
|
+
installHint: string // How to install if missing
|
|
27
|
+
docs?: string // Documentation URL
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface ToolStatus {
|
|
31
|
+
available: boolean
|
|
32
|
+
version?: string
|
|
33
|
+
error?: ErrorWithHint
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// ============================================================================
|
|
37
|
+
// TOOL DEFINITIONS
|
|
38
|
+
// ============================================================================
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Known tools that prjct depends on
|
|
42
|
+
*/
|
|
43
|
+
export const TOOLS: Record<string, ToolDefinition> = {
|
|
44
|
+
git: {
|
|
45
|
+
name: 'git',
|
|
46
|
+
command: 'git --version',
|
|
47
|
+
versionRegex: /git version ([\d.]+)/,
|
|
48
|
+
required: true,
|
|
49
|
+
installHint: 'Install Git: https://git-scm.com/downloads',
|
|
50
|
+
docs: 'https://git-scm.com/doc',
|
|
51
|
+
},
|
|
52
|
+
node: {
|
|
53
|
+
name: 'node',
|
|
54
|
+
command: 'node --version',
|
|
55
|
+
versionRegex: /v([\d.]+)/,
|
|
56
|
+
required: true,
|
|
57
|
+
installHint: 'Install Node.js: https://nodejs.org',
|
|
58
|
+
docs: 'https://nodejs.org/docs',
|
|
59
|
+
},
|
|
60
|
+
bun: {
|
|
61
|
+
name: 'bun',
|
|
62
|
+
command: 'bun --version',
|
|
63
|
+
versionRegex: /([\d.]+)/,
|
|
64
|
+
required: false,
|
|
65
|
+
installHint: 'Install Bun: curl -fsSL https://bun.sh/install | bash',
|
|
66
|
+
docs: 'https://bun.sh/docs',
|
|
67
|
+
},
|
|
68
|
+
gh: {
|
|
69
|
+
name: 'gh',
|
|
70
|
+
command: 'gh --version',
|
|
71
|
+
versionRegex: /gh version ([\d.]+)/,
|
|
72
|
+
required: false,
|
|
73
|
+
installHint: 'Install GitHub CLI: https://cli.github.com',
|
|
74
|
+
docs: 'https://cli.github.com/manual',
|
|
75
|
+
},
|
|
76
|
+
npm: {
|
|
77
|
+
name: 'npm',
|
|
78
|
+
command: 'npm --version',
|
|
79
|
+
versionRegex: /([\d.]+)/,
|
|
80
|
+
required: false,
|
|
81
|
+
installHint: 'npm comes with Node.js: https://nodejs.org',
|
|
82
|
+
},
|
|
83
|
+
claude: {
|
|
84
|
+
name: 'claude',
|
|
85
|
+
command: 'claude --version',
|
|
86
|
+
versionRegex: /claude ([\d.]+)/,
|
|
87
|
+
required: false,
|
|
88
|
+
installHint: 'Install Claude Code: npm install -g @anthropic-ai/claude-code',
|
|
89
|
+
docs: 'https://docs.anthropic.com/claude-code',
|
|
90
|
+
},
|
|
91
|
+
gemini: {
|
|
92
|
+
name: 'gemini',
|
|
93
|
+
command: 'gemini --version',
|
|
94
|
+
versionRegex: /gemini ([\d.]+)/,
|
|
95
|
+
required: false,
|
|
96
|
+
installHint: 'Install Gemini CLI: npm install -g @google/gemini-cli',
|
|
97
|
+
docs: 'https://ai.google.dev/gemini-api/docs',
|
|
98
|
+
},
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// ============================================================================
|
|
102
|
+
// DEPENDENCY VALIDATOR
|
|
103
|
+
// ============================================================================
|
|
104
|
+
|
|
105
|
+
class DependencyValidator {
|
|
106
|
+
private cache = new Map<string, ToolStatus>()
|
|
107
|
+
private cacheTimeout = 60_000 // 1 minute cache
|
|
108
|
+
private cacheTimestamps = new Map<string, number>()
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Check if a tool is available
|
|
112
|
+
* Uses caching to avoid repeated execSync calls
|
|
113
|
+
*/
|
|
114
|
+
checkTool(toolName: string): ToolStatus {
|
|
115
|
+
// Check cache first
|
|
116
|
+
const cached = this.getCached(toolName)
|
|
117
|
+
if (cached) return cached
|
|
118
|
+
|
|
119
|
+
const definition = TOOLS[toolName]
|
|
120
|
+
if (!definition) {
|
|
121
|
+
// Unknown tool - try to check directly
|
|
122
|
+
return this.checkUnknownTool(toolName)
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const status = this.executeCheck(definition)
|
|
126
|
+
this.setCache(toolName, status)
|
|
127
|
+
return status
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Ensure a tool is available, throw helpful error if not
|
|
132
|
+
* Use this before operations that require a specific tool
|
|
133
|
+
*/
|
|
134
|
+
ensureTool(toolName: string): void {
|
|
135
|
+
const status = this.checkTool(toolName)
|
|
136
|
+
|
|
137
|
+
if (!status.available) {
|
|
138
|
+
const definition = TOOLS[toolName]
|
|
139
|
+
const error = status.error || {
|
|
140
|
+
message: `${toolName} is not available`,
|
|
141
|
+
hint: definition?.installHint || `Install ${toolName} and try again`,
|
|
142
|
+
docs: definition?.docs,
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
throw new DependencyError(error)
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Ensure multiple tools are available
|
|
151
|
+
*/
|
|
152
|
+
ensureTools(toolNames: string[]): void {
|
|
153
|
+
const missing: string[] = []
|
|
154
|
+
|
|
155
|
+
for (const name of toolNames) {
|
|
156
|
+
const status = this.checkTool(name)
|
|
157
|
+
if (!status.available) {
|
|
158
|
+
missing.push(name)
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
if (missing.length > 0) {
|
|
163
|
+
const hints = missing
|
|
164
|
+
.map((name) => {
|
|
165
|
+
const def = TOOLS[name]
|
|
166
|
+
return def ? ` ${name}: ${def.installHint}` : ` ${name}: Install and try again`
|
|
167
|
+
})
|
|
168
|
+
.join('\n')
|
|
169
|
+
|
|
170
|
+
throw new DependencyError({
|
|
171
|
+
message: `Missing required tools: ${missing.join(', ')}`,
|
|
172
|
+
hint: `Install the following:\n${hints}`,
|
|
173
|
+
})
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Check if tool is available (boolean convenience method)
|
|
179
|
+
*/
|
|
180
|
+
isAvailable(toolName: string): boolean {
|
|
181
|
+
return this.checkTool(toolName).available
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Get tool version if available
|
|
186
|
+
*/
|
|
187
|
+
getVersion(toolName: string): string | undefined {
|
|
188
|
+
return this.checkTool(toolName).version
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Check multiple tools and return summary
|
|
193
|
+
*/
|
|
194
|
+
checkAll(toolNames?: string[]): Map<string, ToolStatus> {
|
|
195
|
+
const names = toolNames || Object.keys(TOOLS)
|
|
196
|
+
const results = new Map<string, ToolStatus>()
|
|
197
|
+
|
|
198
|
+
for (const name of names) {
|
|
199
|
+
results.set(name, this.checkTool(name))
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
return results
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Clear the cache (useful for tests or after installations)
|
|
207
|
+
*/
|
|
208
|
+
clearCache(): void {
|
|
209
|
+
this.cache.clear()
|
|
210
|
+
this.cacheTimestamps.clear()
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// ==========================================================================
|
|
214
|
+
// PRIVATE METHODS
|
|
215
|
+
// ==========================================================================
|
|
216
|
+
|
|
217
|
+
private executeCheck(definition: ToolDefinition): ToolStatus {
|
|
218
|
+
try {
|
|
219
|
+
const output = execSync(definition.command, {
|
|
220
|
+
encoding: 'utf-8',
|
|
221
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
222
|
+
timeout: 5000, // 5 second timeout
|
|
223
|
+
})
|
|
224
|
+
|
|
225
|
+
let version: string | undefined
|
|
226
|
+
if (definition.versionRegex) {
|
|
227
|
+
const match = output.match(definition.versionRegex)
|
|
228
|
+
version = match ? match[1] : undefined
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
return { available: true, version }
|
|
232
|
+
} catch {
|
|
233
|
+
return {
|
|
234
|
+
available: false,
|
|
235
|
+
error: createError(
|
|
236
|
+
`${definition.name} is not installed or not in PATH`,
|
|
237
|
+
definition.installHint,
|
|
238
|
+
{ docs: definition.docs }
|
|
239
|
+
),
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
private checkUnknownTool(toolName: string): ToolStatus {
|
|
245
|
+
try {
|
|
246
|
+
// Try running with --version (common convention)
|
|
247
|
+
execSync(`${toolName} --version`, {
|
|
248
|
+
encoding: 'utf-8',
|
|
249
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
250
|
+
timeout: 5000,
|
|
251
|
+
})
|
|
252
|
+
return { available: true }
|
|
253
|
+
} catch {
|
|
254
|
+
// Try running with -v
|
|
255
|
+
try {
|
|
256
|
+
execSync(`${toolName} -v`, {
|
|
257
|
+
encoding: 'utf-8',
|
|
258
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
259
|
+
timeout: 5000,
|
|
260
|
+
})
|
|
261
|
+
return { available: true }
|
|
262
|
+
} catch {
|
|
263
|
+
return {
|
|
264
|
+
available: false,
|
|
265
|
+
error: createError(
|
|
266
|
+
`${toolName} is not installed or not in PATH`,
|
|
267
|
+
`Install ${toolName} and try again`
|
|
268
|
+
),
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
private getCached(toolName: string): ToolStatus | null {
|
|
275
|
+
const timestamp = this.cacheTimestamps.get(toolName)
|
|
276
|
+
if (!timestamp) return null
|
|
277
|
+
|
|
278
|
+
// Check if cache is expired
|
|
279
|
+
if (Date.now() - timestamp > this.cacheTimeout) {
|
|
280
|
+
this.cache.delete(toolName)
|
|
281
|
+
this.cacheTimestamps.delete(toolName)
|
|
282
|
+
return null
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
return this.cache.get(toolName) || null
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
private setCache(toolName: string, status: ToolStatus): void {
|
|
289
|
+
this.cache.set(toolName, status)
|
|
290
|
+
this.cacheTimestamps.set(toolName, Date.now())
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
// ============================================================================
|
|
295
|
+
// CUSTOM ERROR
|
|
296
|
+
// ============================================================================
|
|
297
|
+
|
|
298
|
+
/**
|
|
299
|
+
* Error thrown when a required dependency is missing
|
|
300
|
+
*/
|
|
301
|
+
export class DependencyError extends Error {
|
|
302
|
+
readonly hint?: string
|
|
303
|
+
readonly docs?: string
|
|
304
|
+
|
|
305
|
+
constructor(error: ErrorWithHint) {
|
|
306
|
+
super(error.message)
|
|
307
|
+
this.name = 'DependencyError'
|
|
308
|
+
this.hint = error.hint
|
|
309
|
+
this.docs = error.docs
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
// ============================================================================
|
|
314
|
+
// EXPORTS
|
|
315
|
+
// ============================================================================
|
|
316
|
+
|
|
317
|
+
export const dependencyValidator = new DependencyValidator()
|
|
318
|
+
export default dependencyValidator
|
|
@@ -4,11 +4,15 @@
|
|
|
4
4
|
* Extracted from sync-service.ts for single responsibility.
|
|
5
5
|
* Analyzes git state: branch, commits, contributors, changes, etc.
|
|
6
6
|
*
|
|
7
|
-
*
|
|
7
|
+
* Uses graceful degradation for git availability (PRJ-114).
|
|
8
|
+
* Avoids shell pipes for better cross-platform compatibility.
|
|
9
|
+
*
|
|
10
|
+
* @version 1.1.0
|
|
8
11
|
*/
|
|
9
12
|
|
|
10
13
|
import { exec } from 'node:child_process'
|
|
11
14
|
import { promisify } from 'node:util'
|
|
15
|
+
import { dependencyValidator } from './dependency-validator'
|
|
12
16
|
|
|
13
17
|
const execAsync = promisify(exec)
|
|
14
18
|
|
|
@@ -41,6 +45,7 @@ export class GitAnalyzer {
|
|
|
41
45
|
|
|
42
46
|
/**
|
|
43
47
|
* Analyze git repository state
|
|
48
|
+
* Returns defaults if git is not available (graceful degradation)
|
|
44
49
|
*/
|
|
45
50
|
async analyze(): Promise<GitData> {
|
|
46
51
|
const data: GitData = {
|
|
@@ -55,6 +60,11 @@ export class GitAnalyzer {
|
|
|
55
60
|
weeklyCommits: 0,
|
|
56
61
|
}
|
|
57
62
|
|
|
63
|
+
// PRJ-114: Check git availability first (graceful degradation)
|
|
64
|
+
if (!dependencyValidator.isAvailable('git')) {
|
|
65
|
+
return data
|
|
66
|
+
}
|
|
67
|
+
|
|
58
68
|
try {
|
|
59
69
|
// Run independent git commands in parallel for speed
|
|
60
70
|
const [branch, commits, contributors, status, log, weekly] = await Promise.all([
|
|
@@ -112,13 +122,16 @@ export class GitAnalyzer {
|
|
|
112
122
|
|
|
113
123
|
/**
|
|
114
124
|
* Get contributor count
|
|
125
|
+
* PRJ-114: Avoids shell pipe by counting lines in JS
|
|
115
126
|
*/
|
|
116
127
|
async getContributorCount(): Promise<number> {
|
|
117
128
|
try {
|
|
118
|
-
const { stdout } = await execAsync('git shortlog -sn --all
|
|
129
|
+
const { stdout } = await execAsync('git shortlog -sn --all', {
|
|
119
130
|
cwd: this.projectPath,
|
|
120
131
|
})
|
|
121
|
-
|
|
132
|
+
// Count non-empty lines instead of piping to wc -l
|
|
133
|
+
const lines = stdout.trim().split('\n').filter(Boolean)
|
|
134
|
+
return lines.length
|
|
122
135
|
} catch {
|
|
123
136
|
return 0
|
|
124
137
|
}
|
|
@@ -192,13 +205,16 @@ export class GitAnalyzer {
|
|
|
192
205
|
|
|
193
206
|
/**
|
|
194
207
|
* Get commit count in the last week
|
|
208
|
+
* PRJ-114: Avoids shell pipe by counting lines in JS
|
|
195
209
|
*/
|
|
196
210
|
async getWeeklyCommitCount(): Promise<number> {
|
|
197
211
|
try {
|
|
198
|
-
const { stdout } = await execAsync('git log --oneline --since="1 week ago"
|
|
212
|
+
const { stdout } = await execAsync('git log --oneline --since="1 week ago"', {
|
|
199
213
|
cwd: this.projectPath,
|
|
200
214
|
})
|
|
201
|
-
|
|
215
|
+
// Count non-empty lines instead of piping to wc -l
|
|
216
|
+
const lines = stdout.trim().split('\n').filter(Boolean)
|
|
217
|
+
return lines.length
|
|
202
218
|
} catch {
|
|
203
219
|
return 0
|
|
204
220
|
}
|
|
@@ -220,18 +236,19 @@ export class GitAnalyzer {
|
|
|
220
236
|
|
|
221
237
|
/**
|
|
222
238
|
* Get default main branch name (main or master)
|
|
239
|
+
* PRJ-114: Avoids shell pipe by using JS string replace
|
|
223
240
|
*/
|
|
224
241
|
async getDefaultBranch(): Promise<string> {
|
|
225
242
|
try {
|
|
226
243
|
// Try to get from remote
|
|
227
|
-
const { stdout } = await execAsync(
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
const branch = stdout.trim()
|
|
244
|
+
const { stdout } = await execAsync('git symbolic-ref refs/remotes/origin/HEAD', {
|
|
245
|
+
cwd: this.projectPath,
|
|
246
|
+
})
|
|
247
|
+
// Use JS replace instead of piping to sed
|
|
248
|
+
const branch = stdout.trim().replace(/^refs\/remotes\/origin\//, '')
|
|
232
249
|
if (branch) return branch
|
|
233
250
|
} catch {
|
|
234
|
-
// Ignore
|
|
251
|
+
// Ignore - remote may not exist
|
|
235
252
|
}
|
|
236
253
|
|
|
237
254
|
// Fallback: check if main or master exists
|