prjct-cli 1.6.9 → 1.6.11
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 +66 -1
- package/bin/prjct.ts +11 -2
- package/core/bus/bus.ts +24 -0
- package/core/infrastructure/ai-provider.ts +17 -5
- package/core/services/watch-service.ts +20 -3
- package/core/utils/provider-cache.ts +49 -0
- package/dist/bin/prjct.mjs +1120 -1046
- package/dist/core/infrastructure/command-installer.js +140 -95
- package/dist/core/infrastructure/setup.js +260 -215
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,12 +1,77 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [1.6.11] - 2026-02-07
|
|
4
|
+
|
|
5
|
+
### Performance
|
|
6
|
+
|
|
7
|
+
- cache provider detection to eliminate redundant shell spawns (PRJ-289) (#136)
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
## [1.6.13] - 2026-02-07
|
|
11
|
+
|
|
12
|
+
### Improvements
|
|
13
|
+
- **Cache provider detection to eliminate redundant shell spawns (PRJ-289)**: Provider detection results (Claude, Gemini CLI availability) are now cached to `~/.prjct-cli/cache/providers.json` with a 10-minute TTL. Subsequent CLI commands skip shell spawns entirely. Added 2-second timeout on `which`/`--version` spawns to prevent hangs. Added `--refresh` flag to force re-detection.
|
|
14
|
+
|
|
15
|
+
### Implementation Details
|
|
16
|
+
Created `core/utils/provider-cache.ts` with `readProviderCache()`, `writeProviderCache()`, and `invalidateProviderCache()`. Wired into `detectAllProviders()` — checks cache first, falls through to shell detection on miss or expiry, writes cache after detection. `bin/prjct.ts` parses `--refresh` early (like `--quiet`), invalidates cache, and passes refresh flag to detection.
|
|
17
|
+
|
|
18
|
+
### Learnings
|
|
19
|
+
- `execAsync` accepts a `timeout` option (milliseconds) that kills the child process on expiry — ideal for preventing hangs on broken CLI installations.
|
|
20
|
+
- Biome enforces `Array#indexOf()` over `Array#findIndex()` for simple equality checks (`useIndexOf` rule).
|
|
21
|
+
- Separating cache logic into its own module keeps `ai-provider.ts` focused on detection logic.
|
|
22
|
+
|
|
23
|
+
### Test Plan
|
|
24
|
+
|
|
25
|
+
#### For QA
|
|
26
|
+
1. Run `prjct --version` twice — second run should be near-instant (cache hit)
|
|
27
|
+
2. Delete `~/.prjct-cli/cache/providers.json`, run `prjct --version` — should re-detect and recreate cache
|
|
28
|
+
3. Run `prjct --version --refresh` — should take ~2s (forced re-detection)
|
|
29
|
+
4. Edit cache file to set timestamp 11 minutes ago — next command should re-detect (TTL expired)
|
|
30
|
+
5. Run `prjct sync` — should use cached providers, no shell spawns
|
|
31
|
+
|
|
32
|
+
#### For Users
|
|
33
|
+
**What changed:** Provider detection is now cached for 10 minutes. CLI startup is ~30x faster for cached commands (~66ms vs ~2100ms).
|
|
34
|
+
**How to use:** Automatic. Use `--refresh` to force re-detection after installing a new CLI.
|
|
35
|
+
**Breaking changes:** None.
|
|
36
|
+
|
|
37
|
+
## [1.6.10] - 2026-02-07
|
|
38
|
+
|
|
39
|
+
### Bug Fixes
|
|
40
|
+
|
|
41
|
+
- resolve signal handler and EventBus listener accumulation leaks (PRJ-287) (#135)
|
|
42
|
+
|
|
43
|
+
## [1.6.12] - 2026-02-07
|
|
44
|
+
|
|
45
|
+
### Bug Fixes
|
|
46
|
+
- **Fix signal handler and EventBus listener accumulation leaks (PRJ-287)**: WatchService signal handlers (`SIGINT`/`SIGTERM`) are now stored by reference and removed in `stop()`, preventing accumulation on restart cycles. `pendingChanges` Set is cleared on stop. EventBus gains `flush()` to clear history and stale once-listeners, and `removeAllListeners(event?)` for targeted cleanup.
|
|
47
|
+
|
|
48
|
+
### Implementation Details
|
|
49
|
+
Stored signal handler references as class properties (`sigintHandler`, `sigtermHandler`). In `start()`, old handlers are removed before new ones are added. In `stop()`, handlers are removed via `process.off()` and `pendingChanges` is cleared. EventBus `flush()` clears history array and all once-listeners. `removeAllListeners()` supports both targeted (single event) and global cleanup.
|
|
50
|
+
|
|
51
|
+
### Learnings
|
|
52
|
+
- Arrow functions passed to `process.on()` cannot be removed — must store named handler references for `process.off()`.
|
|
53
|
+
- Cleanup code after `process.exit(0)` is unreachable — perform all cleanup before the exit call.
|
|
54
|
+
|
|
55
|
+
### Test Plan
|
|
56
|
+
|
|
57
|
+
#### For QA
|
|
58
|
+
1. Start/stop watch mode 10 times — verify only 2 signal handlers (not 20)
|
|
59
|
+
2. Trigger file changes, stop — verify `pendingChanges` cleared
|
|
60
|
+
3. Emit 50 events, call `flush()` — verify history empty
|
|
61
|
+
4. Register `once()` for unfired event, `flush()` — verify listener removed
|
|
62
|
+
5. `removeAllListeners('event')` — verify only that event cleared
|
|
63
|
+
6. `removeAllListeners()` — verify all cleared
|
|
64
|
+
|
|
65
|
+
#### For Users
|
|
66
|
+
**What changed:** WatchService no longer leaks signal handlers on restart. EventBus has `flush()` and `removeAllListeners()`.
|
|
67
|
+
**Breaking changes:** None.
|
|
68
|
+
|
|
3
69
|
## [1.6.9] - 2026-02-07
|
|
4
70
|
|
|
5
71
|
### Bug Fixes
|
|
6
72
|
|
|
7
73
|
- resolve SSE zombie connections and infinite promise leak (PRJ-286) (#134)
|
|
8
74
|
|
|
9
|
-
|
|
10
75
|
## [1.6.11] - 2026-02-07
|
|
11
76
|
|
|
12
77
|
### Bug Fixes
|
package/bin/prjct.ts
CHANGED
|
@@ -16,6 +16,7 @@ import configManager from '../core/infrastructure/config-manager'
|
|
|
16
16
|
import editorsConfig from '../core/infrastructure/editors-config'
|
|
17
17
|
import { DEFAULT_PORT, startServer } from '../core/server/server'
|
|
18
18
|
import { fileExists } from '../core/utils/fs-helpers'
|
|
19
|
+
import { invalidateProviderCache } from '../core/utils/provider-cache'
|
|
19
20
|
import { VERSION } from '../core/utils/version'
|
|
20
21
|
|
|
21
22
|
/**
|
|
@@ -62,6 +63,14 @@ if (isQuietMode) {
|
|
|
62
63
|
setQuietMode(true)
|
|
63
64
|
}
|
|
64
65
|
|
|
66
|
+
// Parse --refresh flag (force re-detection of providers, invalidate cache)
|
|
67
|
+
const refreshIndex = args.indexOf('--refresh')
|
|
68
|
+
const isRefresh = refreshIndex !== -1
|
|
69
|
+
if (isRefresh) {
|
|
70
|
+
args.splice(refreshIndex, 1)
|
|
71
|
+
await invalidateProviderCache()
|
|
72
|
+
}
|
|
73
|
+
|
|
65
74
|
// Colors for output (chalk respects NO_COLOR env)
|
|
66
75
|
|
|
67
76
|
// Session tracking for commands that bypass core/index.ts
|
|
@@ -217,8 +226,8 @@ if (args[0] === 'start' || args[0] === 'setup') {
|
|
|
217
226
|
console.log(getHelp(topic))
|
|
218
227
|
process.exitCode = 0
|
|
219
228
|
} else if (args[0] === 'version' || args[0] === '-v' || args[0] === '--version') {
|
|
220
|
-
// Show version with provider status
|
|
221
|
-
const detection = await detectAllProviders()
|
|
229
|
+
// Show version with provider status (uses cached detection unless --refresh)
|
|
230
|
+
const detection = await detectAllProviders(isRefresh)
|
|
222
231
|
const home = os.homedir()
|
|
223
232
|
const cwd = process.cwd()
|
|
224
233
|
const [
|
package/core/bus/bus.ts
CHANGED
|
@@ -237,6 +237,30 @@ class EventBus {
|
|
|
237
237
|
this.onceListeners.clear()
|
|
238
238
|
}
|
|
239
239
|
|
|
240
|
+
/**
|
|
241
|
+
* Flush event history and clean up stale once-listeners.
|
|
242
|
+
* Call on task completion, project switch, or periodically.
|
|
243
|
+
*/
|
|
244
|
+
flush(): void {
|
|
245
|
+
this.history = []
|
|
246
|
+
|
|
247
|
+
// Remove once-listeners for events that were never fired
|
|
248
|
+
this.onceListeners.clear()
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* Remove all listeners for a specific event, or all events if none specified.
|
|
253
|
+
*/
|
|
254
|
+
removeAllListeners(event?: string): void {
|
|
255
|
+
if (event) {
|
|
256
|
+
this.listeners.delete(event)
|
|
257
|
+
this.onceListeners.delete(event)
|
|
258
|
+
} else {
|
|
259
|
+
this.listeners.clear()
|
|
260
|
+
this.onceListeners.clear()
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
240
264
|
/**
|
|
241
265
|
* Get count of listeners for an event
|
|
242
266
|
*/
|
|
@@ -22,8 +22,10 @@ import os from 'node:os'
|
|
|
22
22
|
import path from 'node:path'
|
|
23
23
|
import { promisify } from 'node:util'
|
|
24
24
|
import { fileExists } from '../utils/fs-helpers'
|
|
25
|
+
import { readProviderCache, writeProviderCache } from '../utils/provider-cache'
|
|
25
26
|
|
|
26
27
|
const execAsync = promisify(exec)
|
|
28
|
+
const SPAWN_TIMEOUT_MS = 2000
|
|
27
29
|
|
|
28
30
|
import type {
|
|
29
31
|
AIProviderConfig,
|
|
@@ -179,7 +181,7 @@ export const Providers: Record<AIProviderName, AIProviderConfig> = {
|
|
|
179
181
|
*/
|
|
180
182
|
async function whichCommand(command: string): Promise<string | null> {
|
|
181
183
|
try {
|
|
182
|
-
const { stdout } = await execAsync(`which ${command}
|
|
184
|
+
const { stdout } = await execAsync(`which ${command}`, { timeout: SPAWN_TIMEOUT_MS })
|
|
183
185
|
return stdout.trim()
|
|
184
186
|
} catch {
|
|
185
187
|
return null
|
|
@@ -191,7 +193,7 @@ async function whichCommand(command: string): Promise<string | null> {
|
|
|
191
193
|
*/
|
|
192
194
|
async function getCliVersion(command: string): Promise<string | null> {
|
|
193
195
|
try {
|
|
194
|
-
const { stdout } = await execAsync(`${command} --version
|
|
196
|
+
const { stdout } = await execAsync(`${command} --version`, { timeout: SPAWN_TIMEOUT_MS })
|
|
195
197
|
// Extract version number from output (e.g., "claude 1.0.0" -> "1.0.0")
|
|
196
198
|
const match = stdout.match(/\d+\.\d+\.\d+/)
|
|
197
199
|
return match ? match[0] : stdout.trim()
|
|
@@ -229,14 +231,24 @@ export async function detectProvider(provider: AIProviderName): Promise<Provider
|
|
|
229
231
|
|
|
230
232
|
/**
|
|
231
233
|
* Detect all available CLI-based providers
|
|
232
|
-
*
|
|
234
|
+
* Results are cached to disk with a 10-minute TTL to avoid redundant shell spawns.
|
|
235
|
+
* Pass refresh=true to force re-detection.
|
|
233
236
|
*/
|
|
234
|
-
export async function detectAllProviders(): Promise<{
|
|
237
|
+
export async function detectAllProviders(refresh = false): Promise<{
|
|
235
238
|
claude: ProviderDetectionResult
|
|
236
239
|
gemini: ProviderDetectionResult
|
|
237
240
|
}> {
|
|
241
|
+
if (!refresh) {
|
|
242
|
+
const cached = await readProviderCache()
|
|
243
|
+
if (cached) return cached
|
|
244
|
+
}
|
|
245
|
+
|
|
238
246
|
const [claude, gemini] = await Promise.all([detectProvider('claude'), detectProvider('gemini')])
|
|
239
|
-
|
|
247
|
+
const detection = { claude, gemini }
|
|
248
|
+
|
|
249
|
+
await writeProviderCache(detection).catch(() => {})
|
|
250
|
+
|
|
251
|
+
return detection
|
|
240
252
|
}
|
|
241
253
|
|
|
242
254
|
/**
|
|
@@ -102,6 +102,8 @@ class WatchService {
|
|
|
102
102
|
}
|
|
103
103
|
private isRunning: boolean = false
|
|
104
104
|
private syncCount: number = 0
|
|
105
|
+
private sigintHandler: (() => void) | null = null
|
|
106
|
+
private sigtermHandler: (() => void) | null = null
|
|
105
107
|
|
|
106
108
|
/**
|
|
107
109
|
* Start watching for file changes
|
|
@@ -151,9 +153,13 @@ class WatchService {
|
|
|
151
153
|
.on('unlink', (filePath: string) => this.handleChange('unlink', filePath))
|
|
152
154
|
.on('error', (error: unknown) => this.handleError(error as Error))
|
|
153
155
|
|
|
154
|
-
// Handle graceful shutdown
|
|
155
|
-
process.
|
|
156
|
-
process.
|
|
156
|
+
// Handle graceful shutdown — store references for proper removal
|
|
157
|
+
if (this.sigintHandler) process.off('SIGINT', this.sigintHandler)
|
|
158
|
+
if (this.sigtermHandler) process.off('SIGTERM', this.sigtermHandler)
|
|
159
|
+
this.sigintHandler = () => this.stop()
|
|
160
|
+
this.sigtermHandler = () => this.stop()
|
|
161
|
+
process.on('SIGINT', this.sigintHandler)
|
|
162
|
+
process.on('SIGTERM', this.sigtermHandler)
|
|
157
163
|
|
|
158
164
|
return { success: true }
|
|
159
165
|
}
|
|
@@ -177,6 +183,17 @@ class WatchService {
|
|
|
177
183
|
this.watcher = null
|
|
178
184
|
}
|
|
179
185
|
|
|
186
|
+
// Remove signal handlers to prevent accumulation
|
|
187
|
+
if (this.sigintHandler) {
|
|
188
|
+
process.off('SIGINT', this.sigintHandler)
|
|
189
|
+
this.sigintHandler = null
|
|
190
|
+
}
|
|
191
|
+
if (this.sigtermHandler) {
|
|
192
|
+
process.off('SIGTERM', this.sigtermHandler)
|
|
193
|
+
this.sigtermHandler = null
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
this.pendingChanges.clear()
|
|
180
197
|
this.isRunning = false
|
|
181
198
|
process.exit(0)
|
|
182
199
|
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import fs from 'node:fs/promises'
|
|
2
|
+
import os from 'node:os'
|
|
3
|
+
import path from 'node:path'
|
|
4
|
+
import type { ProviderDetectionResult } from '../types/provider'
|
|
5
|
+
|
|
6
|
+
const CACHE_DIR = path.join(os.homedir(), '.prjct-cli', 'cache')
|
|
7
|
+
const CACHE_FILE = path.join(CACHE_DIR, 'providers.json')
|
|
8
|
+
const TTL_MS = 10 * 60 * 1000 // 10 minutes
|
|
9
|
+
|
|
10
|
+
interface ProviderCache {
|
|
11
|
+
timestamp: string
|
|
12
|
+
detection: {
|
|
13
|
+
claude: ProviderDetectionResult
|
|
14
|
+
gemini: ProviderDetectionResult
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export async function readProviderCache(): Promise<ProviderCache['detection'] | null> {
|
|
19
|
+
try {
|
|
20
|
+
const raw = await fs.readFile(CACHE_FILE, 'utf-8')
|
|
21
|
+
const cache: ProviderCache = JSON.parse(raw)
|
|
22
|
+
|
|
23
|
+
if (!cache.timestamp || !cache.detection) return null
|
|
24
|
+
|
|
25
|
+
const age = Date.now() - new Date(cache.timestamp).getTime()
|
|
26
|
+
if (age > TTL_MS) return null
|
|
27
|
+
|
|
28
|
+
return cache.detection
|
|
29
|
+
} catch {
|
|
30
|
+
return null
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export async function writeProviderCache(detection: ProviderCache['detection']): Promise<void> {
|
|
35
|
+
const cache: ProviderCache = {
|
|
36
|
+
timestamp: new Date().toISOString(),
|
|
37
|
+
detection,
|
|
38
|
+
}
|
|
39
|
+
await fs.mkdir(CACHE_DIR, { recursive: true })
|
|
40
|
+
await fs.writeFile(CACHE_FILE, JSON.stringify(cache, null, 2))
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export async function invalidateProviderCache(): Promise<void> {
|
|
44
|
+
try {
|
|
45
|
+
await fs.unlink(CACHE_FILE)
|
|
46
|
+
} catch {
|
|
47
|
+
// File doesn't exist — fine
|
|
48
|
+
}
|
|
49
|
+
}
|