auditkit 0.1.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/Cargo.toml ADDED
@@ -0,0 +1,18 @@
1
+ [package]
2
+ name = "auditkit"
3
+ version = "0.1.0"
4
+ edition = "2021"
5
+
6
+ [dependencies]
7
+ anyhow = "1.0"
8
+ chrono = { version = "0.4", default-features = false, features = ["clock"] }
9
+ clap = { version = "4.5", features = ["derive"] }
10
+ colored = "3.0"
11
+ indicatif = "0.18"
12
+ regex = "1.12"
13
+ reqwest = { version = "0.12", features = ["blocking", "rustls-tls"], default-features = false }
14
+ serde_json = "1.0"
15
+ url = "2.5"
16
+
17
+ [dev-dependencies]
18
+ tempfile = "3.23"
package/README.md ADDED
@@ -0,0 +1,153 @@
1
+ # Audit Kit
2
+
3
+ Local hybrid CLI for agency website audits.
4
+
5
+ Rust runs the core workflow: audit folders, quick HTML checks, security checks, and report generation. Node is used only for Lighthouse because Lighthouse is a Node tool.
6
+
7
+ ## Install
8
+
9
+ Global install from npm/Bun:
10
+
11
+ ```bash
12
+ bun install -g auditkit
13
+ ```
14
+
15
+ NPM also works:
16
+
17
+ ```bash
18
+ npm install -g auditkit
19
+ ```
20
+
21
+ Run without installing globally:
22
+
23
+ ```bash
24
+ bunx auditkit --help
25
+ ```
26
+
27
+ Local development install from this project folder:
28
+
29
+ ```bash
30
+ npm install
31
+ npm link
32
+ ```
33
+
34
+ Prerequisites:
35
+
36
+ - Rust toolchain with `cargo`
37
+ - Node.js 24+
38
+ - Helium, Chrome, Chromium, Brave, or Edge for Lighthouse
39
+
40
+ ## Basic Workflow
41
+
42
+ ```bash
43
+ ak new
44
+ ak inspect latest
45
+ ak report latest
46
+ ```
47
+
48
+ `ak new` creates a workspace in `audits/`. Fill in:
49
+
50
+ - `findings.md`
51
+ - `scorecard.md`
52
+ - `pages/*.md`
53
+ - `raw-notes.md`
54
+
55
+ Then `ak report latest` creates:
56
+
57
+ - `final-report.md`
58
+ - `client-email.md`
59
+
60
+ ## Commands
61
+
62
+ - `ak new` - create a new audit folder
63
+ - `ak check latest` - run automated feedback for the latest audit and save it
64
+ - `ak security latest` - run security header check for the latest audit and save it
65
+ - `ak lighthouse latest` - run Lighthouse for the latest audit and save markdown + JSON
66
+ - `ak inspect latest` - run automated feedback, security, and Lighthouse
67
+ - `ak check <url>` - fetch a website and print quick feedback
68
+ - `ak security <url>` - fetch a website and print security feedback
69
+ - `ak lighthouse <url>` - run Lighthouse for a website
70
+ - `ak check <url> --save <audit-folder>` - fetch a website and save feedback into an audit
71
+ - `ak security <url> --save <audit-folder>` - save security feedback into an audit
72
+ - `ak lighthouse <url> --save <audit-folder>` - save Lighthouse output into an audit
73
+ - `ak report latest` - generate `final-report.md` and `client-email.md`
74
+ - `ak list` - list existing audits
75
+
76
+ Long form also works:
77
+
78
+ - `auditkit new`
79
+ - `auditkit check latest`
80
+ - `auditkit report latest`
81
+ - `auditkit list`
82
+
83
+ NPM scripts:
84
+
85
+ - `npm run audit -- new` - create a new audit folder
86
+ - `npm run audit -- check latest` - run automated feedback for the latest audit and save it
87
+ - `npm run audit -- check <url>` - fetch a website and print quick feedback
88
+ - `npm run audit -- check <url> --save <audit-folder>` - fetch a website and save feedback into an audit
89
+ - `npm run audit -- report latest` - generate `final-report.md` and `client-email.md`
90
+ - `npm run audit -- list` - list existing audits
91
+
92
+ ## Quick Website Check
93
+
94
+ ```bash
95
+ ak check https://example.com
96
+ ```
97
+
98
+ The check is intentionally lightweight. It reviews basic HTML signals:
99
+
100
+ - title tag
101
+ - meta description
102
+ - H1 count
103
+ - image alt text
104
+ - viewport tag
105
+ - canonical link
106
+ - obvious CTA language
107
+ - initial response time and HTML size
108
+
109
+ ## Lighthouse Requirement
110
+
111
+ `ak lighthouse` and `ak inspect` need a Chrome-family browser installed:
112
+
113
+ - Helium
114
+ - Google Chrome
115
+ - Chromium
116
+ - Brave
117
+ - Microsoft Edge
118
+
119
+ If none is installed, Lighthouse cannot run.
120
+
121
+ Audit Kit auto-detects Helium at:
122
+
123
+ ```text
124
+ /Applications/Helium.app/Contents/MacOS/Helium
125
+ ```
126
+
127
+ Override browser path when needed:
128
+
129
+ ```bash
130
+ AUDITKIT_BROWSER_PATH="/path/to/browser" ak lighthouse https://example.com
131
+ ```
132
+
133
+ ## Development
134
+
135
+ Run all tests:
136
+
137
+ ```bash
138
+ npm test
139
+ ```
140
+
141
+ Rust-only:
142
+
143
+ ```bash
144
+ cargo test
145
+ ```
146
+
147
+ Node Lighthouse helper only:
148
+
149
+ ```bash
150
+ npm run test:node
151
+ ```
152
+
153
+ Architecture notes live in [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md).
@@ -0,0 +1,10 @@
1
+ {
2
+ "agencyName": "Origin",
3
+ "auditorName": "Daniel White",
4
+ "defaultCurrency": "GBP",
5
+ "services": {
6
+ "audit": "Starting at £299",
7
+ "refresh": "Starting at £1,999",
8
+ "growth": "Starting at £499/mo"
9
+ }
10
+ }
@@ -0,0 +1,44 @@
1
+ # Architecture
2
+
3
+ Audit Kit is a hybrid CLI.
4
+
5
+ ## Rust Core
6
+
7
+ Rust owns the workflow that should be fast, predictable, and easy to inspect:
8
+
9
+ - command routing
10
+ - audit folder creation
11
+ - HTML checks
12
+ - security header checks
13
+ - report and email generation
14
+ - terminal output
15
+
16
+ Rust source lives in `src/`.
17
+
18
+ ## Node Lighthouse Helper
19
+
20
+ Lighthouse is a Node ecosystem tool, so Audit Kit keeps that part in Node:
21
+
22
+ - `scripts/lighthouse.mjs`
23
+ - `scripts/auditkit/lighthouse-runner.mjs`
24
+
25
+ Rust calls the Node helper as a subprocess only when running:
26
+
27
+ ```bash
28
+ ak lighthouse latest
29
+ ak inspect latest
30
+ ```
31
+
32
+ ## Module Map
33
+
34
+ - `src/main.rs`: command router
35
+ - `src/audit.rs`: audit input helpers
36
+ - `src/workspace.rs`: paths, generated audit files, folder lookup
37
+ - `src/templates.rs`: starter markdown files
38
+ - `src/html_check.rs`: lightweight HTML feedback
39
+ - `src/security.rs`: security header feedback
40
+ - `src/report.rs`: final report and client email
41
+ - `src/lighthouse.rs`: bridge to the Node Lighthouse helper
42
+ - `src/ui.rs`: terminal output helpers
43
+
44
+ The code is intentionally plain. Each file owns one small part of the workflow.
package/package.json ADDED
@@ -0,0 +1,46 @@
1
+ {
2
+ "name": "auditkit",
3
+ "version": "0.1.0",
4
+ "description": "Local hybrid CLI for agency website audits.",
5
+ "type": "module",
6
+ "private": false,
7
+ "bin": {
8
+ "auditkit": "scripts/auditkit/ak",
9
+ "ak": "scripts/auditkit/ak"
10
+ },
11
+ "files": [
12
+ "Cargo.toml",
13
+ "Cargo.lock",
14
+ "src/",
15
+ "scripts/auditkit/",
16
+ "auditkit.config.json",
17
+ "docs/ARCHITECTURE.md"
18
+ ],
19
+ "publishConfig": {
20
+ "access": "public"
21
+ },
22
+ "engines": {
23
+ "node": ">=24"
24
+ },
25
+ "scripts": {
26
+ "audit": "cargo run --quiet --",
27
+ "audit:new": "cargo run --quiet -- new",
28
+ "audit:list": "cargo run --quiet -- list",
29
+ "audit:check": "cargo run --quiet -- check",
30
+ "audit:report": "cargo run --quiet -- report",
31
+ "audit:security": "cargo run --quiet -- security",
32
+ "audit:lighthouse": "cargo run --quiet -- lighthouse",
33
+ "audit:inspect": "cargo run --quiet -- inspect",
34
+ "postinstall": "node scripts/auditkit/postinstall.mjs",
35
+ "site:dev": "npm --prefix site run dev",
36
+ "site:build": "npm --prefix site run build",
37
+ "site:preview": "npm --prefix site run preview",
38
+ "test": "npm run test:node && npm run test:rust",
39
+ "test:node": "node --test",
40
+ "test:rust": "cargo test"
41
+ },
42
+ "dependencies": {
43
+ "chrome-launcher": "^1.2.1",
44
+ "lighthouse": "^13.3.0"
45
+ }
46
+ }
@@ -0,0 +1,22 @@
1
+ #!/usr/bin/env sh
2
+ set -eu
3
+
4
+ SOURCE="$0"
5
+ while [ -L "$SOURCE" ]; do
6
+ DIR="$(CDPATH= cd -- "$(dirname -- "$SOURCE")" && pwd)"
7
+ TARGET="$(readlink "$SOURCE")"
8
+ case "$TARGET" in
9
+ /*) SOURCE="$TARGET" ;;
10
+ *) SOURCE="$DIR/$TARGET" ;;
11
+ esac
12
+ done
13
+
14
+ SCRIPT_DIR="$(CDPATH= cd -- "$(dirname -- "$SOURCE")" && pwd)"
15
+ ROOT_DIR="$(CDPATH= cd -- "$SCRIPT_DIR/../.." && pwd)"
16
+ BINARY="$ROOT_DIR/target/debug/auditkit"
17
+
18
+ if [ ! -x "$BINARY" ]; then
19
+ cargo build --manifest-path "$ROOT_DIR/Cargo.toml" >/dev/null
20
+ fi
21
+
22
+ exec "$BINARY" "$@"
@@ -0,0 +1,183 @@
1
+ import { existsSync } from "node:fs";
2
+
3
+ const opportunityIds = [
4
+ "render-blocking-resources",
5
+ "unused-javascript",
6
+ "unused-css-rules",
7
+ "uses-optimized-images",
8
+ "modern-image-formats",
9
+ "uses-responsive-images",
10
+ "unminified-javascript",
11
+ "unminified-css",
12
+ "uses-text-compression",
13
+ "server-response-time",
14
+ "redirects",
15
+ "bootup-time",
16
+ "mainthread-work-breakdown",
17
+ "third-party-summary",
18
+ ];
19
+
20
+ const browserCandidates = [
21
+ "/Applications/Helium.app/Contents/MacOS/Helium",
22
+ "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome",
23
+ "/Applications/Chromium.app/Contents/MacOS/Chromium",
24
+ "/Applications/Brave Browser.app/Contents/MacOS/Brave Browser",
25
+ "/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge",
26
+ ];
27
+
28
+ function section(title) {
29
+ return `\n== ${title} ==`;
30
+ }
31
+
32
+ function formatKeyValue(label, value) {
33
+ return `${label}: ${value}`;
34
+ }
35
+
36
+ function bullet(value) {
37
+ return `- ${value}`;
38
+ }
39
+
40
+ export function findBrowserPath({ env = process.env, config = {}, exists = existsSync } = {}) {
41
+ const candidates = [
42
+ config.browserPath,
43
+ env.AUDITKIT_BROWSER_PATH,
44
+ env.CHROME_PATH,
45
+ ...browserCandidates,
46
+ ].filter(Boolean);
47
+
48
+ return candidates.find((candidate) => exists(candidate)) ?? null;
49
+ }
50
+
51
+ function categoryScore(category) {
52
+ if (!category || typeof category.score !== "number") {
53
+ return null;
54
+ }
55
+
56
+ return Math.round(category.score * 100);
57
+ }
58
+
59
+ function auditDisplayValue(audits, id) {
60
+ return audits?.[id]?.displayValue ?? "n/a";
61
+ }
62
+
63
+ export function summarizeLighthouse(lhr) {
64
+ const audits = lhr.audits ?? {};
65
+ const opportunities = opportunityIds
66
+ .map((id) => ({ id, ...audits[id] }))
67
+ .filter((audit) => audit.title && typeof audit.score === "number" && audit.score < 0.9)
68
+ .slice(0, 6)
69
+ .map((audit) => ({
70
+ id: audit.id,
71
+ title: audit.title,
72
+ score: Math.round(audit.score * 100),
73
+ displayValue: audit.displayValue ?? "",
74
+ description: audit.description ?? "",
75
+ }));
76
+
77
+ return {
78
+ url: lhr.finalDisplayedUrl ?? lhr.finalUrl ?? "",
79
+ scores: {
80
+ performance: categoryScore(lhr.categories?.performance),
81
+ accessibility: categoryScore(lhr.categories?.accessibility),
82
+ bestPractices: categoryScore(lhr.categories?.["best-practices"]),
83
+ seo: categoryScore(lhr.categories?.seo),
84
+ },
85
+ vitals: {
86
+ lcp: auditDisplayValue(audits, "largest-contentful-paint"),
87
+ cls: auditDisplayValue(audits, "cumulative-layout-shift"),
88
+ tbt: auditDisplayValue(audits, "total-blocking-time"),
89
+ },
90
+ opportunities,
91
+ };
92
+ }
93
+
94
+ export function formatLighthouseCli(summary) {
95
+ return [
96
+ section("Lighthouse Check"),
97
+ formatKeyValue("URL", summary.url),
98
+ formatKeyValue("Performance", `${summary.scores.performance ?? "n/a"}/100`),
99
+ formatKeyValue("Accessibility", `${summary.scores.accessibility ?? "n/a"}/100`),
100
+ formatKeyValue("Best practices", `${summary.scores.bestPractices ?? "n/a"}/100`),
101
+ formatKeyValue("SEO", `${summary.scores.seo ?? "n/a"}/100`),
102
+ section("Vitals"),
103
+ formatKeyValue("LCP", summary.vitals.lcp),
104
+ formatKeyValue("CLS", summary.vitals.cls),
105
+ formatKeyValue("TBT", summary.vitals.tbt),
106
+ section("Top Opportunities"),
107
+ ...(summary.opportunities.length
108
+ ? summary.opportunities.map((item) => bullet(`${item.title}${item.displayValue ? ` — ${item.displayValue}` : ""}`))
109
+ : [bullet("No major Lighthouse opportunities found.")]),
110
+ ].join("\n");
111
+ }
112
+
113
+ export function formatLighthouseReport(summary) {
114
+ return `# Lighthouse Check
115
+
116
+ URL: ${summary.url}
117
+
118
+ ## Scores
119
+
120
+ - Performance: ${summary.scores.performance ?? "n/a"}/100
121
+ - Accessibility: ${summary.scores.accessibility ?? "n/a"}/100
122
+ - Best practices: ${summary.scores.bestPractices ?? "n/a"}/100
123
+ - SEO: ${summary.scores.seo ?? "n/a"}/100
124
+
125
+ ## Core Web Vitals
126
+
127
+ - LCP: ${summary.vitals.lcp}
128
+ - CLS: ${summary.vitals.cls}
129
+ - TBT: ${summary.vitals.tbt}
130
+
131
+ ## Top Opportunities
132
+
133
+ ${
134
+ summary.opportunities.length
135
+ ? summary.opportunities
136
+ .map((item) => `- ${item.title}${item.displayValue ? `: ${item.displayValue}` : ""}`)
137
+ .join("\n")
138
+ : "- No major Lighthouse opportunities found."
139
+ }
140
+ `;
141
+ }
142
+
143
+ export async function runLighthouse(inputUrl, options = {}) {
144
+ const [{ default: lighthouse }, chromeLauncher] = await Promise.all([
145
+ import("lighthouse"),
146
+ import("chrome-launcher"),
147
+ ]);
148
+
149
+ const browserPath = findBrowserPath({ config: options });
150
+ let chrome;
151
+
152
+ try {
153
+ chrome = await chromeLauncher.launch({
154
+ chromePath: browserPath ?? undefined,
155
+ chromeFlags: ["--headless", "--no-sandbox", "--disable-gpu"],
156
+ });
157
+ } catch (error) {
158
+ if (String(error.message).includes("No Chrome installations found")) {
159
+ throw new Error(
160
+ "No Chrome-compatible browser found. Install Chrome/Chromium/Brave/Edge, or set AUDITKIT_BROWSER_PATH.",
161
+ );
162
+ }
163
+
164
+ throw error;
165
+ }
166
+
167
+ try {
168
+ const result = await lighthouse(inputUrl, {
169
+ port: chrome.port,
170
+ output: "json",
171
+ onlyCategories: ["performance", "accessibility", "best-practices", "seo"],
172
+ });
173
+
174
+ const lhr = result.lhr;
175
+ return {
176
+ lhr,
177
+ json: JSON.stringify(lhr, null, 2),
178
+ summary: summarizeLighthouse(lhr),
179
+ };
180
+ } finally {
181
+ await chrome.kill();
182
+ }
183
+ }
@@ -0,0 +1,78 @@
1
+ import test from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import {
4
+ findBrowserPath,
5
+ formatLighthouseReport,
6
+ summarizeLighthouse,
7
+ } from "./lighthouse-runner.mjs";
8
+
9
+ const lhr = {
10
+ finalDisplayedUrl: "https://example.com/",
11
+ categories: {
12
+ performance: { score: 0.87 },
13
+ accessibility: { score: 0.92 },
14
+ "best-practices": { score: 0.78 },
15
+ seo: { score: 1 },
16
+ },
17
+ audits: {
18
+ "largest-contentful-paint": { displayValue: "1.8 s" },
19
+ "cumulative-layout-shift": { displayValue: "0.02" },
20
+ "total-blocking-time": { displayValue: "120 ms" },
21
+ "render-blocking-resources": {
22
+ title: "Eliminate render-blocking resources",
23
+ description: "Resources are blocking first paint.",
24
+ score: 0.4,
25
+ displayValue: "Potential savings of 350 ms",
26
+ },
27
+ "uses-optimized-images": {
28
+ title: "Efficiently encode images",
29
+ description: "Images could be smaller.",
30
+ score: 0.7,
31
+ displayValue: "Potential savings of 120 KiB",
32
+ },
33
+ },
34
+ };
35
+
36
+ test("summarizeLighthouse extracts scores, vitals, and opportunities", () => {
37
+ const summary = summarizeLighthouse(lhr);
38
+
39
+ assert.deepEqual(summary.scores, {
40
+ performance: 87,
41
+ accessibility: 92,
42
+ bestPractices: 78,
43
+ seo: 100,
44
+ });
45
+ assert.equal(summary.vitals.lcp, "1.8 s");
46
+ assert.equal(summary.opportunities.length, 2);
47
+ assert.equal(summary.opportunities[0].title, "Eliminate render-blocking resources");
48
+ });
49
+
50
+ test("formatLighthouseReport renders markdown summary", () => {
51
+ const report = formatLighthouseReport(summarizeLighthouse(lhr));
52
+
53
+ assert.match(report, /# Lighthouse Check/);
54
+ assert.match(report, /Performance: 87\/100/);
55
+ assert.match(report, /Eliminate render-blocking resources/);
56
+ });
57
+
58
+ test("findBrowserPath prefers explicit config path", () => {
59
+ assert.equal(
60
+ findBrowserPath({
61
+ env: {},
62
+ config: { browserPath: "/Applications/Helium.app/Contents/MacOS/Helium" },
63
+ exists: () => true,
64
+ }),
65
+ "/Applications/Helium.app/Contents/MacOS/Helium",
66
+ );
67
+ });
68
+
69
+ test("findBrowserPath falls back to Helium app path", () => {
70
+ assert.equal(
71
+ findBrowserPath({
72
+ env: {},
73
+ config: {},
74
+ exists: (value) => value === "/Applications/Helium.app/Contents/MacOS/Helium",
75
+ }),
76
+ "/Applications/Helium.app/Contents/MacOS/Helium",
77
+ );
78
+ });
@@ -0,0 +1,31 @@
1
+ const useColor = process.stdout.isTTY && !process.env.NO_COLOR;
2
+
3
+ const cyan = (value) => (useColor ? `\x1b[36m${value}\x1b[0m` : value);
4
+ const green = (value) => (useColor ? `\x1b[32m${value}\x1b[0m` : value);
5
+ const bold = (value) => (useColor ? `\x1b[1m${value}\x1b[0m` : value);
6
+ const dim = (value) => (useColor ? `\x1b[2m${value}\x1b[0m` : value);
7
+
8
+ const width = 54;
9
+ const edge = cyan("│");
10
+ const boxLine = (value = "", format = (text) => text) => {
11
+ const padded = value.padEnd(width - 4, " ");
12
+ return `${edge} ${format(value)}${" ".repeat(padded.length - value.length)} ${edge}`;
13
+ };
14
+
15
+ const lines = [
16
+ "",
17
+ cyan(`┌${"─".repeat(width - 2)}┐`),
18
+ boxLine("Welcome to Audit Kit", bold),
19
+ boxLine("Local audits, Lighthouse, and security checks."),
20
+ cyan(`└${"─".repeat(width - 2)}┘`),
21
+ "",
22
+ `${green("Next steps")}`,
23
+ ` ${bold("ak new")} create audit workspace`,
24
+ ` ${bold("ak inspect latest")} run checks for latest audit`,
25
+ ` ${bold("ak report latest")} generate final report`,
26
+ "",
27
+ `${dim("Tip: first run builds the Rust CLI, so it may take a few seconds.")}`,
28
+ "",
29
+ ];
30
+
31
+ console.log(lines.join("\n"));
package/src/audit.rs ADDED
@@ -0,0 +1,59 @@
1
+ #[derive(Debug, Clone, PartialEq, Eq)]
2
+ pub struct AuditInput {
3
+ pub client_name: String,
4
+ pub slug: String,
5
+ pub url: String,
6
+ pub business_type: String,
7
+ pub goal: String,
8
+ pub target_customer: String,
9
+ pub conversion_action: String,
10
+ pub pages: Vec<String>,
11
+ pub known_concerns: Vec<String>,
12
+ pub competitors: Vec<String>,
13
+ pub created_at: String,
14
+ }
15
+
16
+ pub fn slugify(value: &str) -> String {
17
+ let mut slug = String::new();
18
+ let mut previous_dash = false;
19
+
20
+ for character in value.trim().to_lowercase().chars() {
21
+ if character.is_ascii_alphanumeric() {
22
+ slug.push(character);
23
+ previous_dash = false;
24
+ } else if !previous_dash {
25
+ slug.push('-');
26
+ previous_dash = true;
27
+ }
28
+ }
29
+
30
+ slug.trim_matches('-').to_string()
31
+ }
32
+
33
+ pub fn split_comma_list(value: &str) -> Vec<String> {
34
+ value
35
+ .split(',')
36
+ .map(str::trim)
37
+ .filter(|item| !item.is_empty())
38
+ .map(ToOwned::to_owned)
39
+ .collect()
40
+ }
41
+
42
+ #[cfg(test)]
43
+ mod tests {
44
+ use super::*;
45
+
46
+ #[test]
47
+ fn slugify_makes_folder_safe_names() {
48
+ assert_eq!(slugify("Acme Dental Ltd"), "acme-dental-ltd");
49
+ assert_eq!(slugify(" My Kind_of Cruise! "), "my-kind-of-cruise");
50
+ }
51
+
52
+ #[test]
53
+ fn split_comma_list_ignores_empty_items() {
54
+ assert_eq!(
55
+ split_comma_list("/, /pricing, , /contact"),
56
+ vec!["/", "/pricing", "/contact"]
57
+ );
58
+ }
59
+ }