@wcag-audit/cli 1.0.0-alpha.11

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 (79) hide show
  1. package/LICENSE +25 -0
  2. package/README.md +110 -0
  3. package/package.json +73 -0
  4. package/patches/@guidepup+guidepup+0.24.1.patch +30 -0
  5. package/src/__tests__/sanity.test.js +7 -0
  6. package/src/ai-fix-json.js +321 -0
  7. package/src/audit.js +199 -0
  8. package/src/cache/route-cache.js +46 -0
  9. package/src/cache/route-cache.test.js +96 -0
  10. package/src/checkers/ai-vision.js +102 -0
  11. package/src/checkers/auth.js +111 -0
  12. package/src/checkers/axe.js +65 -0
  13. package/src/checkers/consistency.js +222 -0
  14. package/src/checkers/forms.js +149 -0
  15. package/src/checkers/interaction.js +142 -0
  16. package/src/checkers/keyboard.js +351 -0
  17. package/src/checkers/media.js +102 -0
  18. package/src/checkers/motion.js +155 -0
  19. package/src/checkers/pointer.js +128 -0
  20. package/src/checkers/screen-reader.js +522 -0
  21. package/src/checkers/util/consistency-match.js +53 -0
  22. package/src/checkers/util/consistency-match.test.js +54 -0
  23. package/src/checkers/viewport.js +214 -0
  24. package/src/cli.js +169 -0
  25. package/src/commands/ci.js +63 -0
  26. package/src/commands/ci.test.js +55 -0
  27. package/src/commands/doctor.js +105 -0
  28. package/src/commands/doctor.test.js +81 -0
  29. package/src/commands/init.js +162 -0
  30. package/src/commands/init.test.js +83 -0
  31. package/src/commands/scan.js +362 -0
  32. package/src/commands/scan.test.js +139 -0
  33. package/src/commands/watch.js +89 -0
  34. package/src/config/global.js +60 -0
  35. package/src/config/global.test.js +58 -0
  36. package/src/config/project.js +35 -0
  37. package/src/config/project.test.js +44 -0
  38. package/src/devserver/spawn.js +82 -0
  39. package/src/devserver/spawn.test.js +58 -0
  40. package/src/discovery/astro.js +86 -0
  41. package/src/discovery/astro.test.js +76 -0
  42. package/src/discovery/crawl.js +93 -0
  43. package/src/discovery/crawl.test.js +93 -0
  44. package/src/discovery/dynamic-samples.js +44 -0
  45. package/src/discovery/dynamic-samples.test.js +66 -0
  46. package/src/discovery/manual.js +38 -0
  47. package/src/discovery/manual.test.js +52 -0
  48. package/src/discovery/nextjs.js +136 -0
  49. package/src/discovery/nextjs.test.js +141 -0
  50. package/src/discovery/registry.js +80 -0
  51. package/src/discovery/registry.test.js +33 -0
  52. package/src/discovery/remix.js +82 -0
  53. package/src/discovery/remix.test.js +77 -0
  54. package/src/discovery/sitemap.js +73 -0
  55. package/src/discovery/sitemap.test.js +69 -0
  56. package/src/discovery/sveltekit.js +85 -0
  57. package/src/discovery/sveltekit.test.js +76 -0
  58. package/src/discovery/vite.js +94 -0
  59. package/src/discovery/vite.test.js +144 -0
  60. package/src/license/log-usage.js +23 -0
  61. package/src/license/log-usage.test.js +45 -0
  62. package/src/license/request-free.js +46 -0
  63. package/src/license/request-free.test.js +57 -0
  64. package/src/license/validate.js +58 -0
  65. package/src/license/validate.test.js +58 -0
  66. package/src/output/agents-md.js +58 -0
  67. package/src/output/agents-md.test.js +62 -0
  68. package/src/output/cursor-rules.js +57 -0
  69. package/src/output/cursor-rules.test.js +62 -0
  70. package/src/output/excel-project.js +263 -0
  71. package/src/output/excel-project.test.js +165 -0
  72. package/src/output/markdown.js +119 -0
  73. package/src/output/markdown.test.js +95 -0
  74. package/src/report.js +239 -0
  75. package/src/util/anthropic.js +25 -0
  76. package/src/util/llm.js +159 -0
  77. package/src/util/screenshot.js +131 -0
  78. package/src/wcag-criteria.js +256 -0
  79. package/src/wcag-manual-steps.js +114 -0
@@ -0,0 +1,105 @@
1
+ import { readFile } from "fs/promises";
2
+ import { join } from "path";
3
+ import { randomUUID } from "crypto";
4
+ import { readGlobalConfig } from "../config/global.js";
5
+ import { validateLicense } from "../license/validate.js";
6
+ import { detectFramework } from "../discovery/registry.js";
7
+
8
+ const CLI_VERSION = "1.0.0-alpha.11";
9
+
10
+ export async function runDoctor({ cwd = process.cwd(), log = console.log } = {}) {
11
+ const checks = [];
12
+
13
+ // ── License ──────────────────────────────────────────────────────
14
+ const globalCfg = await readGlobalConfig();
15
+ if (!globalCfg.licenseKey) {
16
+ checks.push({
17
+ name: "license",
18
+ status: "fail",
19
+ message: "No license key found in ~/.wcagauditrc. Run `npx wcag-audit init` to set one.",
20
+ });
21
+ } else {
22
+ const license = await validateLicense(globalCfg.licenseKey, {
23
+ machineId: randomUUID(),
24
+ source: "cli",
25
+ version: CLI_VERSION,
26
+ });
27
+ if (!license.valid) {
28
+ checks.push({
29
+ name: "license",
30
+ status: "fail",
31
+ message: `License validation failed: ${license.error}`,
32
+ });
33
+ } else {
34
+ const credits = license.creditsRemaining?.total ?? "unlimited";
35
+ checks.push({
36
+ name: "license",
37
+ status: "pass",
38
+ message: `${license.tier} plan, ${credits} credits remaining`,
39
+ });
40
+ }
41
+ }
42
+
43
+ // ── AI key (optional) ───────────────────────────────────────────
44
+ if (globalCfg.ai?.enabled) {
45
+ if (!globalCfg.ai.apiKey) {
46
+ checks.push({
47
+ name: "ai-key",
48
+ status: "fail",
49
+ message: "AI review is enabled but no API key is configured. Re-run `wcag-audit init`.",
50
+ });
51
+ } else {
52
+ checks.push({
53
+ name: "ai-key",
54
+ status: "pass",
55
+ message: `${globalCfg.ai.provider} key present`,
56
+ });
57
+ }
58
+ }
59
+
60
+ // ── Framework ────────────────────────────────────────────────────
61
+ const det = await detectFramework(cwd);
62
+ if (!det) {
63
+ checks.push({
64
+ name: "framework",
65
+ status: "fail",
66
+ message:
67
+ "Could not detect a supported framework. Supported: Next.js, Vite+React Router, SvelteKit, Remix, Astro. Use --url or --routes for deployed sites / custom setups.",
68
+ });
69
+ } else {
70
+ checks.push({
71
+ name: "framework",
72
+ status: "pass",
73
+ message: `${det.framework} detected (${det.strategy})`,
74
+ });
75
+ }
76
+
77
+ // ── Dev script ───────────────────────────────────────────────────
78
+ try {
79
+ const pkg = JSON.parse(await readFile(join(cwd, "package.json"), "utf8"));
80
+ const hasDev = pkg.scripts && (pkg.scripts.dev || pkg.scripts.start);
81
+ checks.push({
82
+ name: "dev-script",
83
+ status: hasDev ? "pass" : "fail",
84
+ message: hasDev
85
+ ? `\`${pkg.scripts.dev ? "npm run dev" : "npm run start"}\` will be used to start the dev server`
86
+ : "No `dev` or `start` script in package.json. Add one or set devServer.command in .wcagauditrc.",
87
+ });
88
+ } catch {
89
+ // package.json doesn't exist — already failed on framework check
90
+ }
91
+
92
+ // ── Print results ────────────────────────────────────────────────
93
+ log("");
94
+ log("wcag-audit doctor");
95
+ log("─────────────────────────────");
96
+ for (const c of checks) {
97
+ const icon = c.status === "pass" ? "✓" : "✗";
98
+ log(`${icon} ${c.name.padEnd(14)} ${c.message}`);
99
+ }
100
+ log("");
101
+ const failing = checks.filter((c) => c.status === "fail");
102
+ const ok = failing.length === 0;
103
+ log(ok ? "All checks passed." : `${failing.length} check(s) failed. Fix these before running scan.`);
104
+ return { ok, checks };
105
+ }
@@ -0,0 +1,81 @@
1
+ import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
2
+ import { mkdtemp, rm, writeFile } from "fs/promises";
3
+ import { tmpdir } from "os";
4
+ import { join } from "path";
5
+ import { runDoctor } from "./doctor.js";
6
+
7
+ let tmpHome;
8
+ let projDir;
9
+
10
+ beforeEach(async () => {
11
+ tmpHome = await mkdtemp(join(tmpdir(), "wcagdoc-home-"));
12
+ projDir = await mkdtemp(join(tmpdir(), "wcagdoc-proj-"));
13
+ process.env.HOME = tmpHome;
14
+ global.fetch = vi.fn();
15
+ });
16
+
17
+ afterEach(async () => {
18
+ await rm(tmpHome, { recursive: true, force: true });
19
+ await rm(projDir, { recursive: true, force: true });
20
+ vi.restoreAllMocks();
21
+ });
22
+
23
+ describe("runDoctor", () => {
24
+ it("reports missing license when no global config exists", async () => {
25
+ const logs = [];
26
+ const result = await runDoctor({ cwd: projDir, log: (m) => logs.push(m) });
27
+ expect(result.ok).toBe(false);
28
+ expect(result.checks.find((c) => c.name === "license")?.status).toBe("fail");
29
+ const joined = logs.join("\n");
30
+ expect(joined).toMatch(/✗ license/);
31
+ expect(joined).toMatch(/wcag-audit init/);
32
+ });
33
+
34
+ it("marks license pass when config + validation succeed", async () => {
35
+ await writeFile(
36
+ join(tmpHome, ".wcagauditrc"),
37
+ JSON.stringify({
38
+ licenseKey: "WCAG-TEST-AAAA-BBBB-CCCC",
39
+ ai: { enabled: false, provider: "anthropic", apiKey: null },
40
+ }),
41
+ "utf8",
42
+ );
43
+ global.fetch.mockResolvedValue({
44
+ ok: true,
45
+ json: async () => ({ valid: true, tier: "pro", creditsRemaining: { total: 100 } }),
46
+ });
47
+ const result = await runDoctor({ cwd: projDir, log: () => {} });
48
+ expect(result.checks.find((c) => c.name === "license")?.status).toBe("pass");
49
+ });
50
+
51
+ it("reports missing framework when cwd is not a Next.js project", async () => {
52
+ await writeFile(
53
+ join(tmpHome, ".wcagauditrc"),
54
+ JSON.stringify({ licenseKey: "WCAG-TEST-AAAA-BBBB-CCCC", ai: { enabled: false } }),
55
+ "utf8",
56
+ );
57
+ global.fetch.mockResolvedValue({ ok: true, json: async () => ({ valid: true, tier: "pro" }) });
58
+ const result = await runDoctor({ cwd: projDir, log: () => {} });
59
+ expect(result.checks.find((c) => c.name === "framework")?.status).toBe("fail");
60
+ });
61
+
62
+ it("reports framework pass when cwd is a Next.js app", async () => {
63
+ await writeFile(
64
+ join(tmpHome, ".wcagauditrc"),
65
+ JSON.stringify({ licenseKey: "WCAG-TEST-AAAA-BBBB-CCCC", ai: { enabled: false } }),
66
+ "utf8",
67
+ );
68
+ await writeFile(
69
+ join(projDir, "package.json"),
70
+ JSON.stringify({ dependencies: { next: "15.0.0" }, scripts: { dev: "next dev" } }),
71
+ "utf8",
72
+ );
73
+ const { mkdir } = await import("fs/promises");
74
+ await mkdir(join(projDir, "app"), { recursive: true });
75
+ await writeFile(join(projDir, "app", "page.tsx"), "// test", "utf8");
76
+ global.fetch.mockResolvedValue({ ok: true, json: async () => ({ valid: true, tier: "pro" }) });
77
+ const result = await runDoctor({ cwd: projDir, log: () => {} });
78
+ expect(result.checks.find((c) => c.name === "framework")?.status).toBe("pass");
79
+ expect(result.checks.find((c) => c.name === "dev-script")?.status).toBe("pass");
80
+ });
81
+ });
@@ -0,0 +1,162 @@
1
+ import enquirer from "enquirer";
2
+ import { randomUUID } from "crypto";
3
+ import { validateLicense } from "../license/validate.js";
4
+ import { requestFreeLicense } from "../license/request-free.js";
5
+ import { writeGlobalConfig } from "../config/global.js";
6
+
7
+ const CLI_VERSION = "1.0.0-alpha.11";
8
+
9
+ // runInit can be called two ways:
10
+ // 1. Interactive (no answers) — uses enquirer to prompt
11
+ // 2. Non-interactive (answers provided) — used by tests and CI
12
+ export async function runInit({ answers, log = console.log } = {}) {
13
+ log("");
14
+ log("Welcome to WCAG Audit CLI");
15
+ log("");
16
+
17
+ const resolved = answers || (await promptUser({ log }));
18
+
19
+ // 1. Validate license
20
+ const machineId = randomUUID();
21
+ const license = await validateLicense(resolved.licenseKey, {
22
+ machineId,
23
+ source: "cli",
24
+ version: CLI_VERSION,
25
+ });
26
+
27
+ if (!license.valid) {
28
+ log(`✗ License check failed: ${license.error}`);
29
+ return { ok: false, error: license.error };
30
+ }
31
+
32
+ const credits = license.creditsRemaining?.total ?? "unlimited";
33
+ log(`✓ License valid (${license.tier} plan, ${credits} credits remaining)`);
34
+
35
+ // 2. Write config
36
+ const aiConfig = resolved.enableAi
37
+ ? {
38
+ enabled: true,
39
+ provider: resolved.aiProvider,
40
+ apiKey: resolved.aiApiKey,
41
+ model: defaultModelFor(resolved.aiProvider),
42
+ groups: resolved.aiGroups || ["visual", "structure", "language"],
43
+ }
44
+ : {
45
+ enabled: false,
46
+ provider: "anthropic",
47
+ apiKey: null,
48
+ model: null,
49
+ groups: [],
50
+ };
51
+
52
+ await writeGlobalConfig({
53
+ licenseKey: resolved.licenseKey,
54
+ ai: aiConfig,
55
+ });
56
+
57
+ log("✓ Config saved to ~/.wcagauditrc (chmod 600)");
58
+ log(" Run `npx wcag-audit scan` in your project directory to start.");
59
+ log("");
60
+
61
+ return { ok: true, tier: license.tier };
62
+ }
63
+
64
+ function defaultModelFor(provider) {
65
+ return {
66
+ anthropic: "claude-sonnet-4-6",
67
+ openai: "gpt-4.1-mini",
68
+ google: "gemini-2.5-flash",
69
+ }[provider] || "claude-sonnet-4-6";
70
+ }
71
+
72
+ async function promptUser({ log = console.log } = {}) {
73
+ // Step 1: Has a key? Or request free?
74
+ const { hasKey } = await enquirer.prompt([
75
+ {
76
+ type: "confirm",
77
+ name: "hasKey",
78
+ message: "Do you already have a license key?",
79
+ initial: true,
80
+ },
81
+ ]);
82
+
83
+ if (!hasKey) {
84
+ // Free-tier flow: ask for email, request a free key, wait for them
85
+ // to paste it from their inbox.
86
+ const { email } = await enquirer.prompt([
87
+ {
88
+ type: "input",
89
+ name: "email",
90
+ message: "Enter your email for a free license (1 audit/day):",
91
+ validate: (v) => (/@.+\./.test(v) ? true : "Enter a valid email"),
92
+ },
93
+ ]);
94
+ log("");
95
+ log(`Requesting free license for ${email}...`);
96
+ const machineId = randomUUID();
97
+ const result = await requestFreeLicense({ email, machineId });
98
+ if (!result.ok) {
99
+ log(`✗ ${result.error}`);
100
+ log(" You can try again or skip and use --help to paste a key later.");
101
+ throw new Error(result.error);
102
+ }
103
+ log(`✓ ${result.message}`);
104
+ log("");
105
+ }
106
+
107
+ // Step 2: Paste the key (works for both free and paid)
108
+ const answers = await enquirer.prompt([
109
+ {
110
+ type: "input",
111
+ name: "licenseKey",
112
+ message: "Paste your WCAG Audit license key:",
113
+ validate(value) {
114
+ if (!/^WCAG-[A-Z0-9]{4}-[A-Z0-9]{4}-[A-Z0-9]{4}-[A-Z0-9]{4}$/i.test(value?.trim())) {
115
+ return "Expected format: WCAG-XXXX-XXXX-XXXX-XXXX";
116
+ }
117
+ return true;
118
+ },
119
+ result: (v) => v.trim().toUpperCase(),
120
+ },
121
+ {
122
+ type: "confirm",
123
+ name: "enableAi",
124
+ message: "Enable AI vision review? (uses your own LLM API key)",
125
+ initial: true,
126
+ },
127
+ ]);
128
+
129
+ if (!answers.enableAi) return answers;
130
+
131
+ const aiAnswers = await enquirer.prompt([
132
+ {
133
+ type: "select",
134
+ name: "aiProvider",
135
+ message: "Choose AI provider:",
136
+ choices: [
137
+ { name: "anthropic", message: "Anthropic Claude (recommended)" },
138
+ { name: "openai", message: "OpenAI GPT" },
139
+ { name: "google", message: "Google Gemini" },
140
+ ],
141
+ },
142
+ {
143
+ type: "password",
144
+ name: "aiApiKey",
145
+ message: "Paste your API key:",
146
+ validate: (v) => (v && v.length > 10 ? true : "API key seems too short"),
147
+ },
148
+ {
149
+ type: "multiselect",
150
+ name: "aiGroups",
151
+ message: "Which AI review groups to enable?",
152
+ choices: [
153
+ { name: "visual", message: "Visual quality (color, layout, hierarchy)" },
154
+ { name: "structure", message: "Structure & navigation" },
155
+ { name: "language", message: "Reading & language" },
156
+ ],
157
+ initial: [0, 1, 2],
158
+ },
159
+ ]);
160
+
161
+ return { ...answers, ...aiAnswers };
162
+ }
@@ -0,0 +1,83 @@
1
+ import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
2
+ import { mkdtemp, rm, readFile } from "fs/promises";
3
+ import { tmpdir } from "os";
4
+ import { join } from "path";
5
+ import { runInit } from "./init.js";
6
+
7
+ let tmpHome;
8
+
9
+ beforeEach(async () => {
10
+ tmpHome = await mkdtemp(join(tmpdir(), "wcaginit-"));
11
+ process.env.HOME = tmpHome;
12
+ global.fetch = vi.fn();
13
+ });
14
+
15
+ afterEach(async () => {
16
+ await rm(tmpHome, { recursive: true, force: true });
17
+ vi.restoreAllMocks();
18
+ });
19
+
20
+ describe("runInit", () => {
21
+ it("writes licenseKey + AI config when validation passes", async () => {
22
+ global.fetch.mockResolvedValueOnce({
23
+ ok: true,
24
+ json: async () => ({ valid: true, tier: "pro", creditsRemaining: { total: 100 } }),
25
+ });
26
+ const logs = [];
27
+ const result = await runInit({
28
+ answers: {
29
+ licenseKey: "WCAG-TEST-AAAA-BBBB-CCCC",
30
+ enableAi: true,
31
+ aiProvider: "anthropic",
32
+ aiApiKey: "sk-ant-x",
33
+ aiGroups: ["visual", "structure"],
34
+ },
35
+ log: (msg) => logs.push(msg),
36
+ });
37
+ expect(result.ok).toBe(true);
38
+ const raw = await readFile(join(tmpHome, ".wcagauditrc"), "utf8");
39
+ const cfg = JSON.parse(raw);
40
+ expect(cfg.licenseKey).toBe("WCAG-TEST-AAAA-BBBB-CCCC");
41
+ expect(cfg.ai.enabled).toBe(true);
42
+ expect(cfg.ai.provider).toBe("anthropic");
43
+ expect(cfg.ai.apiKey).toBe("sk-ant-x");
44
+ expect(cfg.ai.groups).toEqual(["visual", "structure"]);
45
+ expect(logs.join("\n")).toMatch(/Config saved/);
46
+ });
47
+
48
+ it("rejects and exits when license validation fails", async () => {
49
+ global.fetch.mockResolvedValueOnce({
50
+ ok: false,
51
+ status: 403,
52
+ json: async () => ({ valid: false, error: "License revoked" }),
53
+ });
54
+ const result = await runInit({
55
+ answers: {
56
+ licenseKey: "WCAG-TEST-AAAA-BBBB-CCCC",
57
+ enableAi: false,
58
+ },
59
+ log: () => {},
60
+ });
61
+ expect(result.ok).toBe(false);
62
+ expect(result.error).toMatch(/revoked/i);
63
+ });
64
+
65
+ it("does not write AI config when enableAi is false", async () => {
66
+ global.fetch.mockResolvedValueOnce({
67
+ ok: true,
68
+ json: async () => ({ valid: true, tier: "pro", creditsRemaining: { total: 100 } }),
69
+ });
70
+ const result = await runInit({
71
+ answers: {
72
+ licenseKey: "WCAG-TEST-AAAA-BBBB-CCCC",
73
+ enableAi: false,
74
+ },
75
+ log: () => {},
76
+ });
77
+ expect(result.ok).toBe(true);
78
+ const raw = await readFile(join(tmpHome, ".wcagauditrc"), "utf8");
79
+ const cfg = JSON.parse(raw);
80
+ expect(cfg.ai.enabled).toBe(false);
81
+ expect(cfg.ai.apiKey).toBe(null);
82
+ });
83
+ });