prjct-cli 0.60.2 → 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 CHANGED
@@ -1,5 +1,78 @@
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
+
40
+ ## [0.61.0] - 2026-02-05
41
+
42
+ ### Features
43
+
44
+ - add .prjct-state.md local state file for persistence (PRJ-112) (#102)
45
+
46
+
47
+ ## [0.61.0] - 2026-02-05
48
+
49
+ ### Features
50
+
51
+ - **Local state file (PRJ-112)**: New `.prjct-state.md` file generated in project root for local persistence
52
+
53
+ ### Implementation Details
54
+
55
+ Created `LocalStateGenerator` service that generates a markdown file showing current task state. Integrated via write-through pattern - `StateStorage.write()` now also generates the local state file. Also hooks into `sync-service.ts` for state.json updates during sync.
56
+
57
+ ### Learnings
58
+
59
+ - Write-through pattern: JSON storage triggers MD generation automatically
60
+ - State can be written from multiple entry points (storage class + sync service) - need hooks in both places
61
+
62
+ ### Test Plan
63
+
64
+ #### For QA
65
+ 1. Run `prjct sync` on any project - verify `.prjct-state.md` is generated in project root
66
+ 2. Start a task with `p. task "test"` - verify `.prjct-state.md` updates with task info
67
+ 3. Check that subtasks, progress, and status are displayed correctly
68
+ 4. Verify the file has "DO NOT EDIT" header comment
69
+
70
+ #### For Users
71
+ - New `.prjct-state.md` file in project root shows current task state
72
+ - Automatic - file updates whenever prjct state changes
73
+ - No breaking changes
74
+
75
+
3
76
  ## [0.60.2] - 2026-02-05
4
77
 
5
78
  ### Performance
@@ -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(`${DIM}Please install manually: npm install -g ${packageName}${NC}`)
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