aios-core 3.4.0 → 3.6.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,629 @@
1
+ /**
2
+ * Squad Publisher Utility
3
+ *
4
+ * Publishes squads to the aios-squads GitHub repository via Pull Request.
5
+ * Requires GitHub CLI (gh) authentication.
6
+ *
7
+ * @module squad-publisher
8
+ * @version 1.0.0
9
+ * @see Story SQS-6: Download & Publish Tasks
10
+ */
11
+
12
+ const { execSync, spawnSync } = require('child_process');
13
+ const fs = require('fs').promises;
14
+ const path = require('path');
15
+
16
+ /**
17
+ * Regex pattern for safe squad/branch names
18
+ * Only allows alphanumerics, hyphens, underscores, and dots
19
+ * @constant {RegExp}
20
+ */
21
+ const SAFE_NAME_PATTERN = /^[a-zA-Z0-9._-]+$/;
22
+
23
+ /**
24
+ * Repository for aios-squads
25
+ * @constant {string}
26
+ */
27
+ const AIOS_SQUADS_REPO = 'SynkraAI/aios-squads';
28
+
29
+ /**
30
+ * Error codes for SquadPublisherError
31
+ * @enum {string}
32
+ */
33
+ const PublisherErrorCodes = {
34
+ AUTH_REQUIRED: 'AUTH_REQUIRED',
35
+ VALIDATION_FAILED: 'VALIDATION_FAILED',
36
+ SQUAD_NOT_FOUND: 'SQUAD_NOT_FOUND',
37
+ MANIFEST_ERROR: 'MANIFEST_ERROR',
38
+ GH_CLI_ERROR: 'GH_CLI_ERROR',
39
+ PR_ERROR: 'PR_ERROR',
40
+ FORK_ERROR: 'FORK_ERROR',
41
+ SQUAD_EXISTS_IN_REGISTRY: 'SQUAD_EXISTS_IN_REGISTRY',
42
+ INVALID_SQUAD_NAME: 'INVALID_SQUAD_NAME',
43
+ };
44
+
45
+ /**
46
+ * Sanitize a string for safe use in shell commands and file paths
47
+ * @param {string} value - Value to sanitize
48
+ * @returns {string} Sanitized value
49
+ */
50
+ function sanitizeForShell(value) {
51
+ if (!value || typeof value !== 'string') {
52
+ return '';
53
+ }
54
+ // Replace unsafe characters with hyphens, then collapse multiple hyphens
55
+ return value
56
+ .replace(/[^a-zA-Z0-9._-]/g, '-')
57
+ .replace(/-+/g, '-')
58
+ .replace(/^-|-$/g, '');
59
+ }
60
+
61
+ /**
62
+ * Validate that a name is safe for use in shell commands
63
+ * @param {string} name - Name to validate
64
+ * @returns {boolean} True if safe
65
+ */
66
+ function isValidName(name) {
67
+ if (!name || typeof name !== 'string') {
68
+ return false;
69
+ }
70
+ return SAFE_NAME_PATTERN.test(name);
71
+ }
72
+
73
+ /**
74
+ * Custom error class for Squad Publisher operations
75
+ * @extends Error
76
+ */
77
+ class SquadPublisherError extends Error {
78
+ /**
79
+ * Create a SquadPublisherError
80
+ * @param {string} code - Error code from PublisherErrorCodes
81
+ * @param {string} message - Human-readable error message
82
+ * @param {string} [suggestion] - Suggested fix for the error
83
+ */
84
+ constructor(code, message, suggestion) {
85
+ super(message);
86
+ this.name = 'SquadPublisherError';
87
+ this.code = code;
88
+ this.suggestion = suggestion || '';
89
+
90
+ if (Error.captureStackTrace) {
91
+ Error.captureStackTrace(this, SquadPublisherError);
92
+ }
93
+ }
94
+
95
+ /**
96
+ * Returns formatted error string
97
+ * @returns {string}
98
+ */
99
+ toString() {
100
+ let str = `[${this.code}] ${this.message}`;
101
+ if (this.suggestion) {
102
+ str += `\n Suggestion: ${this.suggestion}`;
103
+ }
104
+ return str;
105
+ }
106
+ }
107
+
108
+ /**
109
+ * Squad Publisher class for publishing squads to aios-squads repository
110
+ */
111
+ class SquadPublisher {
112
+ /**
113
+ * Create a SquadPublisher instance
114
+ * @param {Object} [options={}] - Configuration options
115
+ * @param {boolean} [options.verbose=false] - Enable verbose logging
116
+ * @param {boolean} [options.dryRun=false] - Simulate without creating PR
117
+ * @param {string} [options.repo] - Target repository (default: SynkraAI/aios-squads)
118
+ */
119
+ constructor(options = {}) {
120
+ this.verbose = options.verbose || false;
121
+ this.dryRun = options.dryRun || false;
122
+ this.repo = options.repo || AIOS_SQUADS_REPO;
123
+ }
124
+
125
+ /**
126
+ * Log message if verbose mode is enabled
127
+ * @private
128
+ * @param {string} message - Message to log
129
+ */
130
+ _log(message) {
131
+ if (this.verbose) {
132
+ console.log(`[SquadPublisher] ${message}`);
133
+ }
134
+ }
135
+
136
+ /**
137
+ * Check GitHub CLI authentication
138
+ *
139
+ * @returns {Promise<{authenticated: boolean, username: string|null}>}
140
+ *
141
+ * @example
142
+ * const publisher = new SquadPublisher();
143
+ * const auth = await publisher.checkAuth();
144
+ * if (!auth.authenticated) {
145
+ * console.log('Please run: gh auth login');
146
+ * }
147
+ */
148
+ async checkAuth() {
149
+ this._log('Checking GitHub CLI authentication');
150
+
151
+ try {
152
+ const result = execSync('gh auth status', {
153
+ encoding: 'utf-8',
154
+ stdio: ['pipe', 'pipe', 'pipe'],
155
+ });
156
+
157
+ // Extract username from output (supports hyphenated GitHub usernames)
158
+ const usernameMatch = result.match(/Logged in to .* as ([\w-]+)/);
159
+ const username = usernameMatch ? usernameMatch[1] : null;
160
+
161
+ this._log(`Authenticated as: ${username || 'unknown'}`);
162
+ return { authenticated: true, username };
163
+ } catch {
164
+ this._log('Not authenticated with GitHub CLI');
165
+ return { authenticated: false, username: null };
166
+ }
167
+ }
168
+
169
+ /**
170
+ * Publish squad to aios-squads repository
171
+ *
172
+ * @param {string} squadPath - Path to squad directory
173
+ * @param {Object} [options={}] - Publish options
174
+ * @param {string} [options.category='community'] - Category: 'official' or 'community'
175
+ * @returns {Promise<{prUrl: string, branch: string, manifest: object}>}
176
+ * @throws {SquadPublisherError} AUTH_REQUIRED if not authenticated
177
+ * @throws {SquadPublisherError} VALIDATION_FAILED if squad validation fails
178
+ * @throws {SquadPublisherError} SQUAD_NOT_FOUND if squad path doesn't exist
179
+ *
180
+ * @example
181
+ * const publisher = new SquadPublisher();
182
+ * const result = await publisher.publish('./squads/my-squad');
183
+ * console.log(`PR created: ${result.prUrl}`);
184
+ *
185
+ * // Dry run
186
+ * const dryPublisher = new SquadPublisher({ dryRun: true });
187
+ * const preview = await dryPublisher.publish('./squads/my-squad');
188
+ */
189
+ async publish(squadPath, options = {}) {
190
+ const category = options.category || 'community';
191
+
192
+ this._log(`Publishing squad from: ${squadPath}`);
193
+ this._log(`Category: ${category}`);
194
+ this._log(`Dry run: ${this.dryRun}`);
195
+
196
+ // 1. Check if squad path exists
197
+ if (!(await this._pathExists(squadPath))) {
198
+ throw new SquadPublisherError(
199
+ PublisherErrorCodes.SQUAD_NOT_FOUND,
200
+ `Squad not found at: ${squadPath}`,
201
+ 'Check the path and ensure squad exists',
202
+ );
203
+ }
204
+
205
+ // 2. Validate squad
206
+ const validation = await this._validateSquad(squadPath);
207
+ if (!validation.valid) {
208
+ throw new SquadPublisherError(
209
+ PublisherErrorCodes.VALIDATION_FAILED,
210
+ `Squad validation failed:\n${validation.errors.map((e) => e.message).join('\n')}`,
211
+ 'Run *validate-squad to see all issues',
212
+ );
213
+ }
214
+
215
+ // 3. Load manifest
216
+ const manifest = await this._loadManifest(squadPath);
217
+ if (!manifest || !manifest.name) {
218
+ throw new SquadPublisherError(
219
+ PublisherErrorCodes.MANIFEST_ERROR,
220
+ 'Failed to load squad manifest or missing name',
221
+ 'Ensure squad.yaml has required fields: name, version',
222
+ );
223
+ }
224
+
225
+ const squadName = manifest.name;
226
+
227
+ // 3.1 Validate squad name is safe for shell commands
228
+ if (!isValidName(squadName)) {
229
+ throw new SquadPublisherError(
230
+ PublisherErrorCodes.INVALID_SQUAD_NAME,
231
+ `Invalid squad name: "${squadName}". Only alphanumerics, hyphens, underscores, and dots allowed.`,
232
+ 'Update squad.yaml name field to use only safe characters',
233
+ );
234
+ }
235
+
236
+ const branchName = `squad/${squadName}`;
237
+
238
+ this._log(`Squad name: ${squadName}`);
239
+ this._log(`Branch: ${branchName}`);
240
+
241
+ // 4. Check GitHub auth
242
+ const auth = await this.checkAuth();
243
+ if (!auth.authenticated) {
244
+ throw new SquadPublisherError(
245
+ PublisherErrorCodes.AUTH_REQUIRED,
246
+ 'GitHub CLI not authenticated',
247
+ 'Run: gh auth login',
248
+ );
249
+ }
250
+
251
+ // 5. Generate PR body
252
+ const prTitle = `Add squad: ${squadName}`;
253
+ const prBody = this.generatePRBody(manifest, category);
254
+
255
+ // 6. Dry run - return preview
256
+ if (this.dryRun) {
257
+ this._log('Dry run mode - not creating actual PR');
258
+ return {
259
+ prUrl: '[dry-run] PR would be created',
260
+ branch: branchName,
261
+ manifest,
262
+ preview: {
263
+ title: prTitle,
264
+ body: prBody,
265
+ repo: this.repo,
266
+ category,
267
+ },
268
+ };
269
+ }
270
+
271
+ // 7. Create actual PR
272
+ const prUrl = await this._createPR(squadPath, manifest, branchName, prTitle, prBody);
273
+
274
+ this._log(`PR created: ${prUrl}`);
275
+ return { prUrl, branch: branchName, manifest };
276
+ }
277
+
278
+ /**
279
+ * Generate PR body with squad metadata
280
+ *
281
+ * @param {Object} manifest - Squad manifest
282
+ * @param {string} [category='community'] - Squad category
283
+ * @returns {string} Markdown-formatted PR body
284
+ */
285
+ generatePRBody(manifest, category = 'community') {
286
+ const components = manifest.components || {};
287
+ const tasksCount = components.tasks?.length || 0;
288
+ const agentsCount = components.agents?.length || 0;
289
+ const workflowsCount = components.workflows?.length || 0;
290
+ const checklistsCount = components.checklists?.length || 0;
291
+ const templatesCount = components.templates?.length || 0;
292
+
293
+ return `## New Squad: ${manifest.name}
294
+
295
+ **Version:** ${manifest.version || '1.0.0'}
296
+ **Author:** ${manifest.author || 'Unknown'}
297
+ **Category:** ${category}
298
+ **Description:** ${manifest.description || 'No description provided'}
299
+
300
+ ### Components
301
+
302
+ | Type | Count |
303
+ |------|-------|
304
+ | Tasks | ${tasksCount} |
305
+ | Agents | ${agentsCount} |
306
+ | Workflows | ${workflowsCount} |
307
+ | Checklists | ${checklistsCount} |
308
+ | Templates | ${templatesCount} |
309
+
310
+ ### Dependencies
311
+
312
+ ${manifest.dependencies?.length > 0 ? manifest.dependencies.map((d) => `- ${d}`).join('\n') : 'None specified'}
313
+
314
+ ### Pre-submission Checklist
315
+
316
+ - [x] Squad follows AIOS task-first architecture
317
+ - [x] Documentation is complete (squad.yaml has all required fields)
318
+ - [x] Squad validated locally with \`*validate-squad\`
319
+ - [ ] No sensitive data included (API keys, credentials, etc.)
320
+ - [ ] All files use kebab-case naming convention
321
+
322
+ ### Testing
323
+
324
+ Tested locally with:
325
+ \`\`\`bash
326
+ @squad-creator
327
+ *validate-squad ${manifest.name}
328
+ \`\`\`
329
+
330
+ ---
331
+ *Submitted via \`*publish-squad\` from AIOS-FullStack*`;
332
+ }
333
+
334
+ /**
335
+ * Create PR using GitHub CLI
336
+ * @private
337
+ * @param {string} squadPath - Path to squad
338
+ * @param {Object} manifest - Squad manifest
339
+ * @param {string} branchName - Branch name
340
+ * @param {string} title - PR title
341
+ * @param {string} body - PR body
342
+ * @returns {Promise<string>} PR URL
343
+ */
344
+ async _createPR(squadPath, manifest, branchName, title, body) {
345
+ const squadName = manifest.name;
346
+ // Sanitize values for commit message (safety layer even though name is validated)
347
+ const safeVersion = sanitizeForShell(manifest.version) || '1.0.0';
348
+ const safeAuthor = sanitizeForShell(manifest.author) || 'Unknown';
349
+
350
+ try {
351
+ // Step 1: Fork the repository (if not already forked)
352
+ this._log('Checking/creating fork...');
353
+ try {
354
+ // Use spawnSync with array args to prevent shell injection
355
+ const forkResult = spawnSync('gh', ['repo', 'fork', this.repo, '--clone=false'], {
356
+ encoding: 'utf-8',
357
+ stdio: ['pipe', 'pipe', 'pipe'],
358
+ });
359
+ if (forkResult.error) {
360
+ throw forkResult.error;
361
+ }
362
+ } catch {
363
+ // Fork may already exist, that's OK
364
+ this._log('Fork already exists or created');
365
+ }
366
+
367
+ // Step 2: Clone the fork to a temp directory
368
+ const tempDir = path.join(process.cwd(), '.tmp-squad-publish');
369
+ await this._cleanupTemp(tempDir);
370
+ await fs.mkdir(tempDir, { recursive: true });
371
+
372
+ this._log(`Cloning fork to: ${tempDir}`);
373
+ // Use spawnSync with array args
374
+ const cloneResult = spawnSync('gh', ['repo', 'clone', this.repo, tempDir, '--', '--depth', '1'], {
375
+ encoding: 'utf-8',
376
+ stdio: ['pipe', 'pipe', 'pipe'],
377
+ });
378
+ if (cloneResult.status !== 0) {
379
+ throw new Error(cloneResult.stderr || 'Clone failed');
380
+ }
381
+
382
+ // Step 3: Create branch
383
+ this._log(`Creating branch: ${branchName}`);
384
+ // Use spawnSync with array args - branchName is validated
385
+ const checkoutResult = spawnSync('git', ['checkout', '-b', branchName], {
386
+ cwd: tempDir,
387
+ encoding: 'utf-8',
388
+ stdio: ['pipe', 'pipe', 'pipe'],
389
+ });
390
+ if (checkoutResult.status !== 0) {
391
+ throw new Error(checkoutResult.stderr || 'Checkout failed');
392
+ }
393
+
394
+ // Step 4: Copy squad files
395
+ const targetSquadDir = path.join(tempDir, 'packages', squadName);
396
+ await fs.mkdir(targetSquadDir, { recursive: true });
397
+ await this._copyDir(squadPath, targetSquadDir);
398
+
399
+ this._log(`Copied squad to: ${targetSquadDir}`);
400
+
401
+ // Step 5: Update registry.json (add to community section)
402
+ const registryPath = path.join(tempDir, 'registry.json');
403
+ await this._updateRegistry(registryPath, manifest);
404
+
405
+ // Step 6: Commit changes
406
+ this._log('Committing changes...');
407
+ spawnSync('git', ['add', '.'], {
408
+ cwd: tempDir,
409
+ encoding: 'utf-8',
410
+ stdio: ['pipe', 'pipe', 'pipe'],
411
+ });
412
+
413
+ // Build commit message with sanitized values
414
+ const commitMessage = `Add squad: ${squadName}\n\nVersion: ${safeVersion}\nAuthor: ${safeAuthor}`;
415
+ // Use spawnSync with -m flag and message as separate arg
416
+ const commitResult = spawnSync('git', ['commit', '-m', commitMessage], {
417
+ cwd: tempDir,
418
+ encoding: 'utf-8',
419
+ stdio: ['pipe', 'pipe', 'pipe'],
420
+ });
421
+ if (commitResult.status !== 0) {
422
+ throw new Error(commitResult.stderr || 'Commit failed');
423
+ }
424
+
425
+ // Step 7: Push branch
426
+ this._log('Pushing to fork...');
427
+ // Use spawnSync with array args - branchName is validated
428
+ const pushResult = spawnSync('git', ['push', '-u', 'origin', branchName], {
429
+ cwd: tempDir,
430
+ encoding: 'utf-8',
431
+ stdio: ['pipe', 'pipe', 'pipe'],
432
+ });
433
+ if (pushResult.status !== 0) {
434
+ throw new Error(pushResult.stderr || 'Push failed');
435
+ }
436
+
437
+ // Step 8: Create PR
438
+ this._log('Creating PR...');
439
+ const prBodyFile = path.join(tempDir, 'pr-body.md');
440
+ await fs.writeFile(prBodyFile, body);
441
+
442
+ // Use spawnSync with array args - title contains validated squadName
443
+ const prResult = spawnSync(
444
+ 'gh',
445
+ ['pr', 'create', '--repo', this.repo, '--title', title, '--body-file', prBodyFile, '--base', 'main'],
446
+ {
447
+ cwd: tempDir,
448
+ encoding: 'utf-8',
449
+ stdio: ['pipe', 'pipe', 'pipe'],
450
+ },
451
+ );
452
+ if (prResult.status !== 0) {
453
+ throw new Error(prResult.stderr || 'PR creation failed');
454
+ }
455
+
456
+ const prUrl = (prResult.stdout || '').trim();
457
+
458
+ // Step 9: Cleanup
459
+ await this._cleanupTemp(tempDir);
460
+
461
+ return prUrl;
462
+ } catch (error) {
463
+ throw new SquadPublisherError(
464
+ PublisherErrorCodes.PR_ERROR,
465
+ `Failed to create PR: ${error.message}`,
466
+ 'Check GitHub CLI is working: gh auth status',
467
+ );
468
+ }
469
+ }
470
+
471
+ /**
472
+ * Update registry.json with new squad
473
+ * @private
474
+ * @param {string} registryPath - Path to registry.json
475
+ * @param {Object} manifest - Squad manifest
476
+ */
477
+ async _updateRegistry(registryPath, manifest) {
478
+ let registry;
479
+
480
+ try {
481
+ const content = await fs.readFile(registryPath, 'utf-8');
482
+ registry = JSON.parse(content);
483
+ } catch {
484
+ // Create new registry if doesn't exist
485
+ registry = {
486
+ version: '1.0.0',
487
+ squads: {
488
+ official: [],
489
+ community: [],
490
+ },
491
+ };
492
+ }
493
+
494
+ // Ensure structure
495
+ if (!registry.squads) {
496
+ registry.squads = {};
497
+ }
498
+ if (!registry.squads.community) {
499
+ registry.squads.community = [];
500
+ }
501
+
502
+ // Check if squad already exists
503
+ const exists = registry.squads.community.some((s) => s.name === manifest.name);
504
+ if (exists) {
505
+ // Update existing entry
506
+ registry.squads.community = registry.squads.community.map((s) =>
507
+ s.name === manifest.name
508
+ ? {
509
+ name: manifest.name,
510
+ version: manifest.version || '1.0.0',
511
+ description: manifest.description || '',
512
+ author: manifest.author || 'Unknown',
513
+ }
514
+ : s,
515
+ );
516
+ } else {
517
+ // Add new entry
518
+ registry.squads.community.push({
519
+ name: manifest.name,
520
+ version: manifest.version || '1.0.0',
521
+ description: manifest.description || '',
522
+ author: manifest.author || 'Unknown',
523
+ });
524
+ }
525
+
526
+ // Sort alphabetically
527
+ registry.squads.community.sort((a, b) => a.name.localeCompare(b.name));
528
+
529
+ await fs.writeFile(registryPath, JSON.stringify(registry, null, 2) + '\n');
530
+ this._log('Updated registry.json');
531
+ }
532
+
533
+ /**
534
+ * Validate squad
535
+ * @private
536
+ * @param {string} squadPath - Path to squad
537
+ * @returns {Promise<{valid: boolean, errors: Array}>}
538
+ */
539
+ async _validateSquad(squadPath) {
540
+ try {
541
+ const { SquadValidator } = require('./squad-validator');
542
+ const validator = new SquadValidator({ verbose: this.verbose });
543
+ return await validator.validate(squadPath);
544
+ } catch (error) {
545
+ this._log(`Validation error: ${error.message}`);
546
+ return {
547
+ valid: false,
548
+ errors: [{ message: `Validation failed: ${error.message}` }],
549
+ };
550
+ }
551
+ }
552
+
553
+ /**
554
+ * Load squad manifest
555
+ * @private
556
+ * @param {string} squadPath - Path to squad
557
+ * @returns {Promise<Object|null>}
558
+ */
559
+ async _loadManifest(squadPath) {
560
+ try {
561
+ const { SquadLoader } = require('./squad-loader');
562
+ const loader = new SquadLoader();
563
+ return await loader.loadManifest(squadPath);
564
+ } catch (error) {
565
+ this._log(`Failed to load manifest: ${error.message}`);
566
+ return null;
567
+ }
568
+ }
569
+
570
+ /**
571
+ * Copy directory recursively
572
+ * @private
573
+ * @param {string} src - Source path
574
+ * @param {string} dest - Destination path
575
+ */
576
+ async _copyDir(src, dest) {
577
+ await fs.mkdir(dest, { recursive: true });
578
+ const entries = await fs.readdir(src, { withFileTypes: true });
579
+
580
+ for (const entry of entries) {
581
+ const srcPath = path.join(src, entry.name);
582
+ const destPath = path.join(dest, entry.name);
583
+
584
+ if (entry.isDirectory()) {
585
+ await this._copyDir(srcPath, destPath);
586
+ } else {
587
+ await fs.copyFile(srcPath, destPath);
588
+ }
589
+ }
590
+ }
591
+
592
+ /**
593
+ * Clean up temp directory
594
+ * @private
595
+ * @param {string} tempDir - Temp directory path
596
+ */
597
+ async _cleanupTemp(tempDir) {
598
+ try {
599
+ await fs.rm(tempDir, { recursive: true, force: true });
600
+ } catch {
601
+ // Ignore cleanup errors
602
+ }
603
+ }
604
+
605
+ /**
606
+ * Check if path exists
607
+ * @private
608
+ * @param {string} filePath - Path to check
609
+ * @returns {Promise<boolean>}
610
+ */
611
+ async _pathExists(filePath) {
612
+ try {
613
+ await fs.access(filePath);
614
+ return true;
615
+ } catch {
616
+ return false;
617
+ }
618
+ }
619
+ }
620
+
621
+ module.exports = {
622
+ SquadPublisher,
623
+ SquadPublisherError,
624
+ PublisherErrorCodes,
625
+ AIOS_SQUADS_REPO,
626
+ SAFE_NAME_PATTERN,
627
+ sanitizeForShell,
628
+ isValidName,
629
+ };