prjct-cli 0.10.14 → 0.11.1
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 +19 -0
- package/bin/dev.js +217 -0
- package/bin/prjct +10 -0
- package/bin/serve.js +78 -0
- package/core/bus/index.js +322 -0
- package/core/command-registry.js +65 -0
- package/core/domain/snapshot-manager.js +375 -0
- package/core/plugin/hooks.js +313 -0
- package/core/plugin/index.js +52 -0
- package/core/plugin/loader.js +331 -0
- package/core/plugin/registry.js +325 -0
- package/core/plugins/webhook.js +143 -0
- package/core/session/index.js +449 -0
- package/core/session/metrics.js +293 -0
- package/package.json +28 -4
- package/packages/shared/dist/index.d.ts +615 -0
- package/packages/shared/dist/index.js +204 -0
- package/packages/shared/package.json +29 -0
- package/packages/shared/src/index.ts +9 -0
- package/packages/shared/src/schemas.ts +124 -0
- package/packages/shared/src/types.ts +187 -0
- package/packages/shared/src/utils.ts +148 -0
- package/packages/shared/tsconfig.json +18 -0
- package/packages/web/README.md +36 -0
- package/packages/web/app/api/claude/sessions/route.ts +44 -0
- package/packages/web/app/api/claude/status/route.ts +34 -0
- package/packages/web/app/api/projects/[id]/delete/route.ts +21 -0
- package/packages/web/app/api/projects/[id]/icon/route.ts +33 -0
- package/packages/web/app/api/projects/[id]/route.ts +29 -0
- package/packages/web/app/api/projects/[id]/stats/route.ts +36 -0
- package/packages/web/app/api/projects/[id]/status/route.ts +21 -0
- package/packages/web/app/api/projects/route.ts +16 -0
- package/packages/web/app/api/sessions/history/route.ts +122 -0
- package/packages/web/app/api/stats/route.ts +38 -0
- package/packages/web/app/error.tsx +34 -0
- package/packages/web/app/favicon.ico +0 -0
- package/packages/web/app/globals.css +155 -0
- package/packages/web/app/layout.tsx +43 -0
- package/packages/web/app/loading.tsx +7 -0
- package/packages/web/app/not-found.tsx +25 -0
- package/packages/web/app/page.tsx +227 -0
- package/packages/web/app/project/[id]/error.tsx +41 -0
- package/packages/web/app/project/[id]/loading.tsx +9 -0
- package/packages/web/app/project/[id]/not-found.tsx +27 -0
- package/packages/web/app/project/[id]/page.tsx +253 -0
- package/packages/web/app/project/[id]/stats/page.tsx +447 -0
- package/packages/web/app/sessions/page.tsx +165 -0
- package/packages/web/app/settings/page.tsx +150 -0
- package/packages/web/components/AppSidebar.tsx +113 -0
- package/packages/web/components/CommandButton.tsx +39 -0
- package/packages/web/components/ConnectionStatus.tsx +29 -0
- package/packages/web/components/Logo.tsx +65 -0
- package/packages/web/components/MarkdownContent.tsx +123 -0
- package/packages/web/components/ProjectAvatar.tsx +54 -0
- package/packages/web/components/TechStackBadges.tsx +20 -0
- package/packages/web/components/TerminalTab.tsx +84 -0
- package/packages/web/components/TerminalTabs.tsx +210 -0
- package/packages/web/components/charts/SessionsChart.tsx +172 -0
- package/packages/web/components/providers.tsx +45 -0
- package/packages/web/components/ui/alert-dialog.tsx +157 -0
- package/packages/web/components/ui/badge.tsx +46 -0
- package/packages/web/components/ui/button.tsx +60 -0
- package/packages/web/components/ui/card.tsx +92 -0
- package/packages/web/components/ui/chart.tsx +385 -0
- package/packages/web/components/ui/dropdown-menu.tsx +257 -0
- package/packages/web/components/ui/scroll-area.tsx +58 -0
- package/packages/web/components/ui/sheet.tsx +139 -0
- package/packages/web/components/ui/tabs.tsx +66 -0
- package/packages/web/components/ui/tooltip.tsx +61 -0
- package/packages/web/components.json +22 -0
- package/packages/web/context/TerminalContext.tsx +45 -0
- package/packages/web/context/TerminalTabsContext.tsx +136 -0
- package/packages/web/eslint.config.mjs +18 -0
- package/packages/web/hooks/useClaudeTerminal.ts +375 -0
- package/packages/web/hooks/useProjectStats.ts +38 -0
- package/packages/web/hooks/useProjects.ts +73 -0
- package/packages/web/hooks/useStats.ts +28 -0
- package/packages/web/lib/format.ts +23 -0
- package/packages/web/lib/parse-prjct-files.ts +1122 -0
- package/packages/web/lib/projects.ts +452 -0
- package/packages/web/lib/pty.ts +101 -0
- package/packages/web/lib/query-config.ts +44 -0
- package/packages/web/lib/utils.ts +6 -0
- package/packages/web/next-env.d.ts +6 -0
- package/packages/web/next.config.ts +7 -0
- package/packages/web/package.json +53 -0
- package/packages/web/postcss.config.mjs +7 -0
- package/packages/web/public/file.svg +1 -0
- package/packages/web/public/globe.svg +1 -0
- package/packages/web/public/next.svg +1 -0
- package/packages/web/public/vercel.svg +1 -0
- package/packages/web/public/window.svg +1 -0
- package/packages/web/server.ts +262 -0
- package/packages/web/tsconfig.json +34 -0
- package/templates/commands/done.md +176 -54
- package/templates/commands/history.md +176 -0
- package/templates/commands/init.md +28 -1
- package/templates/commands/now.md +191 -9
- package/templates/commands/pause.md +176 -12
- package/templates/commands/redo.md +142 -0
- package/templates/commands/resume.md +166 -62
- package/templates/commands/serve.md +121 -0
- package/templates/commands/ship.md +45 -1
- package/templates/commands/sync.md +34 -1
- package/templates/commands/undo.md +152 -0
|
@@ -0,0 +1,313 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HookSystem - Plugin Lifecycle Hooks for prjct-cli
|
|
3
|
+
*
|
|
4
|
+
* Provides hook points for plugins to extend prjct functionality.
|
|
5
|
+
* Hooks can modify data, add side effects, or integrate with external services.
|
|
6
|
+
*
|
|
7
|
+
* @version 1.0.0
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
const { eventBus, EventTypes } = require('../bus')
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Hook Points - Where plugins can intercept
|
|
14
|
+
*/
|
|
15
|
+
const HookPoints = {
|
|
16
|
+
// Before hooks (can modify data)
|
|
17
|
+
BEFORE_SESSION_START: 'before:session.start',
|
|
18
|
+
BEFORE_SESSION_COMPLETE: 'before:session.complete',
|
|
19
|
+
BEFORE_TASK_CREATE: 'before:task.create',
|
|
20
|
+
BEFORE_FEATURE_SHIP: 'before:feature.ship',
|
|
21
|
+
BEFORE_SNAPSHOT_CREATE: 'before:snapshot.create',
|
|
22
|
+
BEFORE_COMMIT: 'before:git.commit',
|
|
23
|
+
|
|
24
|
+
// After hooks (for side effects)
|
|
25
|
+
AFTER_SESSION_START: 'after:session.start',
|
|
26
|
+
AFTER_SESSION_COMPLETE: 'after:session.complete',
|
|
27
|
+
AFTER_TASK_CREATE: 'after:task.create',
|
|
28
|
+
AFTER_TASK_COMPLETE: 'after:task.complete',
|
|
29
|
+
AFTER_FEATURE_SHIP: 'after:feature.ship',
|
|
30
|
+
AFTER_IDEA_CAPTURE: 'after:idea.capture',
|
|
31
|
+
AFTER_SNAPSHOT_CREATE: 'after:snapshot.create',
|
|
32
|
+
AFTER_SNAPSHOT_RESTORE: 'after:snapshot.restore',
|
|
33
|
+
AFTER_COMMIT: 'after:git.commit',
|
|
34
|
+
AFTER_PUSH: 'after:git.push',
|
|
35
|
+
AFTER_SYNC: 'after:project.sync',
|
|
36
|
+
|
|
37
|
+
// Transform hooks (must return modified data)
|
|
38
|
+
TRANSFORM_COMMIT_MESSAGE: 'transform:commit.message',
|
|
39
|
+
TRANSFORM_TASK_DATA: 'transform:task.data',
|
|
40
|
+
TRANSFORM_METRICS: 'transform:metrics'
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
class HookSystem {
|
|
44
|
+
constructor() {
|
|
45
|
+
this.hooks = new Map()
|
|
46
|
+
this.pluginHooks = new Map() // Track hooks by plugin
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Register a hook handler
|
|
51
|
+
* @param {string} hookPoint - Hook point from HookPoints
|
|
52
|
+
* @param {Function} handler - Handler function
|
|
53
|
+
* @param {Object} options
|
|
54
|
+
* @param {string} options.pluginName - Name of the plugin
|
|
55
|
+
* @param {number} options.priority - Execution order (lower = first)
|
|
56
|
+
* @returns {Function} Unregister function
|
|
57
|
+
*/
|
|
58
|
+
register(hookPoint, handler, options = {}) {
|
|
59
|
+
const { pluginName = 'anonymous', priority = 10 } = options
|
|
60
|
+
|
|
61
|
+
if (!this.hooks.has(hookPoint)) {
|
|
62
|
+
this.hooks.set(hookPoint, [])
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const hookEntry = {
|
|
66
|
+
handler,
|
|
67
|
+
pluginName,
|
|
68
|
+
priority,
|
|
69
|
+
id: `${pluginName}:${Date.now()}`
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
this.hooks.get(hookPoint).push(hookEntry)
|
|
73
|
+
|
|
74
|
+
// Sort by priority
|
|
75
|
+
this.hooks.get(hookPoint).sort((a, b) => a.priority - b.priority)
|
|
76
|
+
|
|
77
|
+
// Track by plugin
|
|
78
|
+
if (!this.pluginHooks.has(pluginName)) {
|
|
79
|
+
this.pluginHooks.set(pluginName, [])
|
|
80
|
+
}
|
|
81
|
+
this.pluginHooks.get(pluginName).push({ hookPoint, id: hookEntry.id })
|
|
82
|
+
|
|
83
|
+
// Return unregister function
|
|
84
|
+
return () => this.unregister(hookPoint, hookEntry.id)
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Unregister a hook handler
|
|
89
|
+
* @param {string} hookPoint
|
|
90
|
+
* @param {string} id
|
|
91
|
+
*/
|
|
92
|
+
unregister(hookPoint, id) {
|
|
93
|
+
const hooks = this.hooks.get(hookPoint)
|
|
94
|
+
if (hooks) {
|
|
95
|
+
const index = hooks.findIndex(h => h.id === id)
|
|
96
|
+
if (index !== -1) {
|
|
97
|
+
hooks.splice(index, 1)
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Unregister all hooks from a plugin
|
|
104
|
+
* @param {string} pluginName
|
|
105
|
+
*/
|
|
106
|
+
unregisterPlugin(pluginName) {
|
|
107
|
+
const pluginEntries = this.pluginHooks.get(pluginName)
|
|
108
|
+
if (pluginEntries) {
|
|
109
|
+
for (const entry of pluginEntries) {
|
|
110
|
+
this.unregister(entry.hookPoint, entry.id)
|
|
111
|
+
}
|
|
112
|
+
this.pluginHooks.delete(pluginName)
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Execute a "before" hook (can modify data)
|
|
118
|
+
* @param {string} hookPoint
|
|
119
|
+
* @param {Object} data - Data that can be modified
|
|
120
|
+
* @returns {Promise<Object>} Modified data
|
|
121
|
+
*/
|
|
122
|
+
async executeBefore(hookPoint, data) {
|
|
123
|
+
const hooks = this.hooks.get(hookPoint) || []
|
|
124
|
+
let result = { ...data }
|
|
125
|
+
|
|
126
|
+
for (const hook of hooks) {
|
|
127
|
+
try {
|
|
128
|
+
const modified = await hook.handler(result)
|
|
129
|
+
if (modified !== undefined) {
|
|
130
|
+
result = { ...result, ...modified }
|
|
131
|
+
}
|
|
132
|
+
} catch (error) {
|
|
133
|
+
console.error(`Hook error [${hook.pluginName}] at ${hookPoint}:`, error.message)
|
|
134
|
+
// Continue with other hooks
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
return result
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Execute an "after" hook (side effects only)
|
|
143
|
+
* @param {string} hookPoint
|
|
144
|
+
* @param {Object} data - Read-only data
|
|
145
|
+
* @returns {Promise<void>}
|
|
146
|
+
*/
|
|
147
|
+
async executeAfter(hookPoint, data) {
|
|
148
|
+
const hooks = this.hooks.get(hookPoint) || []
|
|
149
|
+
|
|
150
|
+
// Execute all hooks in parallel for after hooks
|
|
151
|
+
await Promise.allSettled(
|
|
152
|
+
hooks.map(async hook => {
|
|
153
|
+
try {
|
|
154
|
+
await hook.handler(data)
|
|
155
|
+
} catch (error) {
|
|
156
|
+
console.error(`Hook error [${hook.pluginName}] at ${hookPoint}:`, error.message)
|
|
157
|
+
}
|
|
158
|
+
})
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
// Emit corresponding event for plugins listening via EventBus
|
|
162
|
+
const eventType = this.hookToEvent(hookPoint)
|
|
163
|
+
if (eventType) {
|
|
164
|
+
await eventBus.emit(eventType, data)
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Execute a "transform" hook (must return modified value)
|
|
170
|
+
* @param {string} hookPoint
|
|
171
|
+
* @param {*} value - Value to transform
|
|
172
|
+
* @param {Object} context - Additional context
|
|
173
|
+
* @returns {Promise<*>} Transformed value
|
|
174
|
+
*/
|
|
175
|
+
async executeTransform(hookPoint, value, context = {}) {
|
|
176
|
+
const hooks = this.hooks.get(hookPoint) || []
|
|
177
|
+
let result = value
|
|
178
|
+
|
|
179
|
+
for (const hook of hooks) {
|
|
180
|
+
try {
|
|
181
|
+
const transformed = await hook.handler(result, context)
|
|
182
|
+
if (transformed !== undefined) {
|
|
183
|
+
result = transformed
|
|
184
|
+
}
|
|
185
|
+
} catch (error) {
|
|
186
|
+
console.error(`Transform hook error [${hook.pluginName}] at ${hookPoint}:`, error.message)
|
|
187
|
+
// Keep previous value on error
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
return result
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Map hook point to event type
|
|
196
|
+
* @param {string} hookPoint
|
|
197
|
+
* @returns {string|null}
|
|
198
|
+
*/
|
|
199
|
+
hookToEvent(hookPoint) {
|
|
200
|
+
const mapping = {
|
|
201
|
+
[HookPoints.AFTER_SESSION_START]: EventTypes.SESSION_STARTED,
|
|
202
|
+
[HookPoints.AFTER_SESSION_COMPLETE]: EventTypes.SESSION_COMPLETED,
|
|
203
|
+
[HookPoints.AFTER_TASK_CREATE]: EventTypes.TASK_CREATED,
|
|
204
|
+
[HookPoints.AFTER_TASK_COMPLETE]: EventTypes.TASK_COMPLETED,
|
|
205
|
+
[HookPoints.AFTER_FEATURE_SHIP]: EventTypes.FEATURE_SHIPPED,
|
|
206
|
+
[HookPoints.AFTER_IDEA_CAPTURE]: EventTypes.IDEA_CAPTURED,
|
|
207
|
+
[HookPoints.AFTER_SNAPSHOT_CREATE]: EventTypes.SNAPSHOT_CREATED,
|
|
208
|
+
[HookPoints.AFTER_SNAPSHOT_RESTORE]: EventTypes.SNAPSHOT_RESTORED,
|
|
209
|
+
[HookPoints.AFTER_COMMIT]: EventTypes.COMMIT_CREATED,
|
|
210
|
+
[HookPoints.AFTER_PUSH]: EventTypes.PUSH_COMPLETED,
|
|
211
|
+
[HookPoints.AFTER_SYNC]: EventTypes.PROJECT_SYNCED
|
|
212
|
+
}
|
|
213
|
+
return mapping[hookPoint] || null
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Get all registered hooks for a point
|
|
218
|
+
* @param {string} hookPoint
|
|
219
|
+
* @returns {Object[]}
|
|
220
|
+
*/
|
|
221
|
+
getHooks(hookPoint) {
|
|
222
|
+
return (this.hooks.get(hookPoint) || []).map(h => ({
|
|
223
|
+
pluginName: h.pluginName,
|
|
224
|
+
priority: h.priority
|
|
225
|
+
}))
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* Get all hooks registered by a plugin
|
|
230
|
+
* @param {string} pluginName
|
|
231
|
+
* @returns {string[]} Hook points
|
|
232
|
+
*/
|
|
233
|
+
getPluginHooks(pluginName) {
|
|
234
|
+
const entries = this.pluginHooks.get(pluginName) || []
|
|
235
|
+
return entries.map(e => e.hookPoint)
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* Check if a hook point has any handlers
|
|
240
|
+
* @param {string} hookPoint
|
|
241
|
+
* @returns {boolean}
|
|
242
|
+
*/
|
|
243
|
+
hasHooks(hookPoint) {
|
|
244
|
+
const hooks = this.hooks.get(hookPoint)
|
|
245
|
+
return hooks && hooks.length > 0
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* Clear all hooks
|
|
250
|
+
*/
|
|
251
|
+
clear() {
|
|
252
|
+
this.hooks.clear()
|
|
253
|
+
this.pluginHooks.clear()
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// Singleton instance
|
|
258
|
+
const hookSystem = new HookSystem()
|
|
259
|
+
|
|
260
|
+
// Convenience wrapper for common hook patterns
|
|
261
|
+
const hooks = {
|
|
262
|
+
/**
|
|
263
|
+
* Register a before hook
|
|
264
|
+
*/
|
|
265
|
+
before: (point, handler, options) => {
|
|
266
|
+
const hookPoint = `before:${point}`
|
|
267
|
+
return hookSystem.register(hookPoint, handler, options)
|
|
268
|
+
},
|
|
269
|
+
|
|
270
|
+
/**
|
|
271
|
+
* Register an after hook
|
|
272
|
+
*/
|
|
273
|
+
after: (point, handler, options) => {
|
|
274
|
+
const hookPoint = `after:${point}`
|
|
275
|
+
return hookSystem.register(hookPoint, handler, options)
|
|
276
|
+
},
|
|
277
|
+
|
|
278
|
+
/**
|
|
279
|
+
* Register a transform hook
|
|
280
|
+
*/
|
|
281
|
+
transform: (point, handler, options) => {
|
|
282
|
+
const hookPoint = `transform:${point}`
|
|
283
|
+
return hookSystem.register(hookPoint, handler, options)
|
|
284
|
+
},
|
|
285
|
+
|
|
286
|
+
/**
|
|
287
|
+
* Execute before hooks
|
|
288
|
+
*/
|
|
289
|
+
runBefore: (point, data) => {
|
|
290
|
+
return hookSystem.executeBefore(`before:${point}`, data)
|
|
291
|
+
},
|
|
292
|
+
|
|
293
|
+
/**
|
|
294
|
+
* Execute after hooks
|
|
295
|
+
*/
|
|
296
|
+
runAfter: (point, data) => {
|
|
297
|
+
return hookSystem.executeAfter(`after:${point}`, data)
|
|
298
|
+
},
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* Execute transform hooks
|
|
302
|
+
*/
|
|
303
|
+
runTransform: (point, value, context) => {
|
|
304
|
+
return hookSystem.executeTransform(`transform:${point}`, value, context)
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
module.exports = {
|
|
309
|
+
HookSystem,
|
|
310
|
+
HookPoints,
|
|
311
|
+
hookSystem,
|
|
312
|
+
hooks
|
|
313
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Plugin System - Main Entry Point
|
|
3
|
+
*
|
|
4
|
+
* Exports all plugin-related functionality.
|
|
5
|
+
*
|
|
6
|
+
* @version 1.0.0
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
const { HookSystem, HookPoints, hookSystem, hooks } = require('./hooks')
|
|
10
|
+
const { PluginLoader, pluginLoader } = require('./loader')
|
|
11
|
+
const { PluginRegistry, pluginRegistry } = require('./registry')
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Initialize the complete plugin system
|
|
15
|
+
* @param {string} projectPath
|
|
16
|
+
* @param {Object} config
|
|
17
|
+
*/
|
|
18
|
+
async function initializePlugins(projectPath, config = {}) {
|
|
19
|
+
await pluginRegistry.initialize()
|
|
20
|
+
await pluginLoader.initialize(projectPath, config)
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Shutdown the plugin system
|
|
25
|
+
*/
|
|
26
|
+
async function shutdownPlugins() {
|
|
27
|
+
const plugins = pluginLoader.getAllPlugins()
|
|
28
|
+
|
|
29
|
+
for (const plugin of plugins) {
|
|
30
|
+
await pluginLoader.unloadPlugin(plugin.name)
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
module.exports = {
|
|
35
|
+
// Hook system
|
|
36
|
+
HookSystem,
|
|
37
|
+
HookPoints,
|
|
38
|
+
hookSystem,
|
|
39
|
+
hooks,
|
|
40
|
+
|
|
41
|
+
// Plugin loader
|
|
42
|
+
PluginLoader,
|
|
43
|
+
pluginLoader,
|
|
44
|
+
|
|
45
|
+
// Plugin registry
|
|
46
|
+
PluginRegistry,
|
|
47
|
+
pluginRegistry,
|
|
48
|
+
|
|
49
|
+
// Convenience functions
|
|
50
|
+
initializePlugins,
|
|
51
|
+
shutdownPlugins
|
|
52
|
+
}
|
|
@@ -0,0 +1,331 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PluginLoader - Dynamic Plugin Loading for prjct-cli
|
|
3
|
+
*
|
|
4
|
+
* Loads plugins from:
|
|
5
|
+
* 1. Built-in plugins (core/plugins/*)
|
|
6
|
+
* 2. Global plugins (~/.prjct-cli/plugins/*)
|
|
7
|
+
* 3. Project plugins (configured in prjct.config.json)
|
|
8
|
+
*
|
|
9
|
+
* @version 1.0.0
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
const fs = require('fs').promises
|
|
13
|
+
const path = require('path')
|
|
14
|
+
const { hookSystem } = require('./hooks')
|
|
15
|
+
const { eventBus } = require('../bus')
|
|
16
|
+
const pathManager = require('../infrastructure/path-manager')
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Plugin Interface
|
|
20
|
+
* @typedef {Object} Plugin
|
|
21
|
+
* @property {string} name - Unique plugin name
|
|
22
|
+
* @property {string} version - Semver version
|
|
23
|
+
* @property {string} [description] - Plugin description
|
|
24
|
+
* @property {Object} [hooks] - Hook handlers
|
|
25
|
+
* @property {Object} [commands] - Custom commands
|
|
26
|
+
* @property {Function} [activate] - Called when plugin loads
|
|
27
|
+
* @property {Function} [deactivate] - Called when plugin unloads
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
class PluginLoader {
|
|
31
|
+
constructor() {
|
|
32
|
+
this.plugins = new Map()
|
|
33
|
+
this.pluginPaths = new Map()
|
|
34
|
+
this.initialized = false
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Initialize plugin system
|
|
39
|
+
* @param {string} projectPath - Project root path
|
|
40
|
+
* @param {Object} config - Project config
|
|
41
|
+
*/
|
|
42
|
+
async initialize(projectPath, config = {}) {
|
|
43
|
+
if (this.initialized) return
|
|
44
|
+
|
|
45
|
+
this.projectPath = projectPath
|
|
46
|
+
this.config = config
|
|
47
|
+
|
|
48
|
+
// Load plugins in order
|
|
49
|
+
await this.loadBuiltinPlugins()
|
|
50
|
+
await this.loadGlobalPlugins()
|
|
51
|
+
await this.loadProjectPlugins(config.plugins || [])
|
|
52
|
+
|
|
53
|
+
this.initialized = true
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Load built-in plugins from core/plugins
|
|
58
|
+
*/
|
|
59
|
+
async loadBuiltinPlugins() {
|
|
60
|
+
const builtinPath = path.join(__dirname, '..', 'plugins')
|
|
61
|
+
|
|
62
|
+
try {
|
|
63
|
+
const files = await fs.readdir(builtinPath)
|
|
64
|
+
|
|
65
|
+
for (const file of files) {
|
|
66
|
+
if (file.endsWith('.js')) {
|
|
67
|
+
const pluginPath = path.join(builtinPath, file)
|
|
68
|
+
await this.loadPlugin(pluginPath, 'builtin')
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
} catch {
|
|
72
|
+
// No built-in plugins directory yet
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Load global plugins from ~/.prjct-cli/plugins
|
|
78
|
+
*/
|
|
79
|
+
async loadGlobalPlugins() {
|
|
80
|
+
const globalPath = path.join(pathManager.getGlobalStoragePath(), 'plugins')
|
|
81
|
+
|
|
82
|
+
try {
|
|
83
|
+
const files = await fs.readdir(globalPath)
|
|
84
|
+
|
|
85
|
+
for (const file of files) {
|
|
86
|
+
if (file.endsWith('.js') || (await this.isDirectory(path.join(globalPath, file)))) {
|
|
87
|
+
const pluginPath = file.endsWith('.js')
|
|
88
|
+
? path.join(globalPath, file)
|
|
89
|
+
: path.join(globalPath, file, 'index.js')
|
|
90
|
+
await this.loadPlugin(pluginPath, 'global')
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
} catch {
|
|
94
|
+
// No global plugins directory
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Load project-specific plugins from config
|
|
100
|
+
* @param {Array<string|Object>} plugins - Plugin specs from config
|
|
101
|
+
*/
|
|
102
|
+
async loadProjectPlugins(plugins) {
|
|
103
|
+
for (const spec of plugins) {
|
|
104
|
+
try {
|
|
105
|
+
if (typeof spec === 'string') {
|
|
106
|
+
// Simple name or path
|
|
107
|
+
if (spec.startsWith('file://')) {
|
|
108
|
+
// Local file path
|
|
109
|
+
const filePath = spec.replace('file://', '')
|
|
110
|
+
const fullPath = path.resolve(this.projectPath, filePath)
|
|
111
|
+
await this.loadPlugin(fullPath, 'project')
|
|
112
|
+
} else if (spec.startsWith('./') || spec.startsWith('../')) {
|
|
113
|
+
// Relative path
|
|
114
|
+
const fullPath = path.resolve(this.projectPath, spec)
|
|
115
|
+
await this.loadPlugin(fullPath, 'project')
|
|
116
|
+
} else {
|
|
117
|
+
// Plugin name - check if already loaded
|
|
118
|
+
if (!this.plugins.has(spec)) {
|
|
119
|
+
console.warn(`Plugin not found: ${spec}`)
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
} else if (typeof spec === 'object' && spec.path) {
|
|
123
|
+
// Object with path and config
|
|
124
|
+
const fullPath = path.resolve(this.projectPath, spec.path)
|
|
125
|
+
await this.loadPlugin(fullPath, 'project', spec.config)
|
|
126
|
+
}
|
|
127
|
+
} catch (error) {
|
|
128
|
+
console.error(`Failed to load plugin: ${spec}`, error.message)
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Load a single plugin
|
|
135
|
+
* @param {string} pluginPath - Path to plugin file
|
|
136
|
+
* @param {string} source - 'builtin', 'global', or 'project'
|
|
137
|
+
* @param {Object} config - Plugin-specific config
|
|
138
|
+
*/
|
|
139
|
+
async loadPlugin(pluginPath, source, config = {}) {
|
|
140
|
+
try {
|
|
141
|
+
// Check if file exists
|
|
142
|
+
await fs.access(pluginPath)
|
|
143
|
+
|
|
144
|
+
// Require the plugin
|
|
145
|
+
const plugin = require(pluginPath)
|
|
146
|
+
|
|
147
|
+
// Validate plugin structure
|
|
148
|
+
if (!plugin.name) {
|
|
149
|
+
throw new Error('Plugin must have a name property')
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
if (this.plugins.has(plugin.name)) {
|
|
153
|
+
console.warn(`Plugin already loaded: ${plugin.name}`)
|
|
154
|
+
return
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// Store plugin
|
|
158
|
+
this.plugins.set(plugin.name, {
|
|
159
|
+
...plugin,
|
|
160
|
+
source,
|
|
161
|
+
config: { ...config, ...this.config[plugin.name] }
|
|
162
|
+
})
|
|
163
|
+
this.pluginPaths.set(plugin.name, pluginPath)
|
|
164
|
+
|
|
165
|
+
// Register hooks
|
|
166
|
+
if (plugin.hooks) {
|
|
167
|
+
this.registerPluginHooks(plugin)
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// Register event listeners
|
|
171
|
+
if (plugin.events) {
|
|
172
|
+
this.registerPluginEvents(plugin)
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// Call activate if exists
|
|
176
|
+
if (typeof plugin.activate === 'function') {
|
|
177
|
+
await plugin.activate({
|
|
178
|
+
config: this.plugins.get(plugin.name).config,
|
|
179
|
+
eventBus,
|
|
180
|
+
hookSystem,
|
|
181
|
+
projectPath: this.projectPath
|
|
182
|
+
})
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
} catch (error) {
|
|
186
|
+
if (error.code === 'ENOENT') {
|
|
187
|
+
// File not found - skip silently
|
|
188
|
+
} else if (error.code === 'MODULE_NOT_FOUND') {
|
|
189
|
+
console.error(`Plugin module not found: ${pluginPath}`)
|
|
190
|
+
} else {
|
|
191
|
+
console.error(`Failed to load plugin from ${pluginPath}:`, error.message)
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Register plugin hooks
|
|
198
|
+
* @param {Plugin} plugin
|
|
199
|
+
*/
|
|
200
|
+
registerPluginHooks(plugin) {
|
|
201
|
+
for (const [hookPoint, handler] of Object.entries(plugin.hooks)) {
|
|
202
|
+
hookSystem.register(hookPoint, handler, {
|
|
203
|
+
pluginName: plugin.name,
|
|
204
|
+
priority: plugin.priority || 10
|
|
205
|
+
})
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Register plugin event listeners
|
|
211
|
+
* @param {Plugin} plugin
|
|
212
|
+
*/
|
|
213
|
+
registerPluginEvents(plugin) {
|
|
214
|
+
for (const [eventType, handler] of Object.entries(plugin.events)) {
|
|
215
|
+
eventBus.on(eventType, handler)
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* Unload a plugin
|
|
221
|
+
* @param {string} name - Plugin name
|
|
222
|
+
*/
|
|
223
|
+
async unloadPlugin(name) {
|
|
224
|
+
const plugin = this.plugins.get(name)
|
|
225
|
+
if (!plugin) return
|
|
226
|
+
|
|
227
|
+
// Call deactivate if exists
|
|
228
|
+
if (typeof plugin.deactivate === 'function') {
|
|
229
|
+
await plugin.deactivate()
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// Unregister hooks
|
|
233
|
+
hookSystem.unregisterPlugin(name)
|
|
234
|
+
|
|
235
|
+
// Remove from loaded plugins
|
|
236
|
+
this.plugins.delete(name)
|
|
237
|
+
this.pluginPaths.delete(name)
|
|
238
|
+
|
|
239
|
+
// Clear require cache
|
|
240
|
+
const pluginPath = this.pluginPaths.get(name)
|
|
241
|
+
if (pluginPath) {
|
|
242
|
+
delete require.cache[require.resolve(pluginPath)]
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* Reload a plugin
|
|
248
|
+
* @param {string} name - Plugin name
|
|
249
|
+
*/
|
|
250
|
+
async reloadPlugin(name) {
|
|
251
|
+
const pluginPath = this.pluginPaths.get(name)
|
|
252
|
+
const plugin = this.plugins.get(name)
|
|
253
|
+
|
|
254
|
+
if (!pluginPath || !plugin) {
|
|
255
|
+
throw new Error(`Plugin not found: ${name}`)
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
await this.unloadPlugin(name)
|
|
259
|
+
await this.loadPlugin(pluginPath, plugin.source, plugin.config)
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* Get a loaded plugin
|
|
264
|
+
* @param {string} name
|
|
265
|
+
* @returns {Plugin|null}
|
|
266
|
+
*/
|
|
267
|
+
getPlugin(name) {
|
|
268
|
+
return this.plugins.get(name) || null
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* Get all loaded plugins
|
|
273
|
+
* @returns {Plugin[]}
|
|
274
|
+
*/
|
|
275
|
+
getAllPlugins() {
|
|
276
|
+
return Array.from(this.plugins.values())
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/**
|
|
280
|
+
* Get plugins by source
|
|
281
|
+
* @param {string} source - 'builtin', 'global', or 'project'
|
|
282
|
+
* @returns {Plugin[]}
|
|
283
|
+
*/
|
|
284
|
+
getPluginsBySource(source) {
|
|
285
|
+
return this.getAllPlugins().filter(p => p.source === source)
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/**
|
|
289
|
+
* Get custom commands from all plugins
|
|
290
|
+
* @returns {Object} Map of command name to handler
|
|
291
|
+
*/
|
|
292
|
+
getPluginCommands() {
|
|
293
|
+
const commands = {}
|
|
294
|
+
|
|
295
|
+
for (const plugin of this.plugins.values()) {
|
|
296
|
+
if (plugin.commands) {
|
|
297
|
+
for (const [name, handler] of Object.entries(plugin.commands)) {
|
|
298
|
+
commands[name] = {
|
|
299
|
+
handler,
|
|
300
|
+
plugin: plugin.name,
|
|
301
|
+
description: handler.description || `Command from ${plugin.name}`
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
return commands
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/**
|
|
311
|
+
* Check if path is a directory
|
|
312
|
+
* @param {string} p
|
|
313
|
+
* @returns {Promise<boolean>}
|
|
314
|
+
*/
|
|
315
|
+
async isDirectory(p) {
|
|
316
|
+
try {
|
|
317
|
+
const stat = await fs.stat(p)
|
|
318
|
+
return stat.isDirectory()
|
|
319
|
+
} catch {
|
|
320
|
+
return false
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
// Singleton instance
|
|
326
|
+
const pluginLoader = new PluginLoader()
|
|
327
|
+
|
|
328
|
+
module.exports = {
|
|
329
|
+
PluginLoader,
|
|
330
|
+
pluginLoader
|
|
331
|
+
}
|