auditkit 0.1.4 → 0.1.6

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 CHANGED
@@ -90,7 +90,7 @@ checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0"
90
90
 
91
91
  [[package]]
92
92
  name = "auditkit"
93
- version = "0.1.4"
93
+ version = "0.1.6"
94
94
  dependencies = [
95
95
  "anyhow",
96
96
  "chrono",
package/Cargo.toml CHANGED
@@ -1,6 +1,6 @@
1
1
  [package]
2
2
  name = "auditkit"
3
- version = "0.1.4"
3
+ version = "0.1.6"
4
4
  edition = "2021"
5
5
  description = "Local hybrid CLI for agency website audits."
6
6
  license = "MIT"
package/README.md CHANGED
@@ -1,111 +1,98 @@
1
1
  # Audit Kit
2
2
 
3
- Local hybrid CLI for agency website audits.
3
+ Fast local website audits for freelancers and agencies. Run HTML, security, Lighthouse, and report-generation workflows from one small CLI.
4
4
 
5
- Rust runs the core workflow: audit folders, quick HTML checks, security checks, and report generation. Node is used only for Lighthouse.
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
- cargo build
14
- npm link
16
+ npm install -g auditkit
17
+ # or
18
+ bun add -g auditkit
15
19
  ```
16
20
 
17
- Prerequisites:
21
+ Requirements:
18
22
 
19
- - Rust toolchain with `cargo`
23
+ - Rust + `cargo`
20
24
  - Node.js 24+
21
- - Helium, Chrome, Chromium, Brave, or Edge for Lighthouse
25
+ - Chrome, Chromium, Brave, Edge, or Helium for Lighthouse
22
26
 
23
- ## Basic Workflow
27
+ ## Quick start
24
28
 
25
29
  ```bash
26
- ak new
27
- ak inspect latest
28
- ak report latest
30
+ ak check
29
31
  ```
30
32
 
31
- `ak new` creates a workspace in `audits/`. Fill in:
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
- ## Commands
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
- - `ak new` - create a new audit folder
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
- The check is intentionally lightweight. It reviews basic HTML signals:
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
- ## Lighthouse Requirement
91
-
92
- `ak lighthouse` and `ak inspect` need a Chrome-family browser installed:
54
+ ```text
55
+ ~/audits/example/
56
+ ├─ automated-check.md
57
+ ├─ security-check.md
58
+ ├─ lighthouse.md
59
+ └─ lighthouse.json
60
+ ```
93
61
 
94
- - Helium
95
- - Google Chrome
96
- - Chromium
97
- - Brave
98
- - Microsoft Edge
62
+ ## Full client workflow
99
63
 
100
- If none is installed, Lighthouse cannot run.
64
+ ```bash
65
+ ak new
66
+ ak inspect latest
67
+ ak report latest
68
+ ```
101
69
 
102
- Audit Kit auto-detects Helium at:
70
+ That creates an audit workspace, runs every check, then writes:
103
71
 
104
72
  ```text
105
- /Applications/Helium.app/Contents/MacOS/Helium
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
- Override browser path when needed:
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "auditkit",
3
- "version": "0.1.4",
3
+ "version": "0.1.6",
4
4
  "description": "Local hybrid CLI for agency website audits.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -32,7 +32,6 @@
32
32
  "audit:security": "cargo run --quiet -- security",
33
33
  "audit:lighthouse": "cargo run --quiet -- lighthouse",
34
34
  "audit:inspect": "cargo run --quiet -- inspect",
35
- "postinstall": "node scripts/auditkit/postinstall.mjs",
36
35
  "test": "npm run test:node && npm run test:rust",
37
36
  "test:node": "node --test",
38
37
  "test:rust": "cargo test"
@@ -19,6 +19,23 @@ SCRIPT_DIR="$(cd "$(dirname "$SOURCE")" && pwd)"
19
19
  ROOT_DIR="$(cd "$SCRIPT_DIR/../.." && pwd)"
20
20
  BINARY="$ROOT_DIR/target/debug/auditkit"
21
21
 
22
+ if [ -z "${CI:-}" ] && [ -z "${AUDITKIT_SKIP_WELCOME:-}" ]; then
23
+ if [ -n "${XDG_STATE_HOME:-}" ]; then
24
+ WELCOME_DIR="$XDG_STATE_HOME/auditkit"
25
+ elif [ -n "${HOME:-}" ]; then
26
+ WELCOME_DIR="$HOME/.local/state/auditkit"
27
+ else
28
+ WELCOME_DIR=""
29
+ fi
30
+
31
+ if [ -n "$WELCOME_DIR" ] && [ ! -f "$WELCOME_DIR/welcome-v0.1.6" ]; then
32
+ if command -v node >/dev/null 2>&1; then
33
+ node "$ROOT_DIR/scripts/auditkit/postinstall.mjs" || true
34
+ fi
35
+ mkdir -p "$WELCOME_DIR" 2>/dev/null && : >"$WELCOME_DIR/welcome-v0.1.6" 2>/dev/null || true
36
+ fi
37
+ fi
38
+
22
39
  if [ ! -x "$BINARY" ]; then
23
40
  cargo build --manifest-path "$ROOT_DIR/Cargo.toml" >/dev/null
24
41
  fi
@@ -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 new/);
87
- assert.match(message, /ak inspect latest/);
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 new", "create an audit workspace", colors),
18
- commandLine("ak inspect latest", "run check, security, Lighthouse", colors),
19
- commandLine("ak report latest", "generate report + client email", colors),
20
- commandLine("ak list", "show saved audits", colors),
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
- if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href && shouldShowWelcome()) {
35
- console.log(welcomeMessage());
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(18), 36, colors)} ${paint(description.padEnd(34), 90, colors)} `, colors);
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::{fs, process};
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 }) => check(&workspace, &target, save.as_deref()),
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.as_deref())
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.as_deref())
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: &str, save: Option<&str>) -> Result<()> {
117
- if looks_like_url(target) {
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(folder) = save {
123
- let folder = workspace.resolve_target(Some(folder))?;
124
- let path = workspace.update_workspace_section(
125
- &folder,
126
- "Automated Check",
127
- &html_check::format_markdown(&result),
128
- )?;
129
- ui::saved(path.display());
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 path = workspace.update_workspace_section(
141
- &folder,
142
- "Automated Check",
143
- &html_check::format_markdown(&result),
144
- )?;
145
- ui::saved(path.display());
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(workspace: &Workspace, target: Option<&str>, save: Option<&str>) -> Result<()> {
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(folder) = save {
156
- let folder = workspace.resolve_target(Some(folder))?;
157
- let path = workspace.update_workspace_section(
158
- &folder,
159
- "Security Check",
160
- &security::format_markdown(&result),
161
- )?;
162
- ui::saved(path.display());
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 path = workspace.update_workspace_section(
174
- &folder,
175
- "Security Check",
176
- &security::format_markdown(&result),
177
- )?;
178
- ui::saved(path.display());
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(workspace: &Workspace, target: Option<&str>, save: Option<&str>) -> Result<()> {
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 save_folder = save
187
- .map(|folder| workspace.resolve_target(Some(folder)))
188
- .transpose()?;
189
- let temp_dir = save_folder.as_ref().map(|_| lighthouse_temp_dir(workspace));
190
- if let Some(folder) = &temp_dir {
191
- fs::create_dir_all(folder)?;
192
- }
193
- let paths = ui::with_task("Running Lighthouse in Helium", || {
194
- lighthouse::run_lighthouse(&workspace.root, target, temp_dir.as_deref())
195
- })?;
196
- print_lighthouse_output(&paths.cli_output);
197
- if let Some(folder) = save_folder {
198
- save_lighthouse_summary(workspace, &folder, &paths)?;
199
- } else {
200
- ui::saved(paths.markdown_path.display());
201
- ui::saved(paths.json_path.display());
202
- }
203
- if let Some(folder) = temp_dir {
204
- let _ = fs::remove_dir_all(folder);
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
- let temp_dir = lighthouse_temp_dir(workspace);
212
- fs::create_dir_all(&temp_dir)?;
213
- let paths = ui::with_task("Running Lighthouse in Helium", || {
214
- lighthouse::run_lighthouse(&workspace.root, &website, Some(&temp_dir))
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
- save_lighthouse_summary(workspace, &folder, &paths)?;
218
- let _ = fs::remove_dir_all(temp_dir);
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 lighthouse_temp_dir(workspace: &Workspace) -> std::path::PathBuf {
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 new", "create an audit workspace");
149
- help_row("ak list", "show audit folders");
150
- help_row("ak inspect latest", "run check, security, and Lighthouse");
151
- help_row("ak report latest", "create final report and client email");
152
- help_row("ak check <url>", "quick one-off website feedback");
153
- help_row("ak security <url>", "quick one-off security check");
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 io::stdin().is_terminal() && io::stdout().is_terminal() {
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:<23}").bold();
171
- let description = format!("{description:<45}").dimmed();
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();