prjct-cli 1.1.0 → 1.2.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 +96 -0
- package/bin/prjct.ts +6 -0
- package/core/commands/analysis.ts +17 -29
- package/core/commands/planning.ts +1 -0
- package/core/index.ts +1 -0
- package/core/services/doctor-service.ts +13 -16
- package/core/services/hooks-service.ts +676 -0
- package/core/services/staleness-checker.ts +19 -7
- package/core/utils/help.ts +6 -0
- package/core/utils/output.ts +10 -0
- package/dist/bin/prjct.mjs +1350 -843
- package/package.json +1 -1
|
@@ -0,0 +1,676 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HooksService - Git hooks integration for auto-sync
|
|
3
|
+
*
|
|
4
|
+
* Manages git hooks that automatically sync prjct context on
|
|
5
|
+
* commit and checkout. Supports multiple hook managers:
|
|
6
|
+
* - lefthook
|
|
7
|
+
* - husky
|
|
8
|
+
* - direct .git/hooks/ scripts
|
|
9
|
+
*
|
|
10
|
+
* @see PRJ-128
|
|
11
|
+
* @module services/hooks-service
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { execSync } from 'node:child_process'
|
|
15
|
+
import fs from 'node:fs'
|
|
16
|
+
import path from 'node:path'
|
|
17
|
+
import chalk from 'chalk'
|
|
18
|
+
import configManager from '../infrastructure/config-manager'
|
|
19
|
+
import out from '../utils/output'
|
|
20
|
+
|
|
21
|
+
// ============================================================================
|
|
22
|
+
// TYPES
|
|
23
|
+
// ============================================================================
|
|
24
|
+
|
|
25
|
+
export type HookStrategy = 'lefthook' | 'husky' | 'direct'
|
|
26
|
+
export type HookName = 'post-commit' | 'post-checkout'
|
|
27
|
+
|
|
28
|
+
interface HookConfig {
|
|
29
|
+
enabled: boolean
|
|
30
|
+
strategy: HookStrategy
|
|
31
|
+
hooks: HookName[]
|
|
32
|
+
installedAt?: string
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
interface HooksStatusResult {
|
|
36
|
+
installed: boolean
|
|
37
|
+
strategy: HookStrategy | null
|
|
38
|
+
hooks: Array<{
|
|
39
|
+
name: HookName
|
|
40
|
+
installed: boolean
|
|
41
|
+
path: string
|
|
42
|
+
}>
|
|
43
|
+
detectedManagers: HookStrategy[]
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
interface HooksInstallResult {
|
|
47
|
+
success: boolean
|
|
48
|
+
strategy: HookStrategy
|
|
49
|
+
hooksInstalled: HookName[]
|
|
50
|
+
error?: string
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// ============================================================================
|
|
54
|
+
// HOOK SCRIPT TEMPLATES
|
|
55
|
+
// ============================================================================
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Shell script for post-commit hook
|
|
59
|
+
* Runs prjct sync in quiet mode with rate limiting
|
|
60
|
+
*/
|
|
61
|
+
function getPostCommitScript(): string {
|
|
62
|
+
return `#!/bin/sh
|
|
63
|
+
# prjct auto-sync hook (post-commit)
|
|
64
|
+
# Syncs project context after each commit
|
|
65
|
+
# Installed by: prjct hooks install
|
|
66
|
+
|
|
67
|
+
# Rate limit: skip if synced within last 30 seconds
|
|
68
|
+
LOCK_FILE="\${TMPDIR:-/tmp}/prjct-sync-$(pwd | md5sum 2>/dev/null | cut -d' ' -f1 || md5 -q -s "$(pwd)").lock"
|
|
69
|
+
if [ -f "$LOCK_FILE" ]; then
|
|
70
|
+
LOCK_AGE=$(( $(date +%s) - $(stat -f%m "$LOCK_FILE" 2>/dev/null || stat -c%Y "$LOCK_FILE" 2>/dev/null || echo 0) ))
|
|
71
|
+
if [ "$LOCK_AGE" -lt 30 ]; then
|
|
72
|
+
exit 0
|
|
73
|
+
fi
|
|
74
|
+
fi
|
|
75
|
+
|
|
76
|
+
# Run sync in background, suppress all output
|
|
77
|
+
if command -v prjct >/dev/null 2>&1; then
|
|
78
|
+
touch "$LOCK_FILE"
|
|
79
|
+
prjct sync --quiet --yes >/dev/null 2>&1 &
|
|
80
|
+
fi
|
|
81
|
+
|
|
82
|
+
exit 0
|
|
83
|
+
`
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Shell script for post-checkout hook
|
|
88
|
+
* Syncs project context after branch switch
|
|
89
|
+
*/
|
|
90
|
+
function getPostCheckoutScript(): string {
|
|
91
|
+
return `#!/bin/sh
|
|
92
|
+
# prjct auto-sync hook (post-checkout)
|
|
93
|
+
# Syncs project context after branch switch
|
|
94
|
+
# Installed by: prjct hooks install
|
|
95
|
+
|
|
96
|
+
# Only run on branch checkout (not file checkout)
|
|
97
|
+
# $3 is the checkout type flag: 1 = branch, 0 = file
|
|
98
|
+
if [ "$3" != "1" ]; then
|
|
99
|
+
exit 0
|
|
100
|
+
fi
|
|
101
|
+
|
|
102
|
+
# Skip if old and new refs are the same (no actual branch change)
|
|
103
|
+
if [ "$1" = "$2" ]; then
|
|
104
|
+
exit 0
|
|
105
|
+
fi
|
|
106
|
+
|
|
107
|
+
# Rate limit: skip if synced within last 30 seconds
|
|
108
|
+
LOCK_FILE="\${TMPDIR:-/tmp}/prjct-sync-$(pwd | md5sum 2>/dev/null | cut -d' ' -f1 || md5 -q -s "$(pwd)").lock"
|
|
109
|
+
if [ -f "$LOCK_FILE" ]; then
|
|
110
|
+
LOCK_AGE=$(( $(date +%s) - $(stat -f%m "$LOCK_FILE" 2>/dev/null || stat -c%Y "$LOCK_FILE" 2>/dev/null || echo 0) ))
|
|
111
|
+
if [ "$LOCK_AGE" -lt 30 ]; then
|
|
112
|
+
exit 0
|
|
113
|
+
fi
|
|
114
|
+
fi
|
|
115
|
+
|
|
116
|
+
# Run sync in background, suppress all output
|
|
117
|
+
if command -v prjct >/dev/null 2>&1; then
|
|
118
|
+
touch "$LOCK_FILE"
|
|
119
|
+
prjct sync --quiet --yes >/dev/null 2>&1 &
|
|
120
|
+
fi
|
|
121
|
+
|
|
122
|
+
exit 0
|
|
123
|
+
`
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// ============================================================================
|
|
127
|
+
// HOOK MANAGER DETECTION
|
|
128
|
+
// ============================================================================
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Detect which hook managers are available in the project
|
|
132
|
+
*/
|
|
133
|
+
function detectHookManagers(projectPath: string): HookStrategy[] {
|
|
134
|
+
const detected: HookStrategy[] = []
|
|
135
|
+
|
|
136
|
+
// Check for lefthook
|
|
137
|
+
if (
|
|
138
|
+
fs.existsSync(path.join(projectPath, 'lefthook.yml')) ||
|
|
139
|
+
fs.existsSync(path.join(projectPath, 'lefthook.yaml'))
|
|
140
|
+
) {
|
|
141
|
+
detected.push('lefthook')
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// Check for husky
|
|
145
|
+
if (
|
|
146
|
+
fs.existsSync(path.join(projectPath, '.husky')) ||
|
|
147
|
+
fs.existsSync(path.join(projectPath, '.husky', '_'))
|
|
148
|
+
) {
|
|
149
|
+
detected.push('husky')
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// Direct .git/hooks is always available if it's a git repo
|
|
153
|
+
if (fs.existsSync(path.join(projectPath, '.git'))) {
|
|
154
|
+
detected.push('direct')
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
return detected
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Select the best hook strategy based on what's available
|
|
162
|
+
*/
|
|
163
|
+
function selectStrategy(detected: HookStrategy[]): HookStrategy {
|
|
164
|
+
// Prefer managed hook tools over direct
|
|
165
|
+
if (detected.includes('lefthook')) return 'lefthook'
|
|
166
|
+
if (detected.includes('husky')) return 'husky'
|
|
167
|
+
return 'direct'
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// ============================================================================
|
|
171
|
+
// INSTALLATION STRATEGIES
|
|
172
|
+
// ============================================================================
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Install hooks via lefthook (append to existing config)
|
|
176
|
+
*/
|
|
177
|
+
function installLefthook(projectPath: string, hooks: HookName[]): boolean {
|
|
178
|
+
const configFile = fs.existsSync(path.join(projectPath, 'lefthook.yml'))
|
|
179
|
+
? 'lefthook.yml'
|
|
180
|
+
: 'lefthook.yaml'
|
|
181
|
+
const configPath = path.join(projectPath, configFile)
|
|
182
|
+
|
|
183
|
+
let content = fs.readFileSync(configPath, 'utf-8')
|
|
184
|
+
|
|
185
|
+
for (const hook of hooks) {
|
|
186
|
+
const sectionName = hook // e.g. "post-commit"
|
|
187
|
+
const commandName = `prjct-sync-${hook}`
|
|
188
|
+
|
|
189
|
+
// Check if already configured
|
|
190
|
+
if (content.includes(commandName)) {
|
|
191
|
+
continue
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
const hookBlock = `
|
|
195
|
+
${sectionName}:
|
|
196
|
+
commands:
|
|
197
|
+
${commandName}:
|
|
198
|
+
run: prjct sync --quiet --yes
|
|
199
|
+
fail_text: "prjct sync failed (non-blocking)"
|
|
200
|
+
`
|
|
201
|
+
|
|
202
|
+
// If the hook section already exists, add command to it
|
|
203
|
+
const sectionRegex = new RegExp(`^${sectionName}:\\s*$`, 'm')
|
|
204
|
+
if (sectionRegex.test(content)) {
|
|
205
|
+
// Insert command into existing section
|
|
206
|
+
content = content.replace(
|
|
207
|
+
sectionRegex,
|
|
208
|
+
`${sectionName}:\n commands:\n ${commandName}:\n run: prjct sync --quiet --yes\n fail_text: "prjct sync failed (non-blocking)"`
|
|
209
|
+
)
|
|
210
|
+
} else {
|
|
211
|
+
// Append new section
|
|
212
|
+
content = content.trimEnd() + '\n' + hookBlock
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
fs.writeFileSync(configPath, content, 'utf-8')
|
|
217
|
+
return true
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Install hooks via husky
|
|
222
|
+
*/
|
|
223
|
+
function installHusky(projectPath: string, hooks: HookName[]): boolean {
|
|
224
|
+
const huskyDir = path.join(projectPath, '.husky')
|
|
225
|
+
|
|
226
|
+
for (const hook of hooks) {
|
|
227
|
+
const hookPath = path.join(huskyDir, hook)
|
|
228
|
+
const script = hook === 'post-commit' ? getPostCommitScript() : getPostCheckoutScript()
|
|
229
|
+
|
|
230
|
+
if (fs.existsSync(hookPath)) {
|
|
231
|
+
// Append to existing hook if not already present
|
|
232
|
+
const existing = fs.readFileSync(hookPath, 'utf-8')
|
|
233
|
+
if (existing.includes('prjct sync')) {
|
|
234
|
+
continue
|
|
235
|
+
}
|
|
236
|
+
fs.appendFileSync(hookPath, '\n# prjct auto-sync\nprjct sync --quiet --yes &\n')
|
|
237
|
+
} else {
|
|
238
|
+
fs.writeFileSync(hookPath, script, { mode: 0o755 })
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
return true
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* Install hooks directly into .git/hooks/
|
|
247
|
+
*/
|
|
248
|
+
function installDirect(projectPath: string, hooks: HookName[]): boolean {
|
|
249
|
+
const hooksDir = path.join(projectPath, '.git', 'hooks')
|
|
250
|
+
|
|
251
|
+
if (!fs.existsSync(hooksDir)) {
|
|
252
|
+
fs.mkdirSync(hooksDir, { recursive: true })
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
for (const hook of hooks) {
|
|
256
|
+
const hookPath = path.join(hooksDir, hook)
|
|
257
|
+
const script = hook === 'post-commit' ? getPostCommitScript() : getPostCheckoutScript()
|
|
258
|
+
|
|
259
|
+
if (fs.existsSync(hookPath)) {
|
|
260
|
+
const existing = fs.readFileSync(hookPath, 'utf-8')
|
|
261
|
+
if (existing.includes('prjct sync')) {
|
|
262
|
+
continue // Already installed
|
|
263
|
+
}
|
|
264
|
+
// Append to existing hook
|
|
265
|
+
fs.appendFileSync(hookPath, '\n# prjct auto-sync\n' + script.split('\n').slice(1).join('\n'))
|
|
266
|
+
} else {
|
|
267
|
+
fs.writeFileSync(hookPath, script, { mode: 0o755 })
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
return true
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
// ============================================================================
|
|
275
|
+
// UNINSTALL STRATEGIES
|
|
276
|
+
// ============================================================================
|
|
277
|
+
|
|
278
|
+
function uninstallLefthook(projectPath: string): boolean {
|
|
279
|
+
const configFile = fs.existsSync(path.join(projectPath, 'lefthook.yml'))
|
|
280
|
+
? 'lefthook.yml'
|
|
281
|
+
: 'lefthook.yaml'
|
|
282
|
+
const configPath = path.join(projectPath, configFile)
|
|
283
|
+
|
|
284
|
+
if (!fs.existsSync(configPath)) return false
|
|
285
|
+
|
|
286
|
+
let content = fs.readFileSync(configPath, 'utf-8')
|
|
287
|
+
|
|
288
|
+
// Remove prjct-sync commands
|
|
289
|
+
content = content.replace(/\s*prjct-sync-[\w-]+:[\s\S]*?(?=\n\S|\n*$)/g, '')
|
|
290
|
+
|
|
291
|
+
// Clean up empty sections
|
|
292
|
+
content = content.replace(/^(post-commit|post-checkout):\s*commands:\s*$/gm, '')
|
|
293
|
+
|
|
294
|
+
fs.writeFileSync(configPath, content.trimEnd() + '\n', 'utf-8')
|
|
295
|
+
return true
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
function uninstallHusky(projectPath: string): boolean {
|
|
299
|
+
const huskyDir = path.join(projectPath, '.husky')
|
|
300
|
+
|
|
301
|
+
for (const hook of ['post-commit', 'post-checkout'] as HookName[]) {
|
|
302
|
+
const hookPath = path.join(huskyDir, hook)
|
|
303
|
+
if (!fs.existsSync(hookPath)) continue
|
|
304
|
+
|
|
305
|
+
const content = fs.readFileSync(hookPath, 'utf-8')
|
|
306
|
+
if (!content.includes('prjct sync')) continue
|
|
307
|
+
|
|
308
|
+
// Remove prjct lines
|
|
309
|
+
const cleaned = content
|
|
310
|
+
.split('\n')
|
|
311
|
+
.filter((line) => !line.includes('prjct sync') && !line.includes('prjct auto-sync'))
|
|
312
|
+
.join('\n')
|
|
313
|
+
|
|
314
|
+
if (cleaned.trim() === '#!/bin/sh' || cleaned.trim() === '#!/usr/bin/env sh') {
|
|
315
|
+
// Hook is now empty, remove it
|
|
316
|
+
fs.unlinkSync(hookPath)
|
|
317
|
+
} else {
|
|
318
|
+
fs.writeFileSync(hookPath, cleaned, { mode: 0o755 })
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
return true
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
function uninstallDirect(projectPath: string): boolean {
|
|
326
|
+
const hooksDir = path.join(projectPath, '.git', 'hooks')
|
|
327
|
+
|
|
328
|
+
for (const hook of ['post-commit', 'post-checkout'] as HookName[]) {
|
|
329
|
+
const hookPath = path.join(hooksDir, hook)
|
|
330
|
+
if (!fs.existsSync(hookPath)) continue
|
|
331
|
+
|
|
332
|
+
const content = fs.readFileSync(hookPath, 'utf-8')
|
|
333
|
+
if (!content.includes('prjct sync')) continue
|
|
334
|
+
|
|
335
|
+
if (content.includes('Installed by: prjct hooks install')) {
|
|
336
|
+
// Entirely ours, remove it
|
|
337
|
+
fs.unlinkSync(hookPath)
|
|
338
|
+
} else {
|
|
339
|
+
// Shared hook, just remove our lines
|
|
340
|
+
const cleaned = content
|
|
341
|
+
.split('\n')
|
|
342
|
+
.filter((line) => !line.includes('prjct sync') && !line.includes('prjct auto-sync'))
|
|
343
|
+
.join('\n')
|
|
344
|
+
fs.writeFileSync(hookPath, cleaned, { mode: 0o755 })
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
return true
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
// ============================================================================
|
|
352
|
+
// HOOKS SERVICE
|
|
353
|
+
// ============================================================================
|
|
354
|
+
|
|
355
|
+
class HooksService {
|
|
356
|
+
/**
|
|
357
|
+
* Install git hooks for auto-sync
|
|
358
|
+
*/
|
|
359
|
+
async install(
|
|
360
|
+
projectPath: string,
|
|
361
|
+
options: { strategy?: HookStrategy; hooks?: HookName[] } = {}
|
|
362
|
+
): Promise<HooksInstallResult> {
|
|
363
|
+
const hooks: HookName[] = options.hooks || ['post-commit', 'post-checkout']
|
|
364
|
+
|
|
365
|
+
// Detect available managers
|
|
366
|
+
const detected = detectHookManagers(projectPath)
|
|
367
|
+
|
|
368
|
+
if (detected.length === 0) {
|
|
369
|
+
return {
|
|
370
|
+
success: false,
|
|
371
|
+
strategy: 'direct',
|
|
372
|
+
hooksInstalled: [],
|
|
373
|
+
error: 'Not a git repository. Run "git init" first.',
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
const strategy = options.strategy || selectStrategy(detected)
|
|
378
|
+
|
|
379
|
+
try {
|
|
380
|
+
let success = false
|
|
381
|
+
|
|
382
|
+
switch (strategy) {
|
|
383
|
+
case 'lefthook':
|
|
384
|
+
success = installLefthook(projectPath, hooks)
|
|
385
|
+
break
|
|
386
|
+
case 'husky':
|
|
387
|
+
success = installHusky(projectPath, hooks)
|
|
388
|
+
break
|
|
389
|
+
case 'direct':
|
|
390
|
+
success = installDirect(projectPath, hooks)
|
|
391
|
+
break
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
if (success) {
|
|
395
|
+
// Save hook config to project.json
|
|
396
|
+
await this.saveHookConfig(projectPath, {
|
|
397
|
+
enabled: true,
|
|
398
|
+
strategy,
|
|
399
|
+
hooks,
|
|
400
|
+
installedAt: new Date().toISOString(),
|
|
401
|
+
})
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
return {
|
|
405
|
+
success,
|
|
406
|
+
strategy,
|
|
407
|
+
hooksInstalled: success ? hooks : [],
|
|
408
|
+
}
|
|
409
|
+
} catch (error) {
|
|
410
|
+
return {
|
|
411
|
+
success: false,
|
|
412
|
+
strategy,
|
|
413
|
+
hooksInstalled: [],
|
|
414
|
+
error: (error as Error).message,
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
/**
|
|
420
|
+
* Uninstall git hooks
|
|
421
|
+
*/
|
|
422
|
+
async uninstall(projectPath: string): Promise<{ success: boolean; error?: string }> {
|
|
423
|
+
try {
|
|
424
|
+
// Read current config to determine strategy
|
|
425
|
+
const config = await this.getHookConfig(projectPath)
|
|
426
|
+
const strategy = config?.strategy || 'direct'
|
|
427
|
+
|
|
428
|
+
let success = false
|
|
429
|
+
|
|
430
|
+
switch (strategy) {
|
|
431
|
+
case 'lefthook':
|
|
432
|
+
success = uninstallLefthook(projectPath)
|
|
433
|
+
break
|
|
434
|
+
case 'husky':
|
|
435
|
+
success = uninstallHusky(projectPath)
|
|
436
|
+
break
|
|
437
|
+
case 'direct':
|
|
438
|
+
success = uninstallDirect(projectPath)
|
|
439
|
+
break
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
// Clear hook config
|
|
443
|
+
if (success) {
|
|
444
|
+
await this.saveHookConfig(projectPath, {
|
|
445
|
+
enabled: false,
|
|
446
|
+
strategy,
|
|
447
|
+
hooks: [],
|
|
448
|
+
})
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
return { success }
|
|
452
|
+
} catch (error) {
|
|
453
|
+
return { success: false, error: (error as Error).message }
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
/**
|
|
458
|
+
* Get hook installation status
|
|
459
|
+
*/
|
|
460
|
+
async status(projectPath: string): Promise<HooksStatusResult> {
|
|
461
|
+
const detected = detectHookManagers(projectPath)
|
|
462
|
+
const config = await this.getHookConfig(projectPath)
|
|
463
|
+
|
|
464
|
+
const hookNames: HookName[] = ['post-commit', 'post-checkout']
|
|
465
|
+
const hooks = hookNames.map((name) => ({
|
|
466
|
+
name,
|
|
467
|
+
installed: this.isHookInstalled(projectPath, name, config?.strategy || null),
|
|
468
|
+
path: this.getHookPath(projectPath, name, config?.strategy || null),
|
|
469
|
+
}))
|
|
470
|
+
|
|
471
|
+
return {
|
|
472
|
+
installed: hooks.some((h) => h.installed),
|
|
473
|
+
strategy: config?.strategy || null,
|
|
474
|
+
hooks,
|
|
475
|
+
detectedManagers: detected,
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
/**
|
|
480
|
+
* Run the hooks CLI command
|
|
481
|
+
*/
|
|
482
|
+
async run(projectPath: string, subcommand: string): Promise<number> {
|
|
483
|
+
const projectId = await configManager.getProjectId(projectPath)
|
|
484
|
+
|
|
485
|
+
if (!projectId) {
|
|
486
|
+
console.error('No prjct project found. Run "prjct init" first.')
|
|
487
|
+
return 1
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
switch (subcommand) {
|
|
491
|
+
case 'install':
|
|
492
|
+
return this.runInstall(projectPath)
|
|
493
|
+
case 'uninstall':
|
|
494
|
+
return this.runUninstall(projectPath)
|
|
495
|
+
case 'status':
|
|
496
|
+
return this.runStatus(projectPath)
|
|
497
|
+
default:
|
|
498
|
+
return this.runStatus(projectPath)
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
// ==========================================================================
|
|
503
|
+
// CLI SUBCOMMANDS
|
|
504
|
+
// ==========================================================================
|
|
505
|
+
|
|
506
|
+
private async runInstall(projectPath: string): Promise<number> {
|
|
507
|
+
out.start()
|
|
508
|
+
out.section('Git Hooks Installation')
|
|
509
|
+
|
|
510
|
+
const detected = detectHookManagers(projectPath)
|
|
511
|
+
const strategy = selectStrategy(detected)
|
|
512
|
+
|
|
513
|
+
console.log(` Strategy: ${chalk.cyan(strategy)}`)
|
|
514
|
+
console.log(` Hooks: ${chalk.dim('post-commit, post-checkout')}`)
|
|
515
|
+
console.log('')
|
|
516
|
+
|
|
517
|
+
const result = await this.install(projectPath, { strategy })
|
|
518
|
+
|
|
519
|
+
if (result.success) {
|
|
520
|
+
out.done(`Hooks installed via ${result.strategy}`)
|
|
521
|
+
console.log('')
|
|
522
|
+
for (const hook of result.hooksInstalled) {
|
|
523
|
+
console.log(` ${chalk.green('✓')} ${hook}`)
|
|
524
|
+
}
|
|
525
|
+
console.log('')
|
|
526
|
+
console.log(chalk.dim(' Context will auto-sync on commit and branch switch.'))
|
|
527
|
+
console.log(chalk.dim(' Remove with: prjct hooks uninstall'))
|
|
528
|
+
} else {
|
|
529
|
+
out.fail(result.error || 'Failed to install hooks')
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
console.log('')
|
|
533
|
+
out.end()
|
|
534
|
+
return result.success ? 0 : 1
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
private async runUninstall(projectPath: string): Promise<number> {
|
|
538
|
+
out.start()
|
|
539
|
+
out.section('Git Hooks Removal')
|
|
540
|
+
|
|
541
|
+
const result = await this.uninstall(projectPath)
|
|
542
|
+
|
|
543
|
+
if (result.success) {
|
|
544
|
+
out.done('Hooks removed')
|
|
545
|
+
} else {
|
|
546
|
+
out.fail(result.error || 'Failed to remove hooks')
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
console.log('')
|
|
550
|
+
out.end()
|
|
551
|
+
return result.success ? 0 : 1
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
private async runStatus(projectPath: string): Promise<number> {
|
|
555
|
+
out.start()
|
|
556
|
+
out.section('Git Hooks Status')
|
|
557
|
+
|
|
558
|
+
const status = await this.status(projectPath)
|
|
559
|
+
|
|
560
|
+
if (status.installed) {
|
|
561
|
+
console.log(` Status: ${chalk.green('Active')}`)
|
|
562
|
+
console.log(` Strategy: ${chalk.cyan(status.strategy)}`)
|
|
563
|
+
} else {
|
|
564
|
+
console.log(` Status: ${chalk.dim('Not installed')}`)
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
console.log('')
|
|
568
|
+
for (const hook of status.hooks) {
|
|
569
|
+
const icon = hook.installed ? chalk.green('✓') : chalk.dim('○')
|
|
570
|
+
const label = hook.installed ? hook.name : chalk.dim(hook.name)
|
|
571
|
+
console.log(` ${icon} ${label}`)
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
if (status.detectedManagers.length > 0) {
|
|
575
|
+
console.log('')
|
|
576
|
+
console.log(` ${chalk.dim('Available managers:')} ${status.detectedManagers.join(', ')}`)
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
if (!status.installed) {
|
|
580
|
+
console.log('')
|
|
581
|
+
console.log(chalk.dim(' Install with: prjct hooks install'))
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
console.log('')
|
|
585
|
+
out.end()
|
|
586
|
+
return 0
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
// ==========================================================================
|
|
590
|
+
// HELPERS
|
|
591
|
+
// ==========================================================================
|
|
592
|
+
|
|
593
|
+
private isHookInstalled(
|
|
594
|
+
projectPath: string,
|
|
595
|
+
hook: HookName,
|
|
596
|
+
strategy: HookStrategy | null
|
|
597
|
+
): boolean {
|
|
598
|
+
if (strategy === 'lefthook') {
|
|
599
|
+
const configFile = fs.existsSync(path.join(projectPath, 'lefthook.yml'))
|
|
600
|
+
? 'lefthook.yml'
|
|
601
|
+
: 'lefthook.yaml'
|
|
602
|
+
const configPath = path.join(projectPath, configFile)
|
|
603
|
+
if (!fs.existsSync(configPath)) return false
|
|
604
|
+
const content = fs.readFileSync(configPath, 'utf-8')
|
|
605
|
+
return content.includes(`prjct-sync-${hook}`)
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
if (strategy === 'husky') {
|
|
609
|
+
const hookPath = path.join(projectPath, '.husky', hook)
|
|
610
|
+
if (!fs.existsSync(hookPath)) return false
|
|
611
|
+
return fs.readFileSync(hookPath, 'utf-8').includes('prjct sync')
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
// Direct
|
|
615
|
+
const hookPath = path.join(projectPath, '.git', 'hooks', hook)
|
|
616
|
+
if (!fs.existsSync(hookPath)) return false
|
|
617
|
+
return fs.readFileSync(hookPath, 'utf-8').includes('prjct sync')
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
private getHookPath(projectPath: string, hook: HookName, strategy: HookStrategy | null): string {
|
|
621
|
+
if (strategy === 'lefthook') {
|
|
622
|
+
return fs.existsSync(path.join(projectPath, 'lefthook.yml'))
|
|
623
|
+
? 'lefthook.yml'
|
|
624
|
+
: 'lefthook.yaml'
|
|
625
|
+
}
|
|
626
|
+
if (strategy === 'husky') {
|
|
627
|
+
return `.husky/${hook}`
|
|
628
|
+
}
|
|
629
|
+
return `.git/hooks/${hook}`
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
private async getHookConfig(projectPath: string): Promise<HookConfig | null> {
|
|
633
|
+
const projectId = await configManager.getProjectId(projectPath)
|
|
634
|
+
if (!projectId) return null
|
|
635
|
+
|
|
636
|
+
try {
|
|
637
|
+
const projectJsonPath = path.join(
|
|
638
|
+
process.env.HOME || '',
|
|
639
|
+
'.prjct-cli',
|
|
640
|
+
'projects',
|
|
641
|
+
projectId,
|
|
642
|
+
'project.json'
|
|
643
|
+
)
|
|
644
|
+
if (!fs.existsSync(projectJsonPath)) return null
|
|
645
|
+
const project = JSON.parse(fs.readFileSync(projectJsonPath, 'utf-8'))
|
|
646
|
+
return project.hooks || null
|
|
647
|
+
} catch {
|
|
648
|
+
return null
|
|
649
|
+
}
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
private async saveHookConfig(projectPath: string, config: HookConfig): Promise<void> {
|
|
653
|
+
const projectId = await configManager.getProjectId(projectPath)
|
|
654
|
+
if (!projectId) return
|
|
655
|
+
|
|
656
|
+
try {
|
|
657
|
+
const projectJsonPath = path.join(
|
|
658
|
+
process.env.HOME || '',
|
|
659
|
+
'.prjct-cli',
|
|
660
|
+
'projects',
|
|
661
|
+
projectId,
|
|
662
|
+
'project.json'
|
|
663
|
+
)
|
|
664
|
+
if (!fs.existsSync(projectJsonPath)) return
|
|
665
|
+
|
|
666
|
+
const project = JSON.parse(fs.readFileSync(projectJsonPath, 'utf-8'))
|
|
667
|
+
project.hooks = config
|
|
668
|
+
fs.writeFileSync(projectJsonPath, JSON.stringify(project, null, 2))
|
|
669
|
+
} catch {
|
|
670
|
+
// Non-fatal
|
|
671
|
+
}
|
|
672
|
+
}
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
export const hooksService = new HooksService()
|
|
676
|
+
export default { hooksService }
|
|
@@ -195,23 +195,35 @@ export class StalenessChecker {
|
|
|
195
195
|
lines.push('CLAUDE.md status: ✓ Fresh')
|
|
196
196
|
}
|
|
197
197
|
|
|
198
|
-
|
|
199
|
-
|
|
198
|
+
// Build key-value table content
|
|
199
|
+
const details: string[] = []
|
|
200
200
|
if (status.lastSyncCommit) {
|
|
201
|
-
|
|
201
|
+
details.push(`Last sync: ${status.lastSyncCommit}`)
|
|
202
202
|
}
|
|
203
203
|
if (status.currentCommit) {
|
|
204
|
-
|
|
204
|
+
details.push(`Current: ${status.currentCommit}`)
|
|
205
205
|
}
|
|
206
206
|
if (status.commitsSinceSync > 0) {
|
|
207
|
-
|
|
207
|
+
details.push(`Commits since: ${status.commitsSinceSync}`)
|
|
208
208
|
}
|
|
209
209
|
if (status.daysSinceSync > 0) {
|
|
210
|
-
|
|
210
|
+
details.push(`Days since: ${status.daysSinceSync}`)
|
|
211
211
|
}
|
|
212
212
|
if (status.changedFiles.length > 0) {
|
|
213
|
-
|
|
213
|
+
details.push(`Files changed: ${status.changedFiles.length}`)
|
|
214
214
|
}
|
|
215
|
+
|
|
216
|
+
// Wrap details in a box
|
|
217
|
+
if (details.length > 0) {
|
|
218
|
+
const maxLen = Math.max(...details.map((l) => l.length))
|
|
219
|
+
const border = '─'.repeat(maxLen + 2)
|
|
220
|
+
lines.push(`┌${border}┐`)
|
|
221
|
+
for (const detail of details) {
|
|
222
|
+
lines.push(`│ ${detail.padEnd(maxLen)} │`)
|
|
223
|
+
}
|
|
224
|
+
lines.push(`└${border}┘`)
|
|
225
|
+
}
|
|
226
|
+
|
|
215
227
|
if (status.significantChanges.length > 0) {
|
|
216
228
|
lines.push(``)
|
|
217
229
|
lines.push(`Significant changes:`)
|