@adversity/coding-tool-x 2.6.1 → 3.0.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.
@@ -0,0 +1,601 @@
1
+ const fs = require('fs');
2
+ const path = require('path');
3
+ const { execSync, spawn } = require('child_process');
4
+ const os = require('os');
5
+ const crypto = require('crypto');
6
+ const inquirer = require('inquirer');
7
+
8
+ const { PLUGINS_DIR, CONFIG_DIR, INSTALLED_DIR } = require('./constants');
9
+ const { validateManifest, checkVersionCompatibility } = require('./manifest-validator');
10
+ const { addPlugin, removePlugin, getPlugin, listPlugins, updatePlugin: updatePluginRegistry } = require('./registry');
11
+
12
+ // Core commands that plugins cannot override
13
+ const CORE_COMMANDS = [
14
+ 'start', 'stop', 'restart', 'status',
15
+ 'ui', 'logs', 'stats', 'doctor', 'reset',
16
+ 'claude', 'codex', 'gemini', 'proxy',
17
+ 'security', 'help', 'version'
18
+ ];
19
+
20
+ /**
21
+ * Validate Git URL format
22
+ * @param {string} url - Git URL to validate
23
+ * @returns {{ valid: boolean, error?: string }}
24
+ */
25
+ function validateGitUrl(url) {
26
+ if (!url || typeof url !== 'string') {
27
+ return { valid: false, error: 'Git URL is required' };
28
+ }
29
+
30
+ // Support HTTPS URLs
31
+ const httpsPattern = /^https:\/\/github\.com\/[\w-]+\/[\w.-]+(?:\.git)?$/;
32
+ // Support SSH URLs
33
+ const sshPattern = /^git@github\.com:[\w-]+\/[\w.-]+(?:\.git)?$/;
34
+ // Support generic git URLs
35
+ const genericPattern = /^(https?|git):\/\/.+\.git$/;
36
+
37
+ if (httpsPattern.test(url) || sshPattern.test(url) || genericPattern.test(url)) {
38
+ return { valid: true };
39
+ }
40
+
41
+ return {
42
+ valid: false,
43
+ error: `Invalid Git URL format: "${url}". Expected formats:\n` +
44
+ ' - https://github.com/user/repo.git\n' +
45
+ ' - git@github.com:user/repo.git'
46
+ };
47
+ }
48
+
49
+ /**
50
+ * Generate a random temporary directory path
51
+ * @returns {string} Temporary directory path
52
+ */
53
+ function getTempDir() {
54
+ const random = crypto.randomBytes(8).toString('hex');
55
+ return path.join(os.tmpdir(), `ctx-plugin-${random}`);
56
+ }
57
+
58
+ /**
59
+ * Execute a shell command and return result
60
+ * @param {string} command - Command to execute
61
+ * @param {Object} options - Options for execSync
62
+ * @returns {{ success: boolean, output?: string, error?: string }}
63
+ */
64
+ function execCommand(command, options = {}) {
65
+ try {
66
+ const output = execSync(command, {
67
+ encoding: 'utf8',
68
+ stdio: ['pipe', 'pipe', 'pipe'],
69
+ ...options
70
+ });
71
+ return { success: true, output: output.trim() };
72
+ } catch (error) {
73
+ return {
74
+ success: false,
75
+ error: error.stderr || error.message || 'Command failed'
76
+ };
77
+ }
78
+ }
79
+
80
+ /**
81
+ * Recursively delete a directory
82
+ * @param {string} dirPath - Directory path to delete
83
+ */
84
+ function deleteDirectory(dirPath) {
85
+ if (fs.existsSync(dirPath)) {
86
+ fs.rmSync(dirPath, { recursive: true, force: true });
87
+ }
88
+ }
89
+
90
+ /**
91
+ * Ensure a directory exists
92
+ * @param {string} dirPath - Directory path to create
93
+ */
94
+ function ensureDirectory(dirPath) {
95
+ if (!fs.existsSync(dirPath)) {
96
+ fs.mkdirSync(dirPath, { recursive: true });
97
+ }
98
+ }
99
+
100
+ /**
101
+ * Copy directory recursively
102
+ * @param {string} src - Source directory
103
+ * @param {string} dest - Destination directory
104
+ */
105
+ function copyDirectory(src, dest) {
106
+ ensureDirectory(dest);
107
+
108
+ const entries = fs.readdirSync(src, { withFileTypes: true });
109
+
110
+ for (const entry of entries) {
111
+ const srcPath = path.join(src, entry.name);
112
+ const destPath = path.join(dest, entry.name);
113
+
114
+ if (entry.isDirectory()) {
115
+ copyDirectory(srcPath, destPath);
116
+ } else {
117
+ fs.copyFileSync(srcPath, destPath);
118
+ }
119
+ }
120
+ }
121
+
122
+ /**
123
+ * Check for name conflicts with existing plugins
124
+ * @param {string} pluginName - Plugin name to check
125
+ * @returns {{ conflict: boolean, reason?: string }}
126
+ */
127
+ function checkNameConflict(pluginName) {
128
+ const existingPlugin = getPlugin(pluginName);
129
+
130
+ if (existingPlugin) {
131
+ return {
132
+ conflict: true,
133
+ reason: `A plugin named "${pluginName}" is already installed (version ${existingPlugin.version})`
134
+ };
135
+ }
136
+
137
+ return { conflict: false };
138
+ }
139
+
140
+ /**
141
+ * Check for command name conflicts with core commands
142
+ * @param {Array} pluginCommands - Array of command names from plugin
143
+ * @returns {{ conflict: boolean, reason?: string, conflicts?: Array }}
144
+ */
145
+ function checkCommandConflicts(pluginCommands) {
146
+ if (!pluginCommands || !Array.isArray(pluginCommands)) {
147
+ return { conflict: false };
148
+ }
149
+
150
+ const conflicts = [];
151
+
152
+ for (const cmd of pluginCommands) {
153
+ const cmdName = typeof cmd === 'string' ? cmd : cmd.name;
154
+ if (CORE_COMMANDS.includes(cmdName)) {
155
+ conflicts.push(cmdName);
156
+ }
157
+ }
158
+
159
+ if (conflicts.length > 0) {
160
+ return {
161
+ conflict: true,
162
+ reason: `Plugin commands conflict with core commands: ${conflicts.join(', ')}`,
163
+ conflicts
164
+ };
165
+ }
166
+
167
+ return { conflict: false };
168
+ }
169
+
170
+ /**
171
+ * Install npm dependencies for a plugin
172
+ * @param {string} pluginDir - Plugin directory path
173
+ * @returns {{ success: boolean, error?: string }}
174
+ */
175
+ function installDependencies(pluginDir) {
176
+ const packageJsonPath = path.join(pluginDir, 'package.json');
177
+
178
+ if (!fs.existsSync(packageJsonPath)) {
179
+ return { success: true }; // No dependencies to install
180
+ }
181
+
182
+ const result = execCommand('npm install --production --ignore-scripts', {
183
+ cwd: pluginDir,
184
+ timeout: 120000 // 2 minute timeout
185
+ });
186
+
187
+ if (!result.success) {
188
+ return {
189
+ success: false,
190
+ error: `Failed to install dependencies: ${result.error}`
191
+ };
192
+ }
193
+
194
+ return { success: true };
195
+ }
196
+
197
+ /**
198
+ * Install a plugin from a Git URL
199
+ * @param {string} gitUrl - Git repository URL
200
+ * @returns {Promise<{ success: boolean, plugin?: Object, error?: string }>}
201
+ */
202
+ async function installPlugin(gitUrl) {
203
+ let tempDir = null;
204
+ let installedDir = null;
205
+ let registryAdded = false;
206
+ let pluginName = null;
207
+
208
+ try {
209
+ // Step 1: Validate Git URL format
210
+ const urlValidation = validateGitUrl(gitUrl);
211
+ if (!urlValidation.valid) {
212
+ return { success: false, error: urlValidation.error };
213
+ }
214
+
215
+ // Step 2: Security warning and user confirmation
216
+ console.warn('\n⚠️ WARNING: Plugins have full system access!');
217
+ console.warn('Only install plugins from trusted sources.');
218
+ console.warn(`Source: ${gitUrl}\n`);
219
+
220
+ const { confirmed } = await inquirer.prompt([
221
+ {
222
+ type: 'confirm',
223
+ name: 'confirmed',
224
+ message: 'Do you want to continue with the installation?',
225
+ default: false
226
+ }
227
+ ]);
228
+
229
+ if (!confirmed) {
230
+ return {
231
+ success: false,
232
+ error: 'Installation cancelled by user'
233
+ };
234
+ }
235
+
236
+ // Step 3: Clone to temp directory
237
+ tempDir = getTempDir();
238
+ const cloneResult = execCommand(`git clone --depth 1 "${gitUrl}" "${tempDir}"`, {
239
+ timeout: 60000 // 1 minute timeout
240
+ });
241
+
242
+ if (!cloneResult.success) {
243
+ return {
244
+ success: false,
245
+ error: `Failed to clone repository: ${cloneResult.error}`
246
+ };
247
+ }
248
+
249
+ // Step 4: Read and validate plugin.json
250
+ const manifestPath = path.join(tempDir, 'plugin.json');
251
+
252
+ if (!fs.existsSync(manifestPath)) {
253
+ return {
254
+ success: false,
255
+ error: 'Invalid plugin: plugin.json not found in repository root'
256
+ };
257
+ }
258
+
259
+ let manifest;
260
+ try {
261
+ manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
262
+ } catch (parseError) {
263
+ return {
264
+ success: false,
265
+ error: `Failed to parse plugin.json: ${parseError.message}`
266
+ };
267
+ }
268
+
269
+ const validation = validateManifest(manifest);
270
+ if (!validation.valid) {
271
+ const errorMessages = validation.errors
272
+ .map(e => ` - ${e.field}: ${e.message}`)
273
+ .join('\n');
274
+ return {
275
+ success: false,
276
+ error: `Invalid plugin.json:\n${errorMessages}`
277
+ };
278
+ }
279
+
280
+ pluginName = manifest.name;
281
+
282
+ // Step 5: Check version compatibility
283
+ if (manifest.minVersion) {
284
+ const versionCheck = checkVersionCompatibility(manifest.minVersion);
285
+ if (!versionCheck.compatible) {
286
+ return {
287
+ success: false,
288
+ error: versionCheck.reason
289
+ };
290
+ }
291
+ }
292
+
293
+ // Step 6: Check for name conflicts with existing plugins
294
+ const nameCheck = checkNameConflict(pluginName);
295
+ if (nameCheck.conflict) {
296
+ return {
297
+ success: false,
298
+ error: nameCheck.reason
299
+ };
300
+ }
301
+
302
+ // Step 7: Check for command name conflicts with core commands
303
+ const commandCheck = checkCommandConflicts(manifest.commands);
304
+ if (commandCheck.conflict) {
305
+ return {
306
+ success: false,
307
+ error: commandCheck.reason
308
+ };
309
+ }
310
+
311
+ // Step 8: Install npm dependencies if package.json exists
312
+ const depResult = installDependencies(tempDir);
313
+ if (!depResult.success) {
314
+ return {
315
+ success: false,
316
+ error: depResult.error
317
+ };
318
+ }
319
+
320
+ // Step 9: Move to final location
321
+ installedDir = path.join(INSTALLED_DIR, pluginName);
322
+ ensureDirectory(INSTALLED_DIR);
323
+
324
+ // Remove any existing directory (shouldn't exist due to conflict check, but be safe)
325
+ if (fs.existsSync(installedDir)) {
326
+ deleteDirectory(installedDir);
327
+ }
328
+
329
+ // Move temp dir to installed dir
330
+ copyDirectory(tempDir, installedDir);
331
+
332
+ // Step 10: Update registry
333
+ addPlugin(pluginName, {
334
+ version: manifest.version,
335
+ enabled: true,
336
+ source: gitUrl,
337
+ loadOrder: manifest.loadOrder || 10
338
+ });
339
+ registryAdded = true;
340
+
341
+ // Clean up temp directory
342
+ deleteDirectory(tempDir);
343
+ tempDir = null;
344
+
345
+ // Return success with plugin info
346
+ return {
347
+ success: true,
348
+ plugin: {
349
+ name: pluginName,
350
+ version: manifest.version,
351
+ description: manifest.description,
352
+ author: manifest.author,
353
+ commands: manifest.commands ? manifest.commands.length : 0,
354
+ hooks: manifest.hooks ? manifest.hooks.length : 0,
355
+ source: gitUrl,
356
+ installedAt: new Date().toISOString()
357
+ }
358
+ };
359
+
360
+ } catch (error) {
361
+ // ROLLBACK on any failure
362
+ if (tempDir) {
363
+ deleteDirectory(tempDir);
364
+ }
365
+
366
+ if (installedDir && fs.existsSync(installedDir)) {
367
+ deleteDirectory(installedDir);
368
+ }
369
+
370
+ if (registryAdded && pluginName) {
371
+ removePlugin(pluginName);
372
+ }
373
+
374
+ return {
375
+ success: false,
376
+ error: `Installation failed: ${error.message}`
377
+ };
378
+ }
379
+ }
380
+
381
+ /**
382
+ * Uninstall a plugin by name
383
+ * @param {string} name - Plugin name to uninstall
384
+ * @returns {{ success: boolean, message?: string, error?: string }}
385
+ */
386
+ function uninstallPlugin(name) {
387
+ try {
388
+ // Check if plugin exists in registry
389
+ const plugin = getPlugin(name);
390
+ if (!plugin) {
391
+ return {
392
+ success: false,
393
+ error: `Plugin "${name}" is not installed`
394
+ };
395
+ }
396
+
397
+ // Remove from registry first
398
+ removePlugin(name);
399
+
400
+ // Delete installed directory
401
+ const pluginDir = path.join(INSTALLED_DIR, name);
402
+ if (fs.existsSync(pluginDir)) {
403
+ deleteDirectory(pluginDir);
404
+ }
405
+
406
+ // Delete config file if exists
407
+ const configFile = path.join(CONFIG_DIR, `${name}.json`);
408
+ if (fs.existsSync(configFile)) {
409
+ fs.unlinkSync(configFile);
410
+ }
411
+
412
+ return {
413
+ success: true,
414
+ message: `Plugin "${name}" has been uninstalled successfully`
415
+ };
416
+
417
+ } catch (error) {
418
+ return {
419
+ success: false,
420
+ error: `Failed to uninstall plugin "${name}": ${error.message}`
421
+ };
422
+ }
423
+ }
424
+
425
+ /**
426
+ * Update a plugin to the latest version
427
+ * @param {string} name - Plugin name to update
428
+ * @returns {Promise<{ success: boolean, plugin?: Object, message?: string, error?: string }>}
429
+ */
430
+ async function updatePlugin(name) {
431
+ try {
432
+ // Check if plugin exists
433
+ const pluginInfo = getPlugin(name);
434
+ if (!pluginInfo) {
435
+ return {
436
+ success: false,
437
+ error: `Plugin "${name}" is not installed`
438
+ };
439
+ }
440
+
441
+ const pluginDir = path.join(INSTALLED_DIR, name);
442
+
443
+ if (!fs.existsSync(pluginDir)) {
444
+ return {
445
+ success: false,
446
+ error: `Plugin directory not found: ${pluginDir}`
447
+ };
448
+ }
449
+
450
+ // Get old version for comparison
451
+ const manifestPath = path.join(pluginDir, 'plugin.json');
452
+ let oldVersion = pluginInfo.version;
453
+
454
+ // Git pull to get latest changes
455
+ const pullResult = execCommand('git pull --ff-only', {
456
+ cwd: pluginDir,
457
+ timeout: 60000
458
+ });
459
+
460
+ if (!pullResult.success) {
461
+ // Try a fetch and reset if pull fails
462
+ const fetchResult = execCommand('git fetch origin && git reset --hard origin/HEAD', {
463
+ cwd: pluginDir,
464
+ timeout: 60000
465
+ });
466
+
467
+ if (!fetchResult.success) {
468
+ return {
469
+ success: false,
470
+ error: `Failed to update plugin: ${fetchResult.error}`
471
+ };
472
+ }
473
+ }
474
+
475
+ // Reinstall dependencies
476
+ const depResult = installDependencies(pluginDir);
477
+ if (!depResult.success) {
478
+ return {
479
+ success: false,
480
+ error: depResult.error
481
+ };
482
+ }
483
+
484
+ // Read updated manifest
485
+ let manifest;
486
+ try {
487
+ manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
488
+ } catch (parseError) {
489
+ return {
490
+ success: false,
491
+ error: `Failed to read updated plugin.json: ${parseError.message}`
492
+ };
493
+ }
494
+
495
+ // Validate updated manifest
496
+ const validation = validateManifest(manifest);
497
+ if (!validation.valid) {
498
+ return {
499
+ success: false,
500
+ error: `Updated plugin.json is invalid. Please contact the plugin author.`
501
+ };
502
+ }
503
+
504
+ // Check version compatibility
505
+ if (manifest.minVersion) {
506
+ const versionCheck = checkVersionCompatibility(manifest.minVersion);
507
+ if (!versionCheck.compatible) {
508
+ return {
509
+ success: false,
510
+ error: `Updated plugin requires newer CTX version: ${versionCheck.reason}`
511
+ };
512
+ }
513
+ }
514
+
515
+ // Update registry with new version
516
+ updatePluginRegistry(name, {
517
+ version: manifest.version,
518
+ updatedAt: new Date().toISOString()
519
+ });
520
+
521
+ const wasUpdated = oldVersion !== manifest.version;
522
+
523
+ return {
524
+ success: true,
525
+ plugin: {
526
+ name: name,
527
+ oldVersion: oldVersion,
528
+ newVersion: manifest.version,
529
+ updated: wasUpdated
530
+ },
531
+ message: wasUpdated
532
+ ? `Plugin "${name}" updated from v${oldVersion} to v${manifest.version}`
533
+ : `Plugin "${name}" is already at the latest version (v${manifest.version})`
534
+ };
535
+
536
+ } catch (error) {
537
+ return {
538
+ success: false,
539
+ error: `Failed to update plugin "${name}": ${error.message}`
540
+ };
541
+ }
542
+ }
543
+
544
+ /**
545
+ * Update all installed plugins
546
+ * @returns {Promise<{ success: boolean, results: Array, summary: { updated: number, failed: number, unchanged: number } }>}
547
+ */
548
+ async function updateAllPlugins() {
549
+ const plugins = listPlugins();
550
+ const results = [];
551
+ const summary = {
552
+ updated: 0,
553
+ failed: 0,
554
+ unchanged: 0
555
+ };
556
+
557
+ if (plugins.length === 0) {
558
+ return {
559
+ success: true,
560
+ results: [],
561
+ summary
562
+ };
563
+ }
564
+
565
+ for (const plugin of plugins) {
566
+ const result = await updatePlugin(plugin.name);
567
+
568
+ results.push({
569
+ name: plugin.name,
570
+ ...result
571
+ });
572
+
573
+ if (result.success) {
574
+ if (result.plugin && result.plugin.updated) {
575
+ summary.updated++;
576
+ } else {
577
+ summary.unchanged++;
578
+ }
579
+ } else {
580
+ summary.failed++;
581
+ }
582
+ }
583
+
584
+ return {
585
+ success: summary.failed === 0,
586
+ results,
587
+ summary
588
+ };
589
+ }
590
+
591
+ module.exports = {
592
+ installPlugin,
593
+ uninstallPlugin,
594
+ updatePlugin,
595
+ updateAllPlugins,
596
+ // Export utilities for testing
597
+ validateGitUrl,
598
+ checkNameConflict,
599
+ checkCommandConflicts,
600
+ CORE_COMMANDS
601
+ };