auditkit 0.1.4 → 0.1.5
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.lock +1 -1
- package/Cargo.toml +1 -1
- package/README.md +65 -91
- package/package.json +1 -1
- package/scripts/auditkit/lighthouse.test.mjs +14 -3
- package/scripts/auditkit/postinstall.mjs +13 -7
- package/src/main.rs +358 -83
- package/src/ui.rs +80 -10
package/Cargo.lock
CHANGED
package/Cargo.toml
CHANGED
package/README.md
CHANGED
|
@@ -1,111 +1,98 @@
|
|
|
1
1
|
# Audit Kit
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Fast local website audits for freelancers and agencies. Run HTML, security, Lighthouse, and report-generation workflows from one small CLI.
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
```text
|
|
6
|
+
╭──────────────────────── Audit Kit ────────────────────────╮
|
|
7
|
+
│ ak check guided one-off website audit │
|
|
8
|
+
│ ak check --save choose where the audit files should go │
|
|
9
|
+
│ ak new create a reusable client workspace │
|
|
10
|
+
╰────────────────────────────────────────────────────────────╯
|
|
11
|
+
```
|
|
6
12
|
|
|
7
13
|
## Install
|
|
8
14
|
|
|
9
|
-
From this project folder:
|
|
10
|
-
|
|
11
15
|
```bash
|
|
12
|
-
npm install
|
|
13
|
-
|
|
14
|
-
|
|
16
|
+
npm install -g auditkit
|
|
17
|
+
# or
|
|
18
|
+
bun add -g auditkit
|
|
15
19
|
```
|
|
16
20
|
|
|
17
|
-
|
|
21
|
+
Requirements:
|
|
18
22
|
|
|
19
|
-
- Rust
|
|
23
|
+
- Rust + `cargo`
|
|
20
24
|
- Node.js 24+
|
|
21
|
-
-
|
|
25
|
+
- Chrome, Chromium, Brave, Edge, or Helium for Lighthouse
|
|
22
26
|
|
|
23
|
-
##
|
|
27
|
+
## Quick start
|
|
24
28
|
|
|
25
29
|
```bash
|
|
26
|
-
ak
|
|
27
|
-
ak inspect latest
|
|
28
|
-
ak report latest
|
|
30
|
+
ak check
|
|
29
31
|
```
|
|
30
32
|
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
- `findings.md`
|
|
34
|
-
- `workspace.md`
|
|
35
|
-
|
|
36
|
-
Then `ak report latest` creates:
|
|
37
|
-
|
|
38
|
-
- `final-report.md`
|
|
39
|
-
- `client-email.md`
|
|
33
|
+
Audit Kit asks for the website, runs the check, then asks where to save the markdown files.
|
|
40
34
|
|
|
41
|
-
|
|
35
|
+
```text
|
|
36
|
+
› Website URL (https://example.com) https://example.com
|
|
37
|
+
✓ Fetching website and reading HTML
|
|
38
|
+
› Save this check as markdown? (Y/n) y
|
|
39
|
+
› Save folder (./auditkit-example-com) ~/audits/example
|
|
40
|
+
✓ saved /Users/you/audits/example/automated-check.md
|
|
41
|
+
```
|
|
42
42
|
|
|
43
|
-
|
|
44
|
-
- `ak check latest` - run automated feedback for the latest audit and save it
|
|
45
|
-
- `ak security latest` - run security header check for the latest audit and save it
|
|
46
|
-
- `ak lighthouse latest` - run Lighthouse for the latest audit and save markdown + JSON
|
|
47
|
-
- `ak inspect latest` - run automated feedback, security, and Lighthouse
|
|
48
|
-
- `ak check <url>` - fetch a website and print quick feedback
|
|
49
|
-
- `ak security <url>` - fetch a website and print security feedback
|
|
50
|
-
- `ak lighthouse <url>` - run Lighthouse for a website
|
|
51
|
-
- `ak check <url> --save <audit-folder>` - fetch a website and save feedback into an audit
|
|
52
|
-
- `ak security <url> --save <audit-folder>` - save security feedback into an audit
|
|
53
|
-
- `ak lighthouse <url> --save <audit-folder>` - save Lighthouse output into an audit
|
|
54
|
-
- `ak report latest` - generate `final-report.md` and `client-email.md`
|
|
55
|
-
- `ak list` - list existing audits
|
|
56
|
-
|
|
57
|
-
Long form also works:
|
|
58
|
-
|
|
59
|
-
- `auditkit new`
|
|
60
|
-
- `auditkit check latest`
|
|
61
|
-
- `auditkit report latest`
|
|
62
|
-
- `auditkit list`
|
|
63
|
-
|
|
64
|
-
NPM scripts:
|
|
65
|
-
|
|
66
|
-
- `npm run audit -- new` - create a new audit folder
|
|
67
|
-
- `npm run audit -- check latest` - run automated feedback for the latest audit and save it
|
|
68
|
-
- `npm run audit -- check <url>` - fetch a website and print quick feedback
|
|
69
|
-
- `npm run audit -- check <url> --save <audit-folder>` - fetch a website and save feedback into an audit
|
|
70
|
-
- `npm run audit -- report latest` - generate `final-report.md` and `client-email.md`
|
|
71
|
-
- `npm run audit -- list` - list existing audits
|
|
72
|
-
|
|
73
|
-
## Quick Website Check
|
|
43
|
+
## Save anywhere
|
|
74
44
|
|
|
75
45
|
```bash
|
|
76
|
-
ak check https://example.com
|
|
46
|
+
ak check https://example.com --save ~/audits/example
|
|
47
|
+
ak security https://example.com --save ~/audits/example
|
|
48
|
+
ak lighthouse https://example.com --save ~/audits/example
|
|
49
|
+
ak inspect https://example.com --save ~/audits/example
|
|
77
50
|
```
|
|
78
51
|
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
- title tag
|
|
82
|
-
- meta description
|
|
83
|
-
- H1 count
|
|
84
|
-
- image alt text
|
|
85
|
-
- viewport tag
|
|
86
|
-
- canonical link
|
|
87
|
-
- obvious CTA language
|
|
88
|
-
- initial response time and HTML size
|
|
52
|
+
Saved files use simple names:
|
|
89
53
|
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
54
|
+
```text
|
|
55
|
+
~/audits/example/
|
|
56
|
+
├─ automated-check.md
|
|
57
|
+
├─ security-check.md
|
|
58
|
+
├─ lighthouse.md
|
|
59
|
+
└─ lighthouse.json
|
|
60
|
+
```
|
|
93
61
|
|
|
94
|
-
|
|
95
|
-
- Google Chrome
|
|
96
|
-
- Chromium
|
|
97
|
-
- Brave
|
|
98
|
-
- Microsoft Edge
|
|
62
|
+
## Full client workflow
|
|
99
63
|
|
|
100
|
-
|
|
64
|
+
```bash
|
|
65
|
+
ak new
|
|
66
|
+
ak inspect latest
|
|
67
|
+
ak report latest
|
|
68
|
+
```
|
|
101
69
|
|
|
102
|
-
|
|
70
|
+
That creates an audit workspace, runs every check, then writes:
|
|
103
71
|
|
|
104
72
|
```text
|
|
105
|
-
|
|
73
|
+
audits/<date-client>/
|
|
74
|
+
├─ workspace.md
|
|
75
|
+
├─ findings.md
|
|
76
|
+
├─ final-report.md
|
|
77
|
+
├─ client-email.md
|
|
78
|
+
└─ raw/lighthouse.json
|
|
106
79
|
```
|
|
107
80
|
|
|
108
|
-
|
|
81
|
+
## Commands
|
|
82
|
+
|
|
83
|
+
| Command | Use |
|
|
84
|
+
| --- | --- |
|
|
85
|
+
| `ak check` | guided HTML/SEO basics check |
|
|
86
|
+
| `ak check <url> --save <folder>` | save check markdown to any folder |
|
|
87
|
+
| `ak security <url> --save <folder>` | save security header audit |
|
|
88
|
+
| `ak lighthouse <url> --save <folder>` | save Lighthouse markdown + JSON |
|
|
89
|
+
| `ak inspect <url> --save <folder>` | run and save every automated check |
|
|
90
|
+
| `ak new` | create a client audit workspace |
|
|
91
|
+
| `ak inspect latest` | run all checks for the newest workspace |
|
|
92
|
+
| `ak report latest` | create the final report and client email |
|
|
93
|
+
| `ak list` | list audit workspaces |
|
|
94
|
+
|
|
95
|
+
## Browser override
|
|
109
96
|
|
|
110
97
|
```bash
|
|
111
98
|
AUDITKIT_BROWSER_PATH="/path/to/browser" ak lighthouse https://example.com
|
|
@@ -113,22 +100,9 @@ AUDITKIT_BROWSER_PATH="/path/to/browser" ak lighthouse https://example.com
|
|
|
113
100
|
|
|
114
101
|
## Development
|
|
115
102
|
|
|
116
|
-
Run all tests:
|
|
117
|
-
|
|
118
103
|
```bash
|
|
119
104
|
npm test
|
|
120
|
-
```
|
|
121
|
-
|
|
122
|
-
Rust-only:
|
|
123
|
-
|
|
124
|
-
```bash
|
|
125
105
|
cargo test
|
|
126
106
|
```
|
|
127
107
|
|
|
128
|
-
Node Lighthouse helper only:
|
|
129
|
-
|
|
130
|
-
```bash
|
|
131
|
-
npm run test:node
|
|
132
|
-
```
|
|
133
|
-
|
|
134
108
|
Architecture notes live in [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md).
|
package/package.json
CHANGED
|
@@ -5,7 +5,7 @@ import {
|
|
|
5
5
|
formatLighthouseReport,
|
|
6
6
|
summarizeLighthouse,
|
|
7
7
|
} from "./lighthouse-runner.mjs";
|
|
8
|
-
import { shouldShowWelcome, welcomeMessage } from "./postinstall.mjs";
|
|
8
|
+
import { shouldShowWelcome, showWelcome, welcomeMessage } from "./postinstall.mjs";
|
|
9
9
|
|
|
10
10
|
const lhr = {
|
|
11
11
|
finalDisplayedUrl: "https://example.com/",
|
|
@@ -83,8 +83,8 @@ test("postinstall welcome explains first commands", () => {
|
|
|
83
83
|
const plainMessage = welcomeMessage({ NO_COLOR: "1" });
|
|
84
84
|
|
|
85
85
|
assert.match(message, /Audit Kit/);
|
|
86
|
-
assert.match(message, /ak
|
|
87
|
-
assert.match(message, /ak
|
|
86
|
+
assert.match(message, /ak check/);
|
|
87
|
+
assert.match(message, /ak check --save/);
|
|
88
88
|
assert.match(message, /\x1b\[/);
|
|
89
89
|
assert.doesNotMatch(plainMessage, /\x1b\[/);
|
|
90
90
|
assert.match(plainMessage, /Audit Kit/);
|
|
@@ -93,3 +93,14 @@ test("postinstall welcome explains first commands", () => {
|
|
|
93
93
|
assert.equal(shouldShowWelcome({ AUDITKIT_SKIP_WELCOME: "1" }), false);
|
|
94
94
|
assert.equal(shouldShowWelcome({ npm_config_loglevel: "silent" }), false);
|
|
95
95
|
});
|
|
96
|
+
|
|
97
|
+
test("postinstall writes welcome to the provided stream", () => {
|
|
98
|
+
let output = "";
|
|
99
|
+
showWelcome({ write: (value) => (output += value) }, { NO_COLOR: "1" });
|
|
100
|
+
assert.match(output, /Audit Kit/);
|
|
101
|
+
assert.match(output, /ak check/);
|
|
102
|
+
|
|
103
|
+
output = "";
|
|
104
|
+
showWelcome({ write: (value) => (output += value) }, { CI: "true" });
|
|
105
|
+
assert.equal(output, "");
|
|
106
|
+
});
|
|
@@ -14,10 +14,10 @@ export function welcomeMessage(env = process.env) {
|
|
|
14
14
|
divider(colors),
|
|
15
15
|
line(` ${paint("Try first", 97, colors)} `, colors),
|
|
16
16
|
line(" ", colors),
|
|
17
|
-
commandLine("ak
|
|
18
|
-
commandLine("ak
|
|
19
|
-
commandLine("ak
|
|
20
|
-
commandLine("ak
|
|
17
|
+
commandLine("ak check", "guided website check", colors),
|
|
18
|
+
commandLine("ak check --save", "choose a save folder", colors),
|
|
19
|
+
commandLine("ak new", "create a full audit", colors),
|
|
20
|
+
commandLine("ak inspect latest", "run every check", colors),
|
|
21
21
|
line(" ", colors),
|
|
22
22
|
];
|
|
23
23
|
|
|
@@ -31,12 +31,18 @@ export function welcomeMessage(env = process.env) {
|
|
|
31
31
|
].join("\n");
|
|
32
32
|
}
|
|
33
33
|
|
|
34
|
-
|
|
35
|
-
|
|
34
|
+
export function showWelcome(stream = process.stderr, env = process.env) {
|
|
35
|
+
if (shouldShowWelcome(env)) {
|
|
36
|
+
stream.write(`${welcomeMessage(env)}\n`);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) {
|
|
41
|
+
showWelcome();
|
|
36
42
|
}
|
|
37
43
|
|
|
38
44
|
function commandLine(command, description, colors) {
|
|
39
|
-
return line(` ${paint(command.padEnd(
|
|
45
|
+
return line(` ${paint(command.padEnd(24).slice(0, 24), 36, colors)} ${paint(description.padEnd(28).slice(0, 28), 90, colors)} `, colors);
|
|
40
46
|
}
|
|
41
47
|
|
|
42
48
|
function line(content, colors) {
|
package/src/main.rs
CHANGED
|
@@ -1,4 +1,8 @@
|
|
|
1
|
-
use std::{
|
|
1
|
+
use std::{
|
|
2
|
+
env, fs,
|
|
3
|
+
path::{Path, PathBuf},
|
|
4
|
+
process,
|
|
5
|
+
};
|
|
2
6
|
|
|
3
7
|
use anyhow::{Context, Result};
|
|
4
8
|
use auditkit::audit::{slugify, split_comma_list, AuditInput};
|
|
@@ -10,6 +14,7 @@ use auditkit::ui;
|
|
|
10
14
|
use auditkit::workspace::Workspace;
|
|
11
15
|
use chrono::Local;
|
|
12
16
|
use clap::{Parser, Subcommand};
|
|
17
|
+
use url::Url;
|
|
13
18
|
|
|
14
19
|
#[derive(Parser)]
|
|
15
20
|
#[command(name = "ak", about = "Audit Kit: small agency website audit workflow")]
|
|
@@ -23,28 +28,48 @@ enum Command {
|
|
|
23
28
|
New,
|
|
24
29
|
List,
|
|
25
30
|
Check {
|
|
26
|
-
target: String
|
|
27
|
-
#[arg(long)]
|
|
28
|
-
save: Option<String
|
|
31
|
+
target: Option<String>,
|
|
32
|
+
#[arg(long, value_name = "FOLDER", num_args = 0..=1)]
|
|
33
|
+
save: Option<Option<String>>,
|
|
29
34
|
},
|
|
30
35
|
Security {
|
|
31
36
|
target: Option<String>,
|
|
32
|
-
#[arg(long)]
|
|
33
|
-
save: Option<String
|
|
37
|
+
#[arg(long, value_name = "FOLDER", num_args = 0..=1)]
|
|
38
|
+
save: Option<Option<String>>,
|
|
34
39
|
},
|
|
35
40
|
Lighthouse {
|
|
36
41
|
target: Option<String>,
|
|
37
|
-
#[arg(long)]
|
|
38
|
-
save: Option<String
|
|
42
|
+
#[arg(long, value_name = "FOLDER", num_args = 0..=1)]
|
|
43
|
+
save: Option<Option<String>>,
|
|
39
44
|
},
|
|
40
45
|
Inspect {
|
|
41
46
|
target: Option<String>,
|
|
47
|
+
#[arg(long, value_name = "FOLDER", num_args = 0..=1)]
|
|
48
|
+
save: Option<Option<String>>,
|
|
42
49
|
},
|
|
43
50
|
Report {
|
|
44
51
|
target: Option<String>,
|
|
45
52
|
},
|
|
46
53
|
}
|
|
47
54
|
|
|
55
|
+
#[derive(Debug, Clone, Copy)]
|
|
56
|
+
struct SaveRequest<'a> {
|
|
57
|
+
explicit: bool,
|
|
58
|
+
value: Option<&'a str>,
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
#[derive(Debug, Clone)]
|
|
62
|
+
enum SaveDestination {
|
|
63
|
+
Audit(String),
|
|
64
|
+
Directory(PathBuf),
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
#[derive(Debug, Clone)]
|
|
68
|
+
struct TargetInput {
|
|
69
|
+
value: String,
|
|
70
|
+
prompted: bool,
|
|
71
|
+
}
|
|
72
|
+
|
|
48
73
|
fn main() {
|
|
49
74
|
if let Err(error) = run() {
|
|
50
75
|
ui::error(error);
|
|
@@ -59,14 +84,18 @@ fn run() -> Result<()> {
|
|
|
59
84
|
match cli.command {
|
|
60
85
|
Some(Command::New) => new_audit(&workspace),
|
|
61
86
|
Some(Command::List) => list_audits(&workspace),
|
|
62
|
-
Some(Command::Check { target, save }) =>
|
|
87
|
+
Some(Command::Check { target, save }) => {
|
|
88
|
+
check(&workspace, target.as_deref(), save_request(&save))
|
|
89
|
+
}
|
|
63
90
|
Some(Command::Security { target, save }) => {
|
|
64
|
-
security_check(&workspace, target.as_deref(), save
|
|
91
|
+
security_check(&workspace, target.as_deref(), save_request(&save))
|
|
65
92
|
}
|
|
66
93
|
Some(Command::Lighthouse { target, save }) => {
|
|
67
|
-
lighthouse_check(&workspace, target.as_deref(), save
|
|
94
|
+
lighthouse_check(&workspace, target.as_deref(), save_request(&save))
|
|
95
|
+
}
|
|
96
|
+
Some(Command::Inspect { target, save }) => {
|
|
97
|
+
inspect(&workspace, target.as_deref(), save_request(&save))
|
|
68
98
|
}
|
|
69
|
-
Some(Command::Inspect { target }) => inspect(&workspace, target.as_deref()),
|
|
70
99
|
Some(Command::Report { target }) => generate_report(&workspace, target.as_deref()),
|
|
71
100
|
None => {
|
|
72
101
|
ui::help();
|
|
@@ -75,6 +104,23 @@ fn run() -> Result<()> {
|
|
|
75
104
|
}
|
|
76
105
|
}
|
|
77
106
|
|
|
107
|
+
fn save_request(value: &Option<Option<String>>) -> SaveRequest<'_> {
|
|
108
|
+
match value {
|
|
109
|
+
None => SaveRequest {
|
|
110
|
+
explicit: false,
|
|
111
|
+
value: None,
|
|
112
|
+
},
|
|
113
|
+
Some(None) => SaveRequest {
|
|
114
|
+
explicit: true,
|
|
115
|
+
value: None,
|
|
116
|
+
},
|
|
117
|
+
Some(Some(value)) => SaveRequest {
|
|
118
|
+
explicit: true,
|
|
119
|
+
value: Some(value),
|
|
120
|
+
},
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
78
124
|
fn new_audit(workspace: &Workspace) -> Result<()> {
|
|
79
125
|
let answers = ui::collect_audit_input()?;
|
|
80
126
|
|
|
@@ -113,53 +159,63 @@ fn list_audits(workspace: &Workspace) -> Result<()> {
|
|
|
113
159
|
Ok(())
|
|
114
160
|
}
|
|
115
161
|
|
|
116
|
-
fn check(workspace: &Workspace, target:
|
|
117
|
-
|
|
162
|
+
fn check(workspace: &Workspace, target: Option<&str>, save: SaveRequest<'_>) -> Result<()> {
|
|
163
|
+
let target = target_or_prompt("Website URL", "https://example.com", target)?;
|
|
164
|
+
ui::section("Website Check");
|
|
165
|
+
ui::step(1, "Website", &target.value);
|
|
166
|
+
|
|
167
|
+
if looks_like_url(&target.value) {
|
|
118
168
|
let result = ui::with_task("Fetching website and reading HTML", || {
|
|
119
|
-
html_check::check_url(target)
|
|
169
|
+
html_check::check_url(&target.value)
|
|
120
170
|
})?;
|
|
121
171
|
println!("{}", html_check::format_cli(&result));
|
|
122
|
-
if let Some(
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
172
|
+
if let Some(destination) = save_destination_for_result(
|
|
173
|
+
workspace,
|
|
174
|
+
save,
|
|
175
|
+
&target.value,
|
|
176
|
+
"Save this check as markdown?",
|
|
177
|
+
target.prompted,
|
|
178
|
+
)? {
|
|
179
|
+
save_html_result(workspace, &destination, &result)?;
|
|
130
180
|
}
|
|
131
181
|
return Ok(());
|
|
132
182
|
}
|
|
133
183
|
|
|
134
|
-
let folder = workspace.resolve_target(Some(target))?;
|
|
184
|
+
let folder = workspace.resolve_target(Some(&target.value))?;
|
|
135
185
|
let website = audit_website(workspace, &folder)?;
|
|
136
186
|
let result = ui::with_task("Fetching website and reading HTML", || {
|
|
137
187
|
html_check::check_url(&website)
|
|
138
188
|
})?;
|
|
139
189
|
println!("{}", html_check::format_cli(&result));
|
|
140
|
-
let
|
|
141
|
-
&
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
190
|
+
let destination = if save.explicit {
|
|
191
|
+
resolve_save_destination(workspace, save.value, &website)?
|
|
192
|
+
} else {
|
|
193
|
+
SaveDestination::Audit(folder)
|
|
194
|
+
};
|
|
195
|
+
save_html_result(workspace, &destination, &result)?;
|
|
146
196
|
Ok(())
|
|
147
197
|
}
|
|
148
198
|
|
|
149
|
-
fn security_check(
|
|
199
|
+
fn security_check(
|
|
200
|
+
workspace: &Workspace,
|
|
201
|
+
target: Option<&str>,
|
|
202
|
+
save: SaveRequest<'_>,
|
|
203
|
+
) -> Result<()> {
|
|
150
204
|
let target = target.unwrap_or("latest");
|
|
205
|
+
ui::section("Security Check");
|
|
206
|
+
ui::step(1, "Target", target);
|
|
151
207
|
|
|
152
208
|
if looks_like_url(target) {
|
|
153
209
|
let result = ui::with_task("Checking security headers", || security::check_url(target))?;
|
|
154
210
|
println!("{}", security::format_cli(&result));
|
|
155
|
-
if let Some(
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
211
|
+
if let Some(destination) = save_destination_for_result(
|
|
212
|
+
workspace,
|
|
213
|
+
save,
|
|
214
|
+
target,
|
|
215
|
+
"Save this security check as markdown?",
|
|
216
|
+
false,
|
|
217
|
+
)? {
|
|
218
|
+
save_security_result(workspace, &destination, &result)?;
|
|
163
219
|
}
|
|
164
220
|
return Ok(());
|
|
165
221
|
}
|
|
@@ -170,52 +226,226 @@ fn security_check(workspace: &Workspace, target: Option<&str>, save: Option<&str
|
|
|
170
226
|
security::check_url(&website)
|
|
171
227
|
})?;
|
|
172
228
|
println!("{}", security::format_cli(&result));
|
|
173
|
-
let
|
|
174
|
-
&
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
229
|
+
let destination = if save.explicit {
|
|
230
|
+
resolve_save_destination(workspace, save.value, &website)?
|
|
231
|
+
} else {
|
|
232
|
+
SaveDestination::Audit(folder)
|
|
233
|
+
};
|
|
234
|
+
save_security_result(workspace, &destination, &result)?;
|
|
179
235
|
Ok(())
|
|
180
236
|
}
|
|
181
237
|
|
|
182
|
-
fn lighthouse_check(
|
|
238
|
+
fn lighthouse_check(
|
|
239
|
+
workspace: &Workspace,
|
|
240
|
+
target: Option<&str>,
|
|
241
|
+
save: SaveRequest<'_>,
|
|
242
|
+
) -> Result<()> {
|
|
183
243
|
let target = target.unwrap_or("latest");
|
|
244
|
+
ui::section("Lighthouse Check");
|
|
245
|
+
ui::step(1, "Target", target);
|
|
184
246
|
|
|
185
247
|
if looks_like_url(target) {
|
|
186
|
-
let
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
248
|
+
let destination = save_destination_for_result(
|
|
249
|
+
workspace,
|
|
250
|
+
save,
|
|
251
|
+
target,
|
|
252
|
+
"Save this Lighthouse report?",
|
|
253
|
+
false,
|
|
254
|
+
)?;
|
|
255
|
+
run_lighthouse_for_url(workspace, target, destination.as_ref())?;
|
|
256
|
+
return Ok(());
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
let folder = workspace.resolve_target(Some(target))?;
|
|
260
|
+
let website = audit_website(workspace, &folder)?;
|
|
261
|
+
let destination = if save.explicit {
|
|
262
|
+
resolve_save_destination(workspace, save.value, &website)?
|
|
263
|
+
} else {
|
|
264
|
+
SaveDestination::Audit(folder)
|
|
265
|
+
};
|
|
266
|
+
run_lighthouse_for_url(workspace, &website, Some(&destination))?;
|
|
267
|
+
Ok(())
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
fn inspect(workspace: &Workspace, target: Option<&str>, save: SaveRequest<'_>) -> Result<()> {
|
|
271
|
+
let target = target.unwrap_or("latest");
|
|
272
|
+
ui::section("Inspect");
|
|
273
|
+
ui::step(1, "Target", target);
|
|
274
|
+
|
|
275
|
+
if looks_like_url(target) {
|
|
276
|
+
let destination =
|
|
277
|
+
save_destination_for_result(workspace, save, target, "Save all audit files?", true)?;
|
|
278
|
+
inspect_url(workspace, target, destination.as_ref())?;
|
|
206
279
|
return Ok(());
|
|
207
280
|
}
|
|
208
281
|
|
|
209
282
|
let folder = workspace.resolve_target(Some(target))?;
|
|
210
283
|
let website = audit_website(workspace, &folder)?;
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
284
|
+
if save.explicit {
|
|
285
|
+
let destination = resolve_save_destination(workspace, save.value, &website)?;
|
|
286
|
+
inspect_url(workspace, &website, Some(&destination))?;
|
|
287
|
+
} else {
|
|
288
|
+
ui::bullet(&format!("Running checks for {folder}"));
|
|
289
|
+
check(workspace, Some(&folder), SaveRequest::none())?;
|
|
290
|
+
security_check(workspace, Some(&folder), SaveRequest::none())?;
|
|
291
|
+
lighthouse_check(workspace, Some(&folder), SaveRequest::none())?;
|
|
292
|
+
}
|
|
293
|
+
Ok(())
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
fn inspect_url(
|
|
297
|
+
workspace: &Workspace,
|
|
298
|
+
target: &str,
|
|
299
|
+
destination: Option<&SaveDestination>,
|
|
300
|
+
) -> Result<()> {
|
|
301
|
+
ui::step(2, "HTML", "title, meta, H1, images, CTA signals");
|
|
302
|
+
let html = ui::with_task("Fetching website and reading HTML", || {
|
|
303
|
+
html_check::check_url(target)
|
|
304
|
+
})?;
|
|
305
|
+
println!("{}", html_check::format_cli(&html));
|
|
306
|
+
if let Some(destination) = destination {
|
|
307
|
+
save_html_result(workspace, destination, &html)?;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
ui::step(3, "Security", "headers and browser protections");
|
|
311
|
+
let security = ui::with_task("Checking security headers", || security::check_url(target))?;
|
|
312
|
+
println!("{}", security::format_cli(&security));
|
|
313
|
+
if let Some(destination) = destination {
|
|
314
|
+
save_security_result(workspace, destination, &security)?;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
ui::step(4, "Lighthouse", "performance, accessibility, SEO");
|
|
318
|
+
run_lighthouse_for_url(workspace, target, destination)?;
|
|
319
|
+
Ok(())
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
impl<'a> SaveRequest<'a> {
|
|
323
|
+
fn none() -> Self {
|
|
324
|
+
Self {
|
|
325
|
+
explicit: false,
|
|
326
|
+
value: None,
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
fn save_destination_for_result(
|
|
332
|
+
workspace: &Workspace,
|
|
333
|
+
save: SaveRequest<'_>,
|
|
334
|
+
target: &str,
|
|
335
|
+
prompt_label: &str,
|
|
336
|
+
offer_prompt: bool,
|
|
337
|
+
) -> Result<Option<SaveDestination>> {
|
|
338
|
+
if save.explicit {
|
|
339
|
+
return resolve_save_destination(workspace, save.value, target).map(Some);
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
if offer_prompt && ui::can_prompt() && ui::confirm(prompt_label, true)? {
|
|
343
|
+
return resolve_save_destination(workspace, None, target).map(Some);
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
Ok(None)
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
fn resolve_save_destination(
|
|
350
|
+
workspace: &Workspace,
|
|
351
|
+
value: Option<&str>,
|
|
352
|
+
target: &str,
|
|
353
|
+
) -> Result<SaveDestination> {
|
|
354
|
+
let raw = match value {
|
|
355
|
+
Some(value) if !value.trim().is_empty() => value.trim().to_string(),
|
|
356
|
+
_ if ui::can_prompt() => {
|
|
357
|
+
ui::prompt_with_default("Save folder", &suggested_save_folder(target))?
|
|
358
|
+
}
|
|
359
|
+
_ => anyhow::bail!("Missing save folder. Use `--save <folder>`."),
|
|
360
|
+
};
|
|
361
|
+
|
|
362
|
+
let folders = workspace.list_audits()?;
|
|
363
|
+
if raw == "latest" {
|
|
364
|
+
let folder = folders
|
|
365
|
+
.last()
|
|
366
|
+
.with_context(|| "No audits found. Use a folder path such as `--save ./audit`.")?;
|
|
367
|
+
return Ok(SaveDestination::Audit(folder.to_string()));
|
|
368
|
+
}
|
|
369
|
+
if folders.iter().any(|folder| folder == &raw) {
|
|
370
|
+
return Ok(SaveDestination::Audit(raw));
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
Ok(SaveDestination::Directory(expand_path(&raw)?))
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
fn save_html_result(
|
|
377
|
+
workspace: &Workspace,
|
|
378
|
+
destination: &SaveDestination,
|
|
379
|
+
result: &html_check::HtmlCheck,
|
|
380
|
+
) -> Result<()> {
|
|
381
|
+
let markdown = html_check::format_markdown(result);
|
|
382
|
+
match destination {
|
|
383
|
+
SaveDestination::Audit(folder) => {
|
|
384
|
+
let path = workspace.update_workspace_section(folder, "Automated Check", &markdown)?;
|
|
385
|
+
ui::saved(path.display());
|
|
386
|
+
}
|
|
387
|
+
SaveDestination::Directory(folder) => {
|
|
388
|
+
let path = write_save_file(folder, "automated-check.md", &markdown)?;
|
|
389
|
+
ui::saved(path.display());
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
Ok(())
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
fn save_security_result(
|
|
396
|
+
workspace: &Workspace,
|
|
397
|
+
destination: &SaveDestination,
|
|
398
|
+
result: &security::SecurityCheck,
|
|
399
|
+
) -> Result<()> {
|
|
400
|
+
let markdown = security::format_markdown(result);
|
|
401
|
+
match destination {
|
|
402
|
+
SaveDestination::Audit(folder) => {
|
|
403
|
+
let path = workspace.update_workspace_section(folder, "Security Check", &markdown)?;
|
|
404
|
+
ui::saved(path.display());
|
|
405
|
+
}
|
|
406
|
+
SaveDestination::Directory(folder) => {
|
|
407
|
+
let path = write_save_file(folder, "security-check.md", &markdown)?;
|
|
408
|
+
ui::saved(path.display());
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
Ok(())
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
fn run_lighthouse_for_url(
|
|
415
|
+
workspace: &Workspace,
|
|
416
|
+
target: &str,
|
|
417
|
+
destination: Option<&SaveDestination>,
|
|
418
|
+
) -> Result<()> {
|
|
419
|
+
let temp_dir = destination
|
|
420
|
+
.filter(|destination| matches!(destination, SaveDestination::Audit(_)))
|
|
421
|
+
.map(|_| lighthouse_temp_dir(workspace));
|
|
422
|
+
let output_dir = match destination {
|
|
423
|
+
Some(SaveDestination::Directory(folder)) => Some(folder.as_path()),
|
|
424
|
+
Some(SaveDestination::Audit(_)) => temp_dir.as_deref(),
|
|
425
|
+
None => None,
|
|
426
|
+
};
|
|
427
|
+
|
|
428
|
+
if let Some(folder) = output_dir {
|
|
429
|
+
fs::create_dir_all(folder)?;
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
let paths = ui::with_task("Running Lighthouse", || {
|
|
433
|
+
lighthouse::run_lighthouse(&workspace.root, target, output_dir)
|
|
215
434
|
})?;
|
|
216
435
|
print_lighthouse_output(&paths.cli_output);
|
|
217
|
-
|
|
218
|
-
|
|
436
|
+
|
|
437
|
+
match destination {
|
|
438
|
+
Some(SaveDestination::Audit(folder)) => save_lighthouse_summary(workspace, folder, &paths)?,
|
|
439
|
+
Some(SaveDestination::Directory(_)) | None => {
|
|
440
|
+
ui::saved(paths.markdown_path.display());
|
|
441
|
+
ui::saved(paths.json_path.display());
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
if let Some(folder) = temp_dir {
|
|
446
|
+
let _ = fs::remove_dir_all(folder);
|
|
447
|
+
}
|
|
448
|
+
|
|
219
449
|
Ok(())
|
|
220
450
|
}
|
|
221
451
|
|
|
@@ -240,7 +470,14 @@ fn save_lighthouse_summary(
|
|
|
240
470
|
Ok(())
|
|
241
471
|
}
|
|
242
472
|
|
|
243
|
-
fn
|
|
473
|
+
fn write_save_file(folder: &Path, filename: &str, content: &str) -> Result<PathBuf> {
|
|
474
|
+
fs::create_dir_all(folder).with_context(|| format!("Creating {}", folder.display()))?;
|
|
475
|
+
let path = folder.join(filename);
|
|
476
|
+
fs::write(&path, content).with_context(|| format!("Writing {}", path.display()))?;
|
|
477
|
+
Ok(path)
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
fn lighthouse_temp_dir(workspace: &Workspace) -> PathBuf {
|
|
244
481
|
workspace.root.join("target").join(format!(
|
|
245
482
|
"auditkit-lighthouse-{}-{}",
|
|
246
483
|
process::id(),
|
|
@@ -248,16 +485,6 @@ fn lighthouse_temp_dir(workspace: &Workspace) -> std::path::PathBuf {
|
|
|
248
485
|
))
|
|
249
486
|
}
|
|
250
487
|
|
|
251
|
-
fn inspect(workspace: &Workspace, target: Option<&str>) -> Result<()> {
|
|
252
|
-
let folder = workspace.resolve_target(target)?;
|
|
253
|
-
ui::section("Inspect");
|
|
254
|
-
ui::bullet(&format!("Running checks for {folder}"));
|
|
255
|
-
check(workspace, &folder, None)?;
|
|
256
|
-
security_check(workspace, Some(&folder), None)?;
|
|
257
|
-
lighthouse_check(workspace, Some(&folder), None)?;
|
|
258
|
-
Ok(())
|
|
259
|
-
}
|
|
260
|
-
|
|
261
488
|
fn generate_report(workspace: &Workspace, target: Option<&str>) -> Result<()> {
|
|
262
489
|
let folder = workspace.resolve_target(target)?;
|
|
263
490
|
let (report_path, email_path) =
|
|
@@ -280,6 +507,54 @@ fn audit_website(workspace: &Workspace, folder: &str) -> Result<String> {
|
|
|
280
507
|
Ok(parsed.website)
|
|
281
508
|
}
|
|
282
509
|
|
|
510
|
+
fn target_or_prompt(label: &str, placeholder: &str, target: Option<&str>) -> Result<TargetInput> {
|
|
511
|
+
if let Some(value) = target {
|
|
512
|
+
return Ok(TargetInput {
|
|
513
|
+
value: value.to_string(),
|
|
514
|
+
prompted: false,
|
|
515
|
+
});
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
if ui::can_prompt() {
|
|
519
|
+
return Ok(TargetInput {
|
|
520
|
+
value: ui::prompt_required(label, placeholder)?,
|
|
521
|
+
prompted: true,
|
|
522
|
+
});
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
anyhow::bail!("Missing target. Use `ak check <url>` or run `ak check` in a terminal.")
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
fn suggested_save_folder(target: &str) -> String {
|
|
529
|
+
let slug = Url::parse(target)
|
|
530
|
+
.ok()
|
|
531
|
+
.and_then(|url| url.host_str().map(slugify))
|
|
532
|
+
.filter(|slug| !slug.is_empty())
|
|
533
|
+
.unwrap_or_else(|| slugify(target));
|
|
534
|
+
let slug = if slug.is_empty() { "audit" } else { &slug };
|
|
535
|
+
format!("./auditkit-{slug}")
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
fn expand_path(value: &str) -> Result<PathBuf> {
|
|
539
|
+
let path = if value == "~" {
|
|
540
|
+
home_dir().context("HOME is not set")?
|
|
541
|
+
} else if let Some(rest) = value.strip_prefix("~/") {
|
|
542
|
+
home_dir().context("HOME is not set")?.join(rest)
|
|
543
|
+
} else {
|
|
544
|
+
PathBuf::from(value)
|
|
545
|
+
};
|
|
546
|
+
|
|
547
|
+
if path.is_absolute() {
|
|
548
|
+
Ok(path)
|
|
549
|
+
} else {
|
|
550
|
+
Ok(env::current_dir()?.join(path))
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
fn home_dir() -> Option<PathBuf> {
|
|
555
|
+
env::var_os("HOME").map(PathBuf::from)
|
|
556
|
+
}
|
|
557
|
+
|
|
283
558
|
fn looks_like_url(value: &str) -> bool {
|
|
284
559
|
value.starts_with("http://") || value.starts_with("https://") || value.contains('.')
|
|
285
560
|
}
|
package/src/ui.rs
CHANGED
|
@@ -49,6 +49,15 @@ pub fn saved(path: impl std::fmt::Display) {
|
|
|
49
49
|
println!("{} {}", "✓ saved".green().bold(), path);
|
|
50
50
|
}
|
|
51
51
|
|
|
52
|
+
pub fn step(number: usize, title: &str, detail: &str) {
|
|
53
|
+
println!(
|
|
54
|
+
" {} {} {}",
|
|
55
|
+
format!("{number}.").cyan().bold(),
|
|
56
|
+
title.bold(),
|
|
57
|
+
detail.dimmed()
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
|
|
52
61
|
pub fn error(message: impl std::fmt::Display) {
|
|
53
62
|
eprintln!("{} {}", "✕ error".red().bold(), message);
|
|
54
63
|
}
|
|
@@ -145,13 +154,12 @@ pub fn help() {
|
|
|
145
154
|
"{}",
|
|
146
155
|
"├──────────────────────────────────────────────────────────────────────────┤".cyan()
|
|
147
156
|
);
|
|
148
|
-
help_row("ak
|
|
149
|
-
help_row("ak
|
|
150
|
-
help_row("ak
|
|
151
|
-
help_row("ak
|
|
152
|
-
help_row("ak
|
|
153
|
-
help_row("ak
|
|
154
|
-
help_row("ak lighthouse <url>", "quick one-off Lighthouse check");
|
|
157
|
+
help_row("ak check", "guided website check");
|
|
158
|
+
help_row("ak check <url> --save ~/audits", "save files anywhere");
|
|
159
|
+
help_row("ak new", "create a full audit workspace");
|
|
160
|
+
help_row("ak inspect latest", "run every check for an audit");
|
|
161
|
+
help_row("ak report latest", "create report and email");
|
|
162
|
+
help_row("ak list", "show saved audit workspaces");
|
|
155
163
|
println!(
|
|
156
164
|
"{}",
|
|
157
165
|
"╰──────────────────────────────────────────────────────────────────────────╯".cyan()
|
|
@@ -159,16 +167,20 @@ pub fn help() {
|
|
|
159
167
|
}
|
|
160
168
|
|
|
161
169
|
pub fn collect_audit_input() -> Result<NewAuditAnswers> {
|
|
162
|
-
if
|
|
170
|
+
if can_prompt() {
|
|
163
171
|
run_form()
|
|
164
172
|
} else {
|
|
165
173
|
fallback_prompts()
|
|
166
174
|
}
|
|
167
175
|
}
|
|
168
176
|
|
|
177
|
+
pub fn can_prompt() -> bool {
|
|
178
|
+
io::stdin().is_terminal() && io::stdout().is_terminal()
|
|
179
|
+
}
|
|
180
|
+
|
|
169
181
|
fn help_row(command: &str, description: &str) {
|
|
170
|
-
let command = format!("{command:<
|
|
171
|
-
let description = format!("{description:<
|
|
182
|
+
let command = format!("{command:<31}").bold();
|
|
183
|
+
let description = format!("{description:<39}").dimmed();
|
|
172
184
|
println!("{} {} {} {}", "│".cyan(), command, description, "│".cyan());
|
|
173
185
|
}
|
|
174
186
|
|
|
@@ -194,6 +206,64 @@ fn prompt(label: &str) -> Result<String> {
|
|
|
194
206
|
Ok(value.trim().to_string())
|
|
195
207
|
}
|
|
196
208
|
|
|
209
|
+
pub fn prompt_required(label: &str, placeholder: &str) -> Result<String> {
|
|
210
|
+
loop {
|
|
211
|
+
let suffix = if placeholder.is_empty() {
|
|
212
|
+
String::new()
|
|
213
|
+
} else {
|
|
214
|
+
format!(" {}", format!("({placeholder})").dimmed())
|
|
215
|
+
};
|
|
216
|
+
print!("{} {}{} ", "›".cyan().bold(), label.bold(), suffix);
|
|
217
|
+
io::stdout().flush()?;
|
|
218
|
+
let mut value = String::new();
|
|
219
|
+
io::stdin().read_line(&mut value)?;
|
|
220
|
+
let value = value.trim();
|
|
221
|
+
if !value.is_empty() {
|
|
222
|
+
return Ok(value.to_string());
|
|
223
|
+
}
|
|
224
|
+
println!("{}", " Please enter a value.".yellow());
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
pub fn prompt_with_default(label: &str, default: &str) -> Result<String> {
|
|
229
|
+
print!(
|
|
230
|
+
"{} {} {} ",
|
|
231
|
+
"›".cyan().bold(),
|
|
232
|
+
label.bold(),
|
|
233
|
+
format!("({default})").dimmed()
|
|
234
|
+
);
|
|
235
|
+
io::stdout().flush()?;
|
|
236
|
+
let mut value = String::new();
|
|
237
|
+
io::stdin().read_line(&mut value)?;
|
|
238
|
+
let value = value.trim();
|
|
239
|
+
if value.is_empty() {
|
|
240
|
+
Ok(default.to_string())
|
|
241
|
+
} else {
|
|
242
|
+
Ok(value.to_string())
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
pub fn confirm(label: &str, default: bool) -> Result<bool> {
|
|
247
|
+
let hint = if default { "Y/n" } else { "y/N" };
|
|
248
|
+
loop {
|
|
249
|
+
print!(
|
|
250
|
+
"{} {} {} ",
|
|
251
|
+
"›".cyan().bold(),
|
|
252
|
+
label.bold(),
|
|
253
|
+
format!("({hint})").dimmed()
|
|
254
|
+
);
|
|
255
|
+
io::stdout().flush()?;
|
|
256
|
+
let mut value = String::new();
|
|
257
|
+
io::stdin().read_line(&mut value)?;
|
|
258
|
+
match value.trim().to_lowercase().as_str() {
|
|
259
|
+
"" => return Ok(default),
|
|
260
|
+
"y" | "yes" => return Ok(true),
|
|
261
|
+
"n" | "no" => return Ok(false),
|
|
262
|
+
_ => println!("{}", " Type yes or no.".yellow()),
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
197
267
|
fn run_form() -> Result<NewAuditAnswers> {
|
|
198
268
|
enable_raw_mode()?;
|
|
199
269
|
let mut stdout = io::stdout();
|