prjct-cli 0.61.0 → 0.63.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 +72 -0
- package/core/__tests__/services/dependency-validator.test.ts +175 -0
- package/core/constants/index.ts +54 -0
- package/core/infrastructure/setup.ts +40 -3
- package/core/services/dependency-validator.ts +318 -0
- package/core/services/git-analyzer.ts +38 -12
- package/core/services/skill-installer.ts +21 -3
- package/core/sync/sync-client.ts +32 -2
- package/dist/bin/prjct.mjs +322 -19
- package/dist/core/infrastructure/setup.js +314 -8
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,77 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [0.63.0] - 2026-02-05
|
|
4
|
+
|
|
5
|
+
### Features
|
|
6
|
+
|
|
7
|
+
- add timeout management with configurable limits (PRJ-111) (#104)
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
## [0.62.1] - 2026-02-05
|
|
11
|
+
|
|
12
|
+
### Improved
|
|
13
|
+
|
|
14
|
+
- **Timeout management (PRJ-111)**: Operations now timeout gracefully with configurable limits instead of hanging indefinitely
|
|
15
|
+
|
|
16
|
+
### Implementation Details
|
|
17
|
+
|
|
18
|
+
Added `TIMEOUTS` constants with `getTimeout()` helper function supporting environment variable overrides (`PRJCT_TIMEOUT_*`). Applied timeouts to: npm install (120s), git operations (10s), git clone (60s), and fetch API calls (30s via AbortController). Timeout errors now include helpful hints for increasing limits.
|
|
19
|
+
|
|
20
|
+
### Learnings
|
|
21
|
+
|
|
22
|
+
- AbortController is the standard way to timeout fetch() calls - create controller, set timeout to call abort(), pass signal to fetch
|
|
23
|
+
- Environment variable pattern `PRJCT_TIMEOUT_*` allows user configurability without config files
|
|
24
|
+
|
|
25
|
+
### Test Plan
|
|
26
|
+
|
|
27
|
+
#### For QA
|
|
28
|
+
1. Set `PRJCT_TIMEOUT_GIT_OPERATION=100` (100ms) and run `prjct sync` - should timeout
|
|
29
|
+
2. Unset env var, run `prjct sync` on a large repo - should complete within 10s
|
|
30
|
+
3. Test npm install timeout with `PRJCT_TIMEOUT_NPM_INSTALL=1000` (1s) - should timeout with helpful message
|
|
31
|
+
|
|
32
|
+
#### For Users
|
|
33
|
+
- Operations now timeout gracefully instead of hanging indefinitely
|
|
34
|
+
- Set `PRJCT_TIMEOUT_*` env vars to customize timeouts (e.g., `export PRJCT_TIMEOUT_API_REQUEST=60000` for 60s)
|
|
35
|
+
- No breaking changes
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
## [0.62.0] - 2026-02-05
|
|
39
|
+
|
|
40
|
+
### Features
|
|
41
|
+
|
|
42
|
+
- implement graceful degradation for missing dependencies (PRJ-114) (#103)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
## [0.62.0] - 2026-02-05
|
|
46
|
+
|
|
47
|
+
### Improved
|
|
48
|
+
|
|
49
|
+
- **Graceful degradation (PRJ-114)**: prjct now handles missing dependencies with helpful recovery hints instead of crashing
|
|
50
|
+
|
|
51
|
+
### Implementation Details
|
|
52
|
+
|
|
53
|
+
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.
|
|
54
|
+
|
|
55
|
+
### Learnings
|
|
56
|
+
|
|
57
|
+
- Shell pipes like `wc -l` and `sed` aren't cross-platform - use JS string operations instead
|
|
58
|
+
- execSync calls are expensive - cache results with TTL
|
|
59
|
+
- npm may not be available even when node is - check separately
|
|
60
|
+
|
|
61
|
+
### Test Plan
|
|
62
|
+
|
|
63
|
+
#### For QA
|
|
64
|
+
1. Run `prjct sync` on a machine without git - verify helpful error message instead of crash
|
|
65
|
+
2. Run `prjct skill install owner/repo` without git - verify error suggests install methods
|
|
66
|
+
3. Run `prjct start` without npm - verify suggests alternatives (yarn, pnpm, brew)
|
|
67
|
+
4. Run `prjct doctor` - verify all tool checks display correctly
|
|
68
|
+
|
|
69
|
+
#### For Users
|
|
70
|
+
- prjct now gracefully handles missing dependencies with helpful recovery hints
|
|
71
|
+
- Automatic - errors include installation suggestions
|
|
72
|
+
- No breaking changes
|
|
73
|
+
|
|
74
|
+
|
|
3
75
|
## [0.61.0] - 2026-02-05
|
|
4
76
|
|
|
5
77
|
### 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
|
+
})
|
package/core/constants/index.ts
CHANGED
|
@@ -218,6 +218,52 @@ export const PLANNING_TOOLS = [
|
|
|
218
218
|
'GetDateTime',
|
|
219
219
|
] as const
|
|
220
220
|
|
|
221
|
+
// =============================================================================
|
|
222
|
+
// Timeout Constants (PRJ-111)
|
|
223
|
+
// =============================================================================
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* Timeout values in milliseconds for various operations.
|
|
227
|
+
* Can be overridden via PRJCT_TIMEOUT_* environment variables.
|
|
228
|
+
*/
|
|
229
|
+
export const TIMEOUTS = {
|
|
230
|
+
/** Tool availability checks (git --version, npm --version) */
|
|
231
|
+
TOOL_CHECK: 5_000,
|
|
232
|
+
|
|
233
|
+
/** Standard git operations (status, add, commit) */
|
|
234
|
+
GIT_OPERATION: 10_000,
|
|
235
|
+
|
|
236
|
+
/** Git clone with --depth 1 */
|
|
237
|
+
GIT_CLONE: 60_000,
|
|
238
|
+
|
|
239
|
+
/** HTTP fetch/API requests */
|
|
240
|
+
API_REQUEST: 30_000,
|
|
241
|
+
|
|
242
|
+
/** npm install -g (CLI installation) - 2 minutes */
|
|
243
|
+
NPM_INSTALL: 120_000,
|
|
244
|
+
|
|
245
|
+
/** User-defined workflow hooks */
|
|
246
|
+
WORKFLOW_HOOK: 60_000,
|
|
247
|
+
} as const
|
|
248
|
+
|
|
249
|
+
export type TimeoutKey = keyof typeof TIMEOUTS
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* Get timeout value with optional environment variable override.
|
|
253
|
+
* Environment variables: PRJCT_TIMEOUT_TOOL_CHECK, PRJCT_TIMEOUT_GIT_OPERATION, etc.
|
|
254
|
+
*/
|
|
255
|
+
export function getTimeout(key: TimeoutKey): number {
|
|
256
|
+
const envVar = `PRJCT_TIMEOUT_${key}`
|
|
257
|
+
const envValue = process.env[envVar]
|
|
258
|
+
if (envValue) {
|
|
259
|
+
const parsed = Number.parseInt(envValue, 10)
|
|
260
|
+
if (!Number.isNaN(parsed) && parsed > 0) {
|
|
261
|
+
return parsed
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
return TIMEOUTS[key]
|
|
265
|
+
}
|
|
266
|
+
|
|
221
267
|
// =============================================================================
|
|
222
268
|
// Combined Exports
|
|
223
269
|
// =============================================================================
|
|
@@ -245,3 +291,11 @@ export const PLAN = {
|
|
|
245
291
|
DESTRUCTIVE_COMMANDS,
|
|
246
292
|
TOOLS: PLANNING_TOOLS,
|
|
247
293
|
} as const
|
|
294
|
+
|
|
295
|
+
/**
|
|
296
|
+
* Combined timeout exports for easy import.
|
|
297
|
+
*/
|
|
298
|
+
export const TIMEOUT = {
|
|
299
|
+
VALUES: TIMEOUTS,
|
|
300
|
+
get: getTimeout,
|
|
301
|
+
} as const
|
|
@@ -21,6 +21,8 @@ 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 { getTimeout } from '../constants'
|
|
25
|
+
import { dependencyValidator } from '../services/dependency-validator'
|
|
24
26
|
import { isNotFoundError } from '../types/fs'
|
|
25
27
|
import type { AIProviderConfig, AIProviderName } from '../types/provider'
|
|
26
28
|
import { getPackageRoot, VERSION } from '../utils/version'
|
|
@@ -67,24 +69,59 @@ async function _hasAICLI(provider: AIProviderConfig): Promise<boolean> {
|
|
|
67
69
|
|
|
68
70
|
/**
|
|
69
71
|
* Install AI CLI for the specified provider
|
|
72
|
+
* PRJ-114: Enhanced with graceful degradation and alternative install suggestions
|
|
70
73
|
*/
|
|
71
74
|
async function installAICLI(provider: AIProviderConfig): Promise<boolean> {
|
|
72
75
|
const packageName =
|
|
73
76
|
provider.name === 'claude' ? '@anthropic-ai/claude-code' : '@google/gemini-cli'
|
|
74
77
|
|
|
78
|
+
// PRJ-114: Check npm availability first
|
|
79
|
+
if (!dependencyValidator.isAvailable('npm')) {
|
|
80
|
+
console.log(`${YELLOW}⚠️ npm is not available${NC}`)
|
|
81
|
+
console.log('')
|
|
82
|
+
console.log(`${DIM}Install ${provider.displayName} using one of:${NC}`)
|
|
83
|
+
console.log(`${DIM} • Install Node.js: https://nodejs.org${NC}`)
|
|
84
|
+
console.log(
|
|
85
|
+
`${DIM} • Use Homebrew: brew install ${provider.name === 'claude' ? 'claude' : 'gemini'}${NC}`
|
|
86
|
+
)
|
|
87
|
+
console.log(`${DIM} • Use npx directly: npx ${packageName}${NC}`)
|
|
88
|
+
console.log('')
|
|
89
|
+
return false
|
|
90
|
+
}
|
|
91
|
+
|
|
75
92
|
try {
|
|
76
93
|
console.log(`${YELLOW}📦 ${provider.displayName} not found. Installing...${NC}`)
|
|
77
94
|
console.log('')
|
|
78
|
-
|
|
95
|
+
// PRJ-111: Add timeout to npm install (default: 2 minutes, configurable via PRJCT_TIMEOUT_NPM_INSTALL)
|
|
96
|
+
execSync(`npm install -g ${packageName}`, {
|
|
97
|
+
stdio: 'inherit',
|
|
98
|
+
timeout: getTimeout('NPM_INSTALL'),
|
|
99
|
+
})
|
|
79
100
|
console.log('')
|
|
80
101
|
console.log(`${GREEN}✓${NC} ${provider.displayName} installed successfully`)
|
|
81
102
|
console.log('')
|
|
82
103
|
return true
|
|
83
104
|
} catch (error) {
|
|
105
|
+
const err = error as Error & { killed?: boolean; signal?: string }
|
|
106
|
+
const isTimeout = err.killed && err.signal === 'SIGTERM'
|
|
107
|
+
|
|
108
|
+
if (isTimeout) {
|
|
109
|
+
console.log(`${YELLOW}⚠️ Installation timed out for ${provider.displayName}${NC}`)
|
|
110
|
+
console.log('')
|
|
111
|
+
console.log(`${DIM}The npm install took too long. Try:${NC}`)
|
|
112
|
+
console.log(`${DIM} • Set PRJCT_TIMEOUT_NPM_INSTALL=300000 for 5 minutes${NC}`)
|
|
113
|
+
console.log(`${DIM} • Run manually: npm install -g ${packageName}${NC}`)
|
|
114
|
+
} else {
|
|
115
|
+
console.log(`${YELLOW}⚠️ Failed to install ${provider.displayName}: ${err.message}${NC}`)
|
|
116
|
+
}
|
|
117
|
+
console.log('')
|
|
118
|
+
console.log(`${DIM}Alternative installation methods:${NC}`)
|
|
119
|
+
console.log(`${DIM} • npm: npm install -g ${packageName}${NC}`)
|
|
120
|
+
console.log(`${DIM} • yarn: yarn global add ${packageName}${NC}`)
|
|
121
|
+
console.log(`${DIM} • pnpm: pnpm add -g ${packageName}${NC}`)
|
|
84
122
|
console.log(
|
|
85
|
-
`${
|
|
123
|
+
`${DIM} • brew: brew install ${provider.name === 'claude' ? 'claude' : 'gemini'}${NC}`
|
|
86
124
|
)
|
|
87
|
-
console.log(`${DIM}Please install manually: npm install -g ${packageName}${NC}`)
|
|
88
125
|
console.log('')
|
|
89
126
|
return false
|
|
90
127
|
}
|