prjct-cli 0.10.14 → 0.11.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 +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 +18 -4
- 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,325 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PluginRegistry - Plugin Discovery and Management
|
|
3
|
+
*
|
|
4
|
+
* Central registry for plugin information, discovery, and management.
|
|
5
|
+
*
|
|
6
|
+
* @version 1.0.0
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
const fs = require('fs').promises
|
|
10
|
+
const path = require('path')
|
|
11
|
+
const pathManager = require('../infrastructure/path-manager')
|
|
12
|
+
const { pluginLoader } = require('./loader')
|
|
13
|
+
|
|
14
|
+
class PluginRegistry {
|
|
15
|
+
constructor() {
|
|
16
|
+
this.availablePlugins = new Map()
|
|
17
|
+
this.initialized = false
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Initialize registry and discover available plugins
|
|
22
|
+
*/
|
|
23
|
+
async initialize() {
|
|
24
|
+
if (this.initialized) return
|
|
25
|
+
|
|
26
|
+
await this.discoverPlugins()
|
|
27
|
+
this.initialized = true
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Discover all available plugins (not necessarily loaded)
|
|
32
|
+
*/
|
|
33
|
+
async discoverPlugins() {
|
|
34
|
+
// Built-in plugins
|
|
35
|
+
await this.discoverFromPath(
|
|
36
|
+
path.join(__dirname, '..', 'plugins'),
|
|
37
|
+
'builtin'
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
// Global plugins
|
|
41
|
+
await this.discoverFromPath(
|
|
42
|
+
path.join(pathManager.getGlobalStoragePath(), 'plugins'),
|
|
43
|
+
'global'
|
|
44
|
+
)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Discover plugins from a path
|
|
49
|
+
* @param {string} dir
|
|
50
|
+
* @param {string} source
|
|
51
|
+
*/
|
|
52
|
+
async discoverFromPath(dir, source) {
|
|
53
|
+
try {
|
|
54
|
+
const entries = await fs.readdir(dir, { withFileTypes: true })
|
|
55
|
+
|
|
56
|
+
for (const entry of entries) {
|
|
57
|
+
try {
|
|
58
|
+
let pluginPath
|
|
59
|
+
|
|
60
|
+
if (entry.isFile() && entry.name.endsWith('.js')) {
|
|
61
|
+
pluginPath = path.join(dir, entry.name)
|
|
62
|
+
} else if (entry.isDirectory()) {
|
|
63
|
+
pluginPath = path.join(dir, entry.name, 'index.js')
|
|
64
|
+
} else {
|
|
65
|
+
continue
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Check if file exists
|
|
69
|
+
await fs.access(pluginPath)
|
|
70
|
+
|
|
71
|
+
// Read plugin metadata without loading
|
|
72
|
+
const metadata = await this.readPluginMetadata(pluginPath)
|
|
73
|
+
if (metadata) {
|
|
74
|
+
this.availablePlugins.set(metadata.name, {
|
|
75
|
+
...metadata,
|
|
76
|
+
path: pluginPath,
|
|
77
|
+
source
|
|
78
|
+
})
|
|
79
|
+
}
|
|
80
|
+
} catch {
|
|
81
|
+
// Skip invalid plugins
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
} catch {
|
|
85
|
+
// Directory doesn't exist
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Read plugin metadata without fully loading
|
|
91
|
+
* @param {string} pluginPath
|
|
92
|
+
* @returns {Object|null}
|
|
93
|
+
*/
|
|
94
|
+
async readPluginMetadata(pluginPath) {
|
|
95
|
+
try {
|
|
96
|
+
const content = await fs.readFile(pluginPath, 'utf-8')
|
|
97
|
+
|
|
98
|
+
// Extract basic metadata from comments or exports
|
|
99
|
+
const nameMatch = content.match(/name:\s*['"]([^'"]+)['"]/)
|
|
100
|
+
const versionMatch = content.match(/version:\s*['"]([^'"]+)['"]/)
|
|
101
|
+
const descMatch = content.match(/description:\s*['"]([^'"]+)['"]/)
|
|
102
|
+
|
|
103
|
+
if (nameMatch) {
|
|
104
|
+
return {
|
|
105
|
+
name: nameMatch[1],
|
|
106
|
+
version: versionMatch ? versionMatch[1] : '1.0.0',
|
|
107
|
+
description: descMatch ? descMatch[1] : ''
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Fallback: try to require (might have side effects)
|
|
112
|
+
const plugin = require(pluginPath)
|
|
113
|
+
delete require.cache[require.resolve(pluginPath)]
|
|
114
|
+
|
|
115
|
+
if (plugin.name) {
|
|
116
|
+
return {
|
|
117
|
+
name: plugin.name,
|
|
118
|
+
version: plugin.version || '1.0.0',
|
|
119
|
+
description: plugin.description || ''
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
} catch {
|
|
123
|
+
// Can't read metadata
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
return null
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Get all available plugins
|
|
131
|
+
* @returns {Object[]}
|
|
132
|
+
*/
|
|
133
|
+
getAvailable() {
|
|
134
|
+
return Array.from(this.availablePlugins.values())
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Get available plugins by source
|
|
139
|
+
* @param {string} source
|
|
140
|
+
* @returns {Object[]}
|
|
141
|
+
*/
|
|
142
|
+
getAvailableBySource(source) {
|
|
143
|
+
return this.getAvailable().filter(p => p.source === source)
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Check if a plugin is available
|
|
148
|
+
* @param {string} name
|
|
149
|
+
* @returns {boolean}
|
|
150
|
+
*/
|
|
151
|
+
isAvailable(name) {
|
|
152
|
+
return this.availablePlugins.has(name)
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Check if a plugin is loaded
|
|
157
|
+
* @param {string} name
|
|
158
|
+
* @returns {boolean}
|
|
159
|
+
*/
|
|
160
|
+
isLoaded(name) {
|
|
161
|
+
return pluginLoader.getPlugin(name) !== null
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Get plugin info
|
|
166
|
+
* @param {string} name
|
|
167
|
+
* @returns {Object|null}
|
|
168
|
+
*/
|
|
169
|
+
getPluginInfo(name) {
|
|
170
|
+
const available = this.availablePlugins.get(name)
|
|
171
|
+
const loaded = pluginLoader.getPlugin(name)
|
|
172
|
+
|
|
173
|
+
if (!available && !loaded) return null
|
|
174
|
+
|
|
175
|
+
return {
|
|
176
|
+
...(available || {}),
|
|
177
|
+
loaded: !!loaded,
|
|
178
|
+
active: loaded ? true : false
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Install a plugin (copy to global plugins)
|
|
184
|
+
* @param {string} sourcePath - Path to plugin file/directory
|
|
185
|
+
* @param {string} [name] - Optional name override
|
|
186
|
+
*/
|
|
187
|
+
async install(sourcePath, name = null) {
|
|
188
|
+
const globalPluginsPath = path.join(pathManager.getGlobalStoragePath(), 'plugins')
|
|
189
|
+
await fs.mkdir(globalPluginsPath, { recursive: true })
|
|
190
|
+
|
|
191
|
+
const stat = await fs.stat(sourcePath)
|
|
192
|
+
|
|
193
|
+
if (stat.isFile()) {
|
|
194
|
+
// Single file plugin
|
|
195
|
+
const pluginName = name || path.basename(sourcePath)
|
|
196
|
+
const destPath = path.join(globalPluginsPath, pluginName)
|
|
197
|
+
await fs.copyFile(sourcePath, destPath)
|
|
198
|
+
} else if (stat.isDirectory()) {
|
|
199
|
+
// Directory plugin
|
|
200
|
+
const pluginName = name || path.basename(sourcePath)
|
|
201
|
+
const destPath = path.join(globalPluginsPath, pluginName)
|
|
202
|
+
await this.copyDirectory(sourcePath, destPath)
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// Refresh available plugins
|
|
206
|
+
await this.discoverPlugins()
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Uninstall a plugin
|
|
211
|
+
* @param {string} name
|
|
212
|
+
*/
|
|
213
|
+
async uninstall(name) {
|
|
214
|
+
const plugin = this.availablePlugins.get(name)
|
|
215
|
+
|
|
216
|
+
if (!plugin) {
|
|
217
|
+
throw new Error(`Plugin not found: ${name}`)
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
if (plugin.source === 'builtin') {
|
|
221
|
+
throw new Error('Cannot uninstall built-in plugins')
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// Unload if loaded
|
|
225
|
+
if (this.isLoaded(name)) {
|
|
226
|
+
await pluginLoader.unloadPlugin(name)
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// Delete plugin file/directory
|
|
230
|
+
const pluginPath = plugin.path
|
|
231
|
+
const stat = await fs.stat(path.dirname(pluginPath))
|
|
232
|
+
|
|
233
|
+
if (path.basename(pluginPath) === 'index.js') {
|
|
234
|
+
// Directory plugin
|
|
235
|
+
await fs.rm(path.dirname(pluginPath), { recursive: true })
|
|
236
|
+
} else {
|
|
237
|
+
// Single file plugin
|
|
238
|
+
await fs.unlink(pluginPath)
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// Remove from registry
|
|
242
|
+
this.availablePlugins.delete(name)
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* Copy directory recursively
|
|
247
|
+
* @param {string} src
|
|
248
|
+
* @param {string} dest
|
|
249
|
+
*/
|
|
250
|
+
async copyDirectory(src, dest) {
|
|
251
|
+
await fs.mkdir(dest, { recursive: true })
|
|
252
|
+
const entries = await fs.readdir(src, { withFileTypes: true })
|
|
253
|
+
|
|
254
|
+
for (const entry of entries) {
|
|
255
|
+
const srcPath = path.join(src, entry.name)
|
|
256
|
+
const destPath = path.join(dest, entry.name)
|
|
257
|
+
|
|
258
|
+
if (entry.isDirectory()) {
|
|
259
|
+
await this.copyDirectory(srcPath, destPath)
|
|
260
|
+
} else {
|
|
261
|
+
await fs.copyFile(srcPath, destPath)
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* Generate plugin list for display
|
|
268
|
+
* @returns {string}
|
|
269
|
+
*/
|
|
270
|
+
generatePluginList() {
|
|
271
|
+
const available = this.getAvailable()
|
|
272
|
+
|
|
273
|
+
if (available.length === 0) {
|
|
274
|
+
return 'No plugins installed.'
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
let output = '## Installed Plugins\n\n'
|
|
278
|
+
|
|
279
|
+
const bySource = {
|
|
280
|
+
builtin: [],
|
|
281
|
+
global: [],
|
|
282
|
+
project: []
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
for (const plugin of available) {
|
|
286
|
+
bySource[plugin.source].push(plugin)
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
if (bySource.builtin.length > 0) {
|
|
290
|
+
output += '### Built-in\n'
|
|
291
|
+
for (const p of bySource.builtin) {
|
|
292
|
+
const status = this.isLoaded(p.name) ? '●' : '○'
|
|
293
|
+
output += `- ${status} **${p.name}** v${p.version}\n`
|
|
294
|
+
if (p.description) {
|
|
295
|
+
output += ` ${p.description}\n`
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
output += '\n'
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
if (bySource.global.length > 0) {
|
|
302
|
+
output += '### Global\n'
|
|
303
|
+
for (const p of bySource.global) {
|
|
304
|
+
const status = this.isLoaded(p.name) ? '●' : '○'
|
|
305
|
+
output += `- ${status} **${p.name}** v${p.version}\n`
|
|
306
|
+
if (p.description) {
|
|
307
|
+
output += ` ${p.description}\n`
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
output += '\n'
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
output += '\n● = loaded, ○ = available'
|
|
314
|
+
|
|
315
|
+
return output
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
// Singleton instance
|
|
320
|
+
const pluginRegistry = new PluginRegistry()
|
|
321
|
+
|
|
322
|
+
module.exports = {
|
|
323
|
+
PluginRegistry,
|
|
324
|
+
pluginRegistry
|
|
325
|
+
}
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Webhook Plugin for prjct-cli
|
|
3
|
+
*
|
|
4
|
+
* Sends HTTP POST requests to configured webhooks on events.
|
|
5
|
+
* Useful for integrating with Slack, Discord, Zapier, etc.
|
|
6
|
+
*
|
|
7
|
+
* @version 1.0.0
|
|
8
|
+
*
|
|
9
|
+
* Configuration in prjct.config.json:
|
|
10
|
+
* {
|
|
11
|
+
* "plugins": ["webhook"],
|
|
12
|
+
* "webhook": {
|
|
13
|
+
* "url": "https://hooks.example.com/...",
|
|
14
|
+
* "events": ["session.completed", "feature.shipped"],
|
|
15
|
+
* "secret": "optional-signing-secret"
|
|
16
|
+
* }
|
|
17
|
+
* }
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
const { EventTypes } = require('../bus')
|
|
21
|
+
const { HookPoints } = require('../plugin/hooks')
|
|
22
|
+
|
|
23
|
+
const plugin = {
|
|
24
|
+
name: 'webhook',
|
|
25
|
+
version: '1.0.0',
|
|
26
|
+
description: 'Send HTTP webhooks on events',
|
|
27
|
+
|
|
28
|
+
// Plugin state
|
|
29
|
+
config: null,
|
|
30
|
+
enabled: false,
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Activate plugin
|
|
34
|
+
*/
|
|
35
|
+
async activate({ config }) {
|
|
36
|
+
this.config = config
|
|
37
|
+
|
|
38
|
+
if (!config.url) {
|
|
39
|
+
console.warn('[webhook] No URL configured, plugin disabled')
|
|
40
|
+
return
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
this.enabled = true
|
|
44
|
+
this.events = config.events || [
|
|
45
|
+
EventTypes.SESSION_COMPLETED,
|
|
46
|
+
EventTypes.FEATURE_SHIPPED,
|
|
47
|
+
EventTypes.SNAPSHOT_CREATED
|
|
48
|
+
]
|
|
49
|
+
},
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Deactivate plugin
|
|
53
|
+
*/
|
|
54
|
+
async deactivate() {
|
|
55
|
+
this.enabled = false
|
|
56
|
+
},
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Event handlers
|
|
60
|
+
*/
|
|
61
|
+
events: {
|
|
62
|
+
[EventTypes.SESSION_COMPLETED]: async function(data) {
|
|
63
|
+
await plugin.sendWebhook('session.completed', data)
|
|
64
|
+
},
|
|
65
|
+
|
|
66
|
+
[EventTypes.FEATURE_SHIPPED]: async function(data) {
|
|
67
|
+
await plugin.sendWebhook('feature.shipped', data)
|
|
68
|
+
},
|
|
69
|
+
|
|
70
|
+
[EventTypes.SNAPSHOT_CREATED]: async function(data) {
|
|
71
|
+
await plugin.sendWebhook('snapshot.created', data)
|
|
72
|
+
},
|
|
73
|
+
|
|
74
|
+
[EventTypes.TASK_COMPLETED]: async function(data) {
|
|
75
|
+
await plugin.sendWebhook('task.completed', data)
|
|
76
|
+
}
|
|
77
|
+
},
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Hook handlers
|
|
81
|
+
*/
|
|
82
|
+
hooks: {
|
|
83
|
+
[HookPoints.AFTER_FEATURE_SHIP]: async function(data) {
|
|
84
|
+
await plugin.sendWebhook('feature.shipped', {
|
|
85
|
+
feature: data.feature,
|
|
86
|
+
version: data.version,
|
|
87
|
+
timestamp: data.timestamp
|
|
88
|
+
})
|
|
89
|
+
}
|
|
90
|
+
},
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Send webhook request
|
|
94
|
+
* @param {string} event - Event type
|
|
95
|
+
* @param {Object} data - Event data
|
|
96
|
+
*/
|
|
97
|
+
async sendWebhook(event, data) {
|
|
98
|
+
if (!this.enabled || !this.config.url) return
|
|
99
|
+
|
|
100
|
+
// Check if this event should be sent
|
|
101
|
+
if (this.config.events && !this.config.events.includes(event)) {
|
|
102
|
+
return
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const payload = {
|
|
106
|
+
event,
|
|
107
|
+
timestamp: new Date().toISOString(),
|
|
108
|
+
source: 'prjct-cli',
|
|
109
|
+
data
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
try {
|
|
113
|
+
const headers = {
|
|
114
|
+
'Content-Type': 'application/json',
|
|
115
|
+
'User-Agent': 'prjct-cli/webhook'
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// Add signature if secret is configured
|
|
119
|
+
if (this.config.secret) {
|
|
120
|
+
const crypto = require('crypto')
|
|
121
|
+
const signature = crypto
|
|
122
|
+
.createHmac('sha256', this.config.secret)
|
|
123
|
+
.update(JSON.stringify(payload))
|
|
124
|
+
.digest('hex')
|
|
125
|
+
headers['X-Prjct-Signature'] = `sha256=${signature}`
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const response = await fetch(this.config.url, {
|
|
129
|
+
method: 'POST',
|
|
130
|
+
headers,
|
|
131
|
+
body: JSON.stringify(payload)
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
if (!response.ok) {
|
|
135
|
+
console.error(`[webhook] Request failed: ${response.status}`)
|
|
136
|
+
}
|
|
137
|
+
} catch (error) {
|
|
138
|
+
console.error(`[webhook] Error sending webhook:`, error.message)
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
module.exports = plugin
|