ally-a11y 1.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.
Files changed (84) hide show
  1. package/ACCESSIBILITY.md +205 -0
  2. package/LICENSE +21 -0
  3. package/README.md +940 -0
  4. package/dist/cli.d.ts +7 -0
  5. package/dist/cli.js +528 -0
  6. package/dist/commands/audit-palette.d.ts +18 -0
  7. package/dist/commands/audit-palette.js +613 -0
  8. package/dist/commands/auto-pr.d.ts +19 -0
  9. package/dist/commands/auto-pr.js +434 -0
  10. package/dist/commands/badge.d.ts +11 -0
  11. package/dist/commands/badge.js +143 -0
  12. package/dist/commands/completion.d.ts +4 -0
  13. package/dist/commands/completion.js +185 -0
  14. package/dist/commands/crawl.d.ts +12 -0
  15. package/dist/commands/crawl.js +249 -0
  16. package/dist/commands/doctor.d.ts +5 -0
  17. package/dist/commands/doctor.js +233 -0
  18. package/dist/commands/explain.d.ts +12 -0
  19. package/dist/commands/explain.js +233 -0
  20. package/dist/commands/fix.d.ts +13 -0
  21. package/dist/commands/fix.js +668 -0
  22. package/dist/commands/health.d.ts +11 -0
  23. package/dist/commands/health.js +367 -0
  24. package/dist/commands/history.d.ts +10 -0
  25. package/dist/commands/history.js +191 -0
  26. package/dist/commands/init.d.ts +9 -0
  27. package/dist/commands/init.js +164 -0
  28. package/dist/commands/learn.d.ts +8 -0
  29. package/dist/commands/learn.js +592 -0
  30. package/dist/commands/pr-check.d.ts +12 -0
  31. package/dist/commands/pr-check.js +270 -0
  32. package/dist/commands/report.d.ts +11 -0
  33. package/dist/commands/report.js +375 -0
  34. package/dist/commands/scan-storybook.d.ts +18 -0
  35. package/dist/commands/scan-storybook.js +402 -0
  36. package/dist/commands/scan.d.ts +25 -0
  37. package/dist/commands/scan.js +673 -0
  38. package/dist/commands/stats.d.ts +5 -0
  39. package/dist/commands/stats.js +137 -0
  40. package/dist/commands/tree.d.ts +12 -0
  41. package/dist/commands/tree.js +635 -0
  42. package/dist/commands/triage.d.ts +13 -0
  43. package/dist/commands/triage.js +327 -0
  44. package/dist/commands/watch.d.ts +17 -0
  45. package/dist/commands/watch.js +302 -0
  46. package/dist/types/index.d.ts +60 -0
  47. package/dist/types/index.js +4 -0
  48. package/dist/utils/baseline.d.ts +62 -0
  49. package/dist/utils/baseline.js +169 -0
  50. package/dist/utils/browser.d.ts +78 -0
  51. package/dist/utils/browser.js +239 -0
  52. package/dist/utils/cache.d.ts +76 -0
  53. package/dist/utils/cache.js +178 -0
  54. package/dist/utils/config.d.ts +102 -0
  55. package/dist/utils/config.js +237 -0
  56. package/dist/utils/converters.d.ts +77 -0
  57. package/dist/utils/converters.js +200 -0
  58. package/dist/utils/copilot.d.ts +36 -0
  59. package/dist/utils/copilot.js +139 -0
  60. package/dist/utils/detect.d.ts +22 -0
  61. package/dist/utils/detect.js +197 -0
  62. package/dist/utils/enhanced-errors.d.ts +46 -0
  63. package/dist/utils/enhanced-errors.js +295 -0
  64. package/dist/utils/errors.d.ts +31 -0
  65. package/dist/utils/errors.js +149 -0
  66. package/dist/utils/fix-patterns.d.ts +56 -0
  67. package/dist/utils/fix-patterns.js +529 -0
  68. package/dist/utils/history-tracking.d.ts +94 -0
  69. package/dist/utils/history-tracking.js +230 -0
  70. package/dist/utils/history.d.ts +42 -0
  71. package/dist/utils/history.js +255 -0
  72. package/dist/utils/impact-scores.d.ts +44 -0
  73. package/dist/utils/impact-scores.js +257 -0
  74. package/dist/utils/retry.d.ts +24 -0
  75. package/dist/utils/retry.js +76 -0
  76. package/dist/utils/scanner.d.ts +74 -0
  77. package/dist/utils/scanner.js +606 -0
  78. package/dist/utils/scanner.test.d.ts +4 -0
  79. package/dist/utils/scanner.test.js +162 -0
  80. package/dist/utils/ui.d.ts +44 -0
  81. package/dist/utils/ui.js +276 -0
  82. package/mcp-server/dist/index.d.ts +8 -0
  83. package/mcp-server/dist/index.js +1923 -0
  84. package/package.json +88 -0
@@ -0,0 +1,185 @@
1
+ /**
2
+ * ally completion command - Generate shell completion scripts
3
+ */
4
+ // All commands and their options for completion
5
+ const COMMANDS = {
6
+ scan: {
7
+ description: 'Scan files for accessibility violations',
8
+ options: ['-o', '--output', '-u', '--url', '-j', '--json', '-f', '--format', '-v', '--verbose', '-t', '--threshold', '--ci', '-F', '--fail-on', '-S', '--simulate', '-s', '--standard', '-T', '--timeout'],
9
+ },
10
+ explain: {
11
+ description: 'Get plain-language explanations of violations',
12
+ options: ['-i', '--input', '-s', '--severity', '-l', '--limit'],
13
+ },
14
+ fix: {
15
+ description: 'Fix accessibility issues using Copilot',
16
+ options: ['-i', '--input', '-s', '--severity', '-a', '--auto', '-d', '--dry-run'],
17
+ },
18
+ report: {
19
+ description: 'Generate accessibility report',
20
+ options: ['-i', '--input', '-o', '--output', '-f', '--format'],
21
+ },
22
+ init: {
23
+ description: 'Initialize ally in your project',
24
+ options: ['-f', '--force', '-H', '--hooks'],
25
+ },
26
+ stats: {
27
+ description: 'Show accessibility progress dashboard',
28
+ options: [],
29
+ },
30
+ badge: {
31
+ description: 'Generate accessibility score badge',
32
+ options: ['-i', '--input', '-f', '--format', '-o', '--output'],
33
+ },
34
+ watch: {
35
+ description: 'Watch for file changes and scan continuously',
36
+ options: ['-d', '--debounce', '--clear'],
37
+ },
38
+ learn: {
39
+ description: 'Learn about WCAG accessibility criteria',
40
+ options: ['-l', '--list'],
41
+ },
42
+ crawl: {
43
+ description: 'Crawl website and scan each page',
44
+ options: ['-d', '--depth', '-l', '--limit', '--same-origin', '--no-same-origin', '-o', '--output'],
45
+ },
46
+ tree: {
47
+ description: 'Display accessibility tree for a URL',
48
+ options: ['-d', '--depth', '-r', '--role', '-j', '--json'],
49
+ },
50
+ triage: {
51
+ description: 'Interactively categorize accessibility issues',
52
+ options: ['-i', '--input'],
53
+ },
54
+ 'pr-check': {
55
+ description: 'Post accessibility results to GitHub PR',
56
+ options: ['-i', '--input', '-p', '--pr', '--no-comment', '-F', '--fail-on'],
57
+ },
58
+ completion: {
59
+ description: 'Generate shell completion script',
60
+ options: [],
61
+ },
62
+ };
63
+ const commandNames = Object.keys(COMMANDS);
64
+ function generateBashCompletion() {
65
+ return `# Bash completion for ally
66
+ # Add to ~/.bashrc or ~/.bash_profile:
67
+ # eval "$(ally completion bash)"
68
+
69
+ _ally_completions() {
70
+ local cur prev commands
71
+ COMPREPLY=()
72
+ cur="\${COMP_WORDS[COMP_CWORD]}"
73
+ prev="\${COMP_WORDS[COMP_CWORD-1]}"
74
+
75
+ commands="${commandNames.join(' ')}"
76
+
77
+ # Complete commands
78
+ if [[ \${COMP_CWORD} -eq 1 ]]; then
79
+ COMPREPLY=( $(compgen -W "\${commands}" -- "\${cur}") )
80
+ return 0
81
+ fi
82
+
83
+ # Complete options based on command
84
+ case "\${COMP_WORDS[1]}" in
85
+ ${Object.entries(COMMANDS).map(([cmd, info]) => ` ${cmd})
86
+ COMPREPLY=( $(compgen -W "${info.options.join(' ')}" -- "\${cur}") )
87
+ ;;`).join('\n')}
88
+ esac
89
+
90
+ return 0
91
+ }
92
+
93
+ complete -F _ally_completions ally
94
+ `;
95
+ }
96
+ function generateZshCompletion() {
97
+ return `#compdef ally
98
+ # Zsh completion for ally
99
+ # Add to ~/.zshrc:
100
+ # eval "$(ally completion zsh)"
101
+
102
+ _ally() {
103
+ local -a commands
104
+ commands=(
105
+ ${Object.entries(COMMANDS).map(([cmd, info]) => ` '${cmd}:${info.description}'`).join('\n')}
106
+ )
107
+
108
+ _arguments -C \\
109
+ '1:command:->command' \\
110
+ '*::options:->options'
111
+
112
+ case $state in
113
+ command)
114
+ _describe 'command' commands
115
+ ;;
116
+ options)
117
+ case $words[1] in
118
+ ${Object.entries(COMMANDS).map(([cmd, info]) => ` ${cmd})
119
+ _arguments \\
120
+ ${info.options.filter(o => o.startsWith('--')).map(opt => ` '${opt}[${opt.replace('--', '')}]'`).join(' \\\n') || " '*:'"}
121
+ ;;`).join('\n')}
122
+ esac
123
+ ;;
124
+ esac
125
+ }
126
+
127
+ compdef _ally ally
128
+ `;
129
+ }
130
+ function generateFishCompletion() {
131
+ return `# Fish completion for ally
132
+ # Add to ~/.config/fish/completions/ally.fish:
133
+ # ally completion fish > ~/.config/fish/completions/ally.fish
134
+
135
+ # Disable file completions for ally
136
+ complete -c ally -f
137
+
138
+ # Commands
139
+ ${Object.entries(COMMANDS).map(([cmd, info]) => `complete -c ally -n "__fish_use_subcommand" -a "${cmd}" -d "${info.description}"`).join('\n')}
140
+
141
+ # Options for each command
142
+ ${Object.entries(COMMANDS).map(([cmd, info]) => info.options.filter(o => o.startsWith('--')).map(opt => `complete -c ally -n "__fish_seen_subcommand_from ${cmd}" -l "${opt.replace('--', '')}" -d "${opt.replace('--', '')}"`).join('\n')).filter(Boolean).join('\n')}
143
+ `;
144
+ }
145
+ export async function completionCommand(shell) {
146
+ const validShells = ['bash', 'zsh', 'fish'];
147
+ if (!shell) {
148
+ console.log(`Usage: ally completion <shell>
149
+
150
+ Generate shell completion scripts.
151
+
152
+ Supported shells:
153
+ bash Bash completion script
154
+ zsh Zsh completion script
155
+ fish Fish completion script
156
+
157
+ Examples:
158
+ # Bash - add to ~/.bashrc
159
+ eval "$(ally completion bash)"
160
+
161
+ # Zsh - add to ~/.zshrc
162
+ eval "$(ally completion zsh)"
163
+
164
+ # Fish - save to completions directory
165
+ ally completion fish > ~/.config/fish/completions/ally.fish
166
+ `);
167
+ return;
168
+ }
169
+ if (!validShells.includes(shell)) {
170
+ console.error(`Unknown shell: ${shell}`);
171
+ console.error(`Supported shells: ${validShells.join(', ')}`);
172
+ process.exit(1);
173
+ }
174
+ switch (shell) {
175
+ case 'bash':
176
+ console.log(generateBashCompletion());
177
+ break;
178
+ case 'zsh':
179
+ console.log(generateZshCompletion());
180
+ break;
181
+ case 'fish':
182
+ console.log(generateFishCompletion());
183
+ break;
184
+ }
185
+ }
@@ -0,0 +1,12 @@
1
+ /**
2
+ * ally crawl command - Crawls entire websites by following links
3
+ */
4
+ import type { AllyReport } from '../types/index.js';
5
+ interface CrawlCommandOptions {
6
+ depth?: number;
7
+ limit?: number;
8
+ sameOrigin?: boolean;
9
+ output?: string;
10
+ }
11
+ export declare function crawlCommand(startUrl: string, options?: CrawlCommandOptions): Promise<AllyReport | null>;
12
+ export default crawlCommand;
@@ -0,0 +1,249 @@
1
+ /**
2
+ * ally crawl command - Crawls entire websites by following links
3
+ */
4
+ import { resolve } from 'path';
5
+ import { mkdir, writeFile } from 'fs/promises';
6
+ import { existsSync } from 'fs';
7
+ import { AccessibilityScanner, createReport, calculateScore } from '../utils/scanner.js';
8
+ import { printBanner, createSpinner, printSummary, printSuccess, printError, printInfo, printWarning, } from '../utils/ui.js';
9
+ import { withRetry } from '../utils/retry.js';
10
+ import chalk from 'chalk';
11
+ /**
12
+ * Normalize URL by removing hash and trailing slash
13
+ */
14
+ function normalizeUrl(url) {
15
+ try {
16
+ const parsed = new URL(url);
17
+ // Remove hash
18
+ parsed.hash = '';
19
+ // Remove trailing slash (except for root)
20
+ let normalized = parsed.toString();
21
+ if (normalized.endsWith('/') && parsed.pathname !== '/') {
22
+ normalized = normalized.slice(0, -1);
23
+ }
24
+ return normalized;
25
+ }
26
+ catch {
27
+ return url;
28
+ }
29
+ }
30
+ /**
31
+ * Check if a URL is same-origin as the base URL
32
+ */
33
+ function isSameOrigin(baseUrl, targetUrl) {
34
+ try {
35
+ const base = new URL(baseUrl);
36
+ const target = new URL(targetUrl);
37
+ return base.origin === target.origin;
38
+ }
39
+ catch {
40
+ return false;
41
+ }
42
+ }
43
+ /**
44
+ * Check if a URL should be crawled (is a valid page URL)
45
+ */
46
+ function isValidPageUrl(url) {
47
+ try {
48
+ const parsed = new URL(url);
49
+ // Only HTTP/HTTPS
50
+ if (!['http:', 'https:'].includes(parsed.protocol)) {
51
+ return false;
52
+ }
53
+ // Block localhost and private IPs for security (except for explicit localhost scans)
54
+ const hostname = parsed.hostname.toLowerCase();
55
+ if (hostname !== 'localhost' && hostname !== '127.0.0.1') {
56
+ // Block private IP ranges (10.x, 172.16-31.x, 192.168.x)
57
+ const ipMatch = hostname.match(/^(\d+)\.(\d+)\.(\d+)\.(\d+)$/);
58
+ if (ipMatch) {
59
+ const [, a, b] = ipMatch.map(Number);
60
+ if (a === 10 || (a === 172 && b >= 16 && b <= 31) || (a === 192 && b === 168)) {
61
+ return false;
62
+ }
63
+ }
64
+ }
65
+ // Skip common non-page extensions
66
+ const skipExtensions = [
67
+ '.pdf', '.zip', '.tar', '.gz', '.rar',
68
+ '.jpg', '.jpeg', '.png', '.gif', '.svg', '.ico', '.webp',
69
+ '.mp3', '.mp4', '.avi', '.mov', '.wmv',
70
+ '.css', '.js', '.json', '.xml',
71
+ '.doc', '.docx', '.xls', '.xlsx', '.ppt', '.pptx',
72
+ ];
73
+ const pathname = parsed.pathname.toLowerCase();
74
+ for (const ext of skipExtensions) {
75
+ if (pathname.endsWith(ext)) {
76
+ return false;
77
+ }
78
+ }
79
+ return true;
80
+ }
81
+ catch {
82
+ return false;
83
+ }
84
+ }
85
+ /**
86
+ * Format a page result line for console output
87
+ */
88
+ function formatPageResult(progress) {
89
+ const { current, total, url, violations, score } = progress;
90
+ // Truncate URL if too long
91
+ const maxUrlLength = 50;
92
+ let displayUrl = url;
93
+ if (displayUrl.length > maxUrlLength) {
94
+ displayUrl = displayUrl.slice(0, maxUrlLength - 3) + '...';
95
+ }
96
+ // Color based on score
97
+ let scoreColor = chalk.green;
98
+ if (score < 50)
99
+ scoreColor = chalk.red;
100
+ else if (score < 75)
101
+ scoreColor = chalk.yellow;
102
+ const violationText = violations === 0
103
+ ? chalk.green('0 violations')
104
+ : violations === 1
105
+ ? chalk.yellow('1 violation')
106
+ : chalk.yellow(`${violations} violations`);
107
+ return `[${current}/${total}] ${displayUrl} - ${violationText} (score: ${scoreColor(score.toString())})`;
108
+ }
109
+ export async function crawlCommand(startUrl, options = {}) {
110
+ printBanner();
111
+ const { depth = 2, limit = 10, sameOrigin = true, output = '.ally', } = options;
112
+ // Validate URL
113
+ let normalizedStartUrl;
114
+ try {
115
+ normalizedStartUrl = normalizeUrl(startUrl);
116
+ new URL(normalizedStartUrl);
117
+ }
118
+ catch {
119
+ printError(`Invalid URL: ${startUrl}`);
120
+ return null;
121
+ }
122
+ console.log(chalk.cyan(`Crawling ${normalizedStartUrl} (depth: ${depth}, limit: ${limit})`));
123
+ console.log();
124
+ const scanner = new AccessibilityScanner();
125
+ const visited = new Set();
126
+ const toVisit = [{ url: normalizedStartUrl, depth: 0 }];
127
+ const results = [];
128
+ try {
129
+ await scanner.init();
130
+ while (toVisit.length > 0 && results.length < limit) {
131
+ const next = toVisit.shift();
132
+ if (!next)
133
+ break;
134
+ const { url, depth: currentDepth } = next;
135
+ const normalized = normalizeUrl(url);
136
+ // Skip if already visited
137
+ if (visited.has(normalized)) {
138
+ continue;
139
+ }
140
+ visited.add(normalized);
141
+ // Skip if not same origin and sameOrigin is enabled
142
+ if (sameOrigin && !isSameOrigin(normalizedStartUrl, normalized)) {
143
+ continue;
144
+ }
145
+ // Skip if not a valid page URL
146
+ if (!isValidPageUrl(normalized)) {
147
+ continue;
148
+ }
149
+ const pageNum = results.length + 1;
150
+ const spinner = createSpinner(`[${pageNum}/${limit}] Scanning ${normalized}...`);
151
+ spinner.start();
152
+ try {
153
+ // Scan the page for accessibility issues with retry for transient errors
154
+ const result = await withRetry(() => scanner.scanUrl(normalized), {
155
+ maxRetries: 3,
156
+ baseDelayMs: 1000,
157
+ onRetry: (attempt, error, delayMs) => {
158
+ spinner.stop();
159
+ printWarning(`Retry ${attempt}/3 for ${normalized} after ${delayMs / 1000}s (${error.message})`);
160
+ spinner.start();
161
+ },
162
+ });
163
+ results.push(result);
164
+ // Calculate score for this single result
165
+ const pageScore = calculateScore([result]);
166
+ spinner.stop();
167
+ console.log(formatPageResult({
168
+ current: pageNum,
169
+ total: limit,
170
+ url: normalized,
171
+ violations: result.violations.length,
172
+ score: pageScore,
173
+ }));
174
+ // Extract links if we haven't reached max depth
175
+ if (currentDepth < depth) {
176
+ const links = await scanner.extractLinks(normalized);
177
+ // Add new links to the queue
178
+ for (const link of links) {
179
+ const normalizedLink = normalizeUrl(link);
180
+ if (!visited.has(normalizedLink) && isValidPageUrl(normalizedLink)) {
181
+ if (!sameOrigin || isSameOrigin(normalizedStartUrl, normalizedLink)) {
182
+ // Check if already in queue
183
+ const alreadyQueued = toVisit.some(item => normalizeUrl(item.url) === normalizedLink);
184
+ if (!alreadyQueued) {
185
+ toVisit.push({ url: normalizedLink, depth: currentDepth + 1 });
186
+ }
187
+ }
188
+ }
189
+ }
190
+ }
191
+ }
192
+ catch (error) {
193
+ spinner.fail(`Failed to scan ${normalized}`);
194
+ printError(error instanceof Error ? error.message : String(error));
195
+ }
196
+ }
197
+ console.log();
198
+ if (results.length === 0) {
199
+ printError('No pages were successfully scanned');
200
+ return null;
201
+ }
202
+ // Create combined report
203
+ const report = createReport(results);
204
+ // Print crawl summary
205
+ console.log(chalk.bold(`Crawl complete: ${results.length} page${results.length === 1 ? '' : 's'} scanned`));
206
+ // Calculate totals
207
+ const totalViolations = results.reduce((sum, r) => sum + r.violations.length, 0);
208
+ const avgScore = Math.round(results.reduce((sum, r) => sum + calculateScore([r]), 0) / results.length);
209
+ console.log(chalk.dim(`Total violations: ${totalViolations}`));
210
+ console.log(chalk.dim(`Average score: ${avgScore}`));
211
+ console.log();
212
+ // Print full summary
213
+ printSummary(report.summary);
214
+ // Save report
215
+ await saveReport(report, output, normalizedStartUrl);
216
+ return report;
217
+ }
218
+ catch (error) {
219
+ printError(`Crawl failed: ${error instanceof Error ? error.message : String(error)}`);
220
+ return null;
221
+ }
222
+ finally {
223
+ await scanner.close();
224
+ }
225
+ }
226
+ async function saveReport(report, outputDir, startUrl) {
227
+ try {
228
+ // Ensure output directory exists
229
+ if (!existsSync(outputDir)) {
230
+ await mkdir(outputDir, { recursive: true });
231
+ }
232
+ // Generate filename from URL
233
+ const urlObj = new URL(startUrl);
234
+ const safeHost = urlObj.hostname.replace(/[^a-z0-9]/gi, '-');
235
+ const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
236
+ // Save crawl report
237
+ const reportPath = resolve(outputDir, `crawl-${safeHost}-${timestamp}.json`);
238
+ await writeFile(reportPath, JSON.stringify(report, null, 2));
239
+ printSuccess(`Crawl report saved to ${reportPath}`);
240
+ // Also save as the default scan.json for compatibility with other commands
241
+ const defaultPath = resolve(outputDir, 'scan.json');
242
+ await writeFile(defaultPath, JSON.stringify(report, null, 2));
243
+ printInfo(`Also saved to ${defaultPath} for use with other ally commands`);
244
+ }
245
+ catch (error) {
246
+ printError(`Failed to save report: ${error instanceof Error ? error.message : String(error)}`);
247
+ }
248
+ }
249
+ export default crawlCommand;
@@ -0,0 +1,5 @@
1
+ /**
2
+ * ally doctor command - Diagnoses installation and configuration issues
3
+ */
4
+ export declare function doctorCommand(): Promise<void>;
5
+ export default doctorCommand;
@@ -0,0 +1,233 @@
1
+ /**
2
+ * ally doctor command - Diagnoses installation and configuration issues
3
+ */
4
+ import { existsSync, readFileSync } from 'fs';
5
+ import { resolve, join, dirname } from 'path';
6
+ import { execSync } from 'child_process';
7
+ import { fileURLToPath } from 'url';
8
+ import { createRequire } from 'module';
9
+ import chalk from 'chalk';
10
+ import { printBanner } from '../utils/ui.js';
11
+ // ESM equivalent of __dirname
12
+ const __filename = fileURLToPath(import.meta.url);
13
+ const __dirname = dirname(__filename);
14
+ /**
15
+ * Get the installed Node.js version
16
+ */
17
+ function getNodeVersion() {
18
+ return process.version.replace('v', '');
19
+ }
20
+ /**
21
+ * Parse a semver string and return major version number
22
+ */
23
+ function parseMajorVersion(version) {
24
+ const match = version.match(/^(\d+)/);
25
+ return match ? parseInt(match[1], 10) : 0;
26
+ }
27
+ /**
28
+ * Check if a command exists
29
+ */
30
+ function commandExists(command) {
31
+ try {
32
+ execSync(`which ${command}`, { stdio: 'ignore' });
33
+ return true;
34
+ }
35
+ catch {
36
+ return false;
37
+ }
38
+ }
39
+ /**
40
+ * Get ally version from package.json
41
+ */
42
+ function getAllyVersion() {
43
+ try {
44
+ // Use createRequire to read package.json (same approach as cli.ts)
45
+ const require = createRequire(import.meta.url);
46
+ const { version } = require('../../package.json');
47
+ return version || 'unknown';
48
+ }
49
+ catch {
50
+ // Fallback: try to get from local package.json
51
+ try {
52
+ const localPkgPath = resolve(process.cwd(), 'package.json');
53
+ if (existsSync(localPkgPath)) {
54
+ const pkg = JSON.parse(readFileSync(localPkgPath, 'utf-8'));
55
+ if (pkg.name === 'ally-a11y') {
56
+ return pkg.version || 'unknown';
57
+ }
58
+ }
59
+ }
60
+ catch {
61
+ // ignore
62
+ }
63
+ }
64
+ return 'unknown';
65
+ }
66
+ /**
67
+ * Check if Puppeteer browser is installed
68
+ */
69
+ async function checkPuppeteerBrowser() {
70
+ try {
71
+ // Try to import puppeteer and check for browser
72
+ const puppeteer = await import('puppeteer');
73
+ const browser = await puppeteer.default.launch({
74
+ headless: true,
75
+ args: ['--no-sandbox', '--disable-setuid-sandbox'],
76
+ });
77
+ await browser.close();
78
+ return { status: 'pass', message: 'Puppeteer browser installed' };
79
+ }
80
+ catch (error) {
81
+ const errorMsg = error instanceof Error ? error.message : String(error);
82
+ if (errorMsg.includes('Could not find Chrome') || errorMsg.includes('Could not find Chromium')) {
83
+ return { status: 'fail', message: 'Puppeteer browser not installed. Run: npx puppeteer browsers install chrome' };
84
+ }
85
+ return { status: 'fail', message: `Puppeteer browser check failed: ${errorMsg}` };
86
+ }
87
+ }
88
+ /**
89
+ * Check if MCP server is configured
90
+ */
91
+ function checkMcpServer() {
92
+ const cwd = process.cwd();
93
+ // Check for mcp-server in node_modules (installed package)
94
+ const nodeModulesPath = join(cwd, 'node_modules', 'ally-a11y', 'mcp-server', 'dist', 'index.js');
95
+ if (existsSync(nodeModulesPath)) {
96
+ return { status: 'pass', message: 'MCP server configured (via node_modules)' };
97
+ }
98
+ // Check for local mcp-server (development)
99
+ const localPath = join(cwd, 'mcp-server', 'dist', 'index.js');
100
+ if (existsSync(localPath)) {
101
+ return { status: 'pass', message: 'MCP server configured (local development)' };
102
+ }
103
+ // Check if .copilot/mcp-config.json exists
104
+ const mcpConfigPath = join(cwd, '.copilot', 'mcp-config.json');
105
+ if (existsSync(mcpConfigPath)) {
106
+ try {
107
+ const config = JSON.parse(readFileSync(mcpConfigPath, 'utf-8'));
108
+ if (config.mcpServers && config.mcpServers['ally-patterns']) {
109
+ return { status: 'warn', message: 'MCP config exists but server files not found. Run: npm run build:all' };
110
+ }
111
+ }
112
+ catch {
113
+ return { status: 'warn', message: 'MCP config file invalid' };
114
+ }
115
+ }
116
+ return { status: 'info', message: 'MCP server not configured. Run: ally init' };
117
+ }
118
+ /**
119
+ * Check if .allyrc.json exists and is valid
120
+ */
121
+ function checkAllyConfig() {
122
+ const cwd = process.cwd();
123
+ const configPath = join(cwd, '.allyrc.json');
124
+ if (!existsSync(configPath)) {
125
+ return { status: 'info', message: '.allyrc.json not found (using defaults)' };
126
+ }
127
+ try {
128
+ const content = readFileSync(configPath, 'utf-8');
129
+ JSON.parse(content);
130
+ return { status: 'pass', message: '.allyrc.json valid' };
131
+ }
132
+ catch (error) {
133
+ const errorMsg = error instanceof Error ? error.message : String(error);
134
+ return { status: 'fail', message: `.allyrc.json invalid JSON: ${errorMsg}` };
135
+ }
136
+ }
137
+ /**
138
+ * Check if .allyignore exists
139
+ */
140
+ function checkAllyIgnore() {
141
+ const cwd = process.cwd();
142
+ const ignorePath = join(cwd, '.allyignore');
143
+ if (existsSync(ignorePath)) {
144
+ return { status: 'pass', message: '.allyignore found' };
145
+ }
146
+ return { status: 'info', message: '.allyignore not found (optional)' };
147
+ }
148
+ /**
149
+ * Print a check result with appropriate styling
150
+ */
151
+ function printCheck(result) {
152
+ const icons = {
153
+ pass: chalk.green(' '),
154
+ warn: chalk.yellow(' '),
155
+ fail: chalk.red(' '),
156
+ info: chalk.blue(' '),
157
+ };
158
+ const colors = {
159
+ pass: chalk.green,
160
+ warn: chalk.yellow,
161
+ fail: chalk.red,
162
+ info: chalk.blue,
163
+ };
164
+ console.log(`${icons[result.status]} ${colors[result.status](result.message)}`);
165
+ }
166
+ export async function doctorCommand() {
167
+ printBanner();
168
+ console.log(chalk.bold.cyan('Checking ally installation...\n'));
169
+ const results = [];
170
+ let hasFailures = false;
171
+ // 1. Check Node.js version
172
+ const nodeVersion = getNodeVersion();
173
+ const nodeMajor = parseMajorVersion(nodeVersion);
174
+ if (nodeMajor >= 18) {
175
+ results.push({ status: 'pass', message: `Node.js ${nodeVersion} (>=18 required)` });
176
+ }
177
+ else {
178
+ results.push({ status: 'fail', message: `Node.js ${nodeVersion} (>=18 required)` });
179
+ hasFailures = true;
180
+ }
181
+ // 2. Check ally version
182
+ const allyVersion = getAllyVersion();
183
+ if (allyVersion !== 'unknown') {
184
+ results.push({ status: 'pass', message: `ally v${allyVersion} installed` });
185
+ }
186
+ else {
187
+ results.push({ status: 'warn', message: 'ally version unknown' });
188
+ }
189
+ // 3. Check Puppeteer browser
190
+ const puppeteerResult = await checkPuppeteerBrowser();
191
+ results.push(puppeteerResult);
192
+ if (puppeteerResult.status === 'fail') {
193
+ hasFailures = true;
194
+ }
195
+ // 4. Check GitHub Copilot CLI
196
+ if (commandExists('copilot')) {
197
+ results.push({ status: 'pass', message: 'GitHub Copilot CLI available' });
198
+ }
199
+ else if (commandExists('gh') && commandExists('gh-copilot')) {
200
+ results.push({ status: 'pass', message: 'GitHub Copilot CLI available (gh extension)' });
201
+ }
202
+ else {
203
+ results.push({ status: 'warn', message: 'GitHub Copilot CLI not found (optional)' });
204
+ }
205
+ // 5. Check MCP server
206
+ const mcpResult = checkMcpServer();
207
+ results.push(mcpResult);
208
+ if (mcpResult.status === 'fail') {
209
+ hasFailures = true;
210
+ }
211
+ // 6. Check .allyrc.json
212
+ const configResult = checkAllyConfig();
213
+ results.push(configResult);
214
+ if (configResult.status === 'fail') {
215
+ hasFailures = true;
216
+ }
217
+ // 7. Check .allyignore
218
+ const ignoreResult = checkAllyIgnore();
219
+ results.push(ignoreResult);
220
+ // Print all results
221
+ for (const result of results) {
222
+ printCheck(result);
223
+ }
224
+ // Print summary
225
+ console.log();
226
+ if (hasFailures) {
227
+ console.log(chalk.red.bold('Some checks failed. Please fix the issues above.'));
228
+ }
229
+ else {
230
+ console.log(chalk.green.bold('All checks passed! ally is ready to use.'));
231
+ }
232
+ }
233
+ export default doctorCommand;