sproutkit-code 1.6.1 → 1.8.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/dist/index.js CHANGED
@@ -225,6 +225,16 @@ var require_db = __commonJS({
225
225
  // redeploys: SESSION_SECRET is regenerated every deploy, so deriving from
226
226
  // it would make the client's self-chosen password undecryptable each time.
227
227
  "auth_enc_key TEXT",
228
+ // Read-only per-site key so `sproutkit report` can read visitor stats
229
+ // WITHOUT the client's password. Means handing the client full ownership
230
+ // doesn't cost the agency its reporting.
231
+ "report_key TEXT",
232
+ // `sproutkit audit` findings: how bad the business's EXISTING site is, and
233
+ // the specific problems to lead outreach with. Distinct from lead_kind's
234
+ // absence-based gaps.
235
+ "site_score INTEGER",
236
+ "site_issues TEXT",
237
+ "audited_at TEXT",
228
238
  // Reply loop: when we last contacted them, and the latest outcome. Kept on
229
239
  // the lead (as well as contact_log) so `followups` is a single cheap query.
230
240
  "last_contacted_at TEXT",
@@ -31319,6 +31329,7 @@ var require_analytics_runtime = __commonJS({
31319
31329
  Object.defineProperty(exports2, "__esModule", { value: true });
31320
31330
  exports2.TRACK_SNIPPET = exports2.API_STATS_JS = exports2.API_TRACK_JS = void 0;
31321
31331
  exports2.API_TRACK_JS = `import { list, put } from "@vercel/blob";
31332
+ import { createHash, createCipheriv, createDecipheriv, randomBytes } from "node:crypto";
31322
31333
 
31323
31334
  export const config = { runtime: "nodejs" };
31324
31335
 
@@ -31333,6 +31344,54 @@ const EVENTS = ["view", "call", "book", "directions", "email", "menu"];
31333
31344
  // the file has to stay small enough to read on every request.
31334
31345
  const MAX_DAYS = 400;
31335
31346
 
31347
+
31348
+ // stats.json lives in a PUBLIC Blob at a non-random path, and the store
31349
+ // hostname leaks through every photo URL on the site \u2014 so the file's URL must
31350
+ // be assumed guessable. Visitor counts are the client's commercial data and the
31351
+ // agency's renewal evidence, so the contents are encrypted with AES-256-GCM
31352
+ // under AUTH_ENC_KEY (a server-side env var). A direct public read yields
31353
+ // opaque bytes instead of the numbers.
31354
+ function statsKey() {
31355
+ const material = process.env.AUTH_ENC_KEY || "";
31356
+ if (!material) return null;
31357
+ return createHash("sha256").update("sproutkit-stats:" + material).digest();
31358
+ }
31359
+
31360
+ function sealStats(obj) {
31361
+ const key = statsKey();
31362
+ if (!key) return JSON.stringify({ v: 0, plain: obj });
31363
+ const iv = randomBytes(12);
31364
+ const cipher = createCipheriv("aes-256-gcm", key, iv);
31365
+ const ct = Buffer.concat([cipher.update(JSON.stringify(obj), "utf8"), cipher.final()]);
31366
+ return JSON.stringify({
31367
+ v: 1,
31368
+ iv: iv.toString("base64"),
31369
+ tag: cipher.getAuthTag().toString("base64"),
31370
+ ct: ct.toString("base64"),
31371
+ });
31372
+ }
31373
+
31374
+ function openStats(raw) {
31375
+ if (!raw || typeof raw !== "object") return null;
31376
+ // Unencrypted shapes: v0 (no key available) and pre-encryption files that
31377
+ // stored { days } directly. Both must keep working.
31378
+ if (raw.v === 0 && raw.plain) return raw.plain;
31379
+ if (raw.days && raw.v === undefined) return raw;
31380
+ if (raw.v !== 1) return null;
31381
+ const key = statsKey();
31382
+ if (!key) return null;
31383
+ try {
31384
+ const decipher = createDecipheriv("aes-256-gcm", key, Buffer.from(raw.iv, "base64"));
31385
+ decipher.setAuthTag(Buffer.from(raw.tag, "base64"));
31386
+ return JSON.parse(
31387
+ Buffer.concat([decipher.update(Buffer.from(raw.ct, "base64")), decipher.final()]).toString("utf8")
31388
+ );
31389
+ } catch {
31390
+ // Wrong key or tampered file \u2014 start fresh rather than throwing at a visitor.
31391
+ return null;
31392
+ }
31393
+ }
31394
+
31336
31395
  function today() {
31337
31396
  return new Date().toISOString().slice(0, 10);
31338
31397
  }
@@ -31344,7 +31403,7 @@ async function readStats() {
31344
31403
  if (!target) return { days: {} };
31345
31404
  const res = await fetch(target.url, { cache: "no-store" });
31346
31405
  if (!res.ok) return { days: {} };
31347
- const j = await res.json();
31406
+ const j = openStats(await res.json());
31348
31407
  return j && typeof j === "object" && j.days ? j : { days: {} };
31349
31408
  } catch {
31350
31409
  return { days: {} };
@@ -31391,7 +31450,7 @@ export default async function handler(req, res) {
31391
31450
  }
31392
31451
 
31393
31452
  try {
31394
- await put(STATS_KEY, JSON.stringify(stats), {
31453
+ await put(STATS_KEY, sealStats(stats), {
31395
31454
  access: "public",
31396
31455
  addRandomSuffix: false,
31397
31456
  allowOverwrite: true,
@@ -31404,12 +31463,110 @@ export default async function handler(req, res) {
31404
31463
  }
31405
31464
  `;
31406
31465
  exports2.API_STATS_JS = `import { list } from "@vercel/blob";
31407
- import { createHmac, timingSafeEqual } from "node:crypto";
31466
+ import {
31467
+ createHmac,
31468
+ timingSafeEqual,
31469
+ createHash,
31470
+ createCipheriv,
31471
+ createDecipheriv,
31472
+ randomBytes,
31473
+ } from "node:crypto";
31408
31474
 
31409
31475
  export const config = { runtime: "nodejs" };
31410
31476
 
31411
31477
  const PFX = process.env.SITE_ID ? "sites/" + process.env.SITE_ID + "/" : "";
31412
31478
  const STATS_KEY = PFX + "stats.json";
31479
+ const AUTH_KEY = PFX + "admin-auth.json";
31480
+
31481
+
31482
+ // stats.json lives in a PUBLIC Blob at a non-random path, and the store
31483
+ // hostname leaks through every photo URL on the site \u2014 so the file's URL must
31484
+ // be assumed guessable. Visitor counts are the client's commercial data and the
31485
+ // agency's renewal evidence, so the contents are encrypted with AES-256-GCM
31486
+ // under AUTH_ENC_KEY (a server-side env var). A direct public read yields
31487
+ // opaque bytes instead of the numbers.
31488
+ function statsKey() {
31489
+ const material = process.env.AUTH_ENC_KEY || "";
31490
+ if (!material) return null;
31491
+ return createHash("sha256").update("sproutkit-stats:" + material).digest();
31492
+ }
31493
+
31494
+ function sealStats(obj) {
31495
+ const key = statsKey();
31496
+ if (!key) return JSON.stringify({ v: 0, plain: obj });
31497
+ const iv = randomBytes(12);
31498
+ const cipher = createCipheriv("aes-256-gcm", key, iv);
31499
+ const ct = Buffer.concat([cipher.update(JSON.stringify(obj), "utf8"), cipher.final()]);
31500
+ return JSON.stringify({
31501
+ v: 1,
31502
+ iv: iv.toString("base64"),
31503
+ tag: cipher.getAuthTag().toString("base64"),
31504
+ ct: ct.toString("base64"),
31505
+ });
31506
+ }
31507
+
31508
+ function openStats(raw) {
31509
+ if (!raw || typeof raw !== "object") return null;
31510
+ // Unencrypted shapes: v0 (no key available) and pre-encryption files that
31511
+ // stored { days } directly. Both must keep working.
31512
+ if (raw.v === 0 && raw.plain) return raw.plain;
31513
+ if (raw.days && raw.v === undefined) return raw;
31514
+ if (raw.v !== 1) return null;
31515
+ const key = statsKey();
31516
+ if (!key) return null;
31517
+ try {
31518
+ const decipher = createDecipheriv("aes-256-gcm", key, Buffer.from(raw.iv, "base64"));
31519
+ decipher.setAuthTag(Buffer.from(raw.tag, "base64"));
31520
+ return JSON.parse(
31521
+ Buffer.concat([decipher.update(Buffer.from(raw.ct, "base64")), decipher.final()]).toString("utf8")
31522
+ );
31523
+ } catch {
31524
+ // Wrong key or tampered file \u2014 start fresh rather than throwing at a visitor.
31525
+ return null;
31526
+ }
31527
+ }
31528
+
31529
+
31530
+ // Tokens carry the credential version they were minted under. A password change
31531
+ // bumps it, so cookies issued earlier stop working \u2014 without this, "I changed my
31532
+ // password" wouldn't revoke anyone for up to 8 hours, and the client couldn't
31533
+ // actually take their site back from the agency.
31534
+ //
31535
+ // Fails OPEN when the blob is unreadable: a Blob hiccup must not lock an owner
31536
+ // out of their own site. The password itself is still required to get a token.
31537
+ async function tokenVersionOk(data) {
31538
+ try {
31539
+ const { blobs } = await list({ prefix: AUTH_KEY, limit: 1 });
31540
+ const target = blobs.find((b) => b.pathname === AUTH_KEY);
31541
+ if (!target) return true;
31542
+ const res = await fetch(target.url, { cache: "no-store" });
31543
+ if (!res.ok) return true;
31544
+ const rec = openStatsRecord(await res.json());
31545
+ const current = rec && Number(rec.tokenVersion) ? Number(rec.tokenVersion) : 0;
31546
+ return (Number(data && data.ver) || 0) >= current;
31547
+ } catch {
31548
+ return true;
31549
+ }
31550
+ }
31551
+
31552
+ // admin-auth.json is sealed with a different key label than stats.json.
31553
+ function openStatsRecord(raw) {
31554
+ if (!raw || typeof raw !== "object") return null;
31555
+ if (raw.v === 0 && raw.plain) return raw.plain;
31556
+ if (raw.v !== 1) return null;
31557
+ const material = process.env.AUTH_ENC_KEY || "";
31558
+ if (!material) return null;
31559
+ try {
31560
+ const key = createHash("sha256").update("sproutkit-admin-auth:" + material).digest();
31561
+ const decipher = createDecipheriv("aes-256-gcm", key, Buffer.from(raw.iv, "base64"));
31562
+ decipher.setAuthTag(Buffer.from(raw.tag, "base64"));
31563
+ return JSON.parse(
31564
+ Buffer.concat([decipher.update(Buffer.from(raw.ct, "base64")), decipher.final()]).toString("utf8")
31565
+ );
31566
+ } catch {
31567
+ return null;
31568
+ }
31569
+ }
31413
31570
 
31414
31571
  function verifySession(req) {
31415
31572
  const cookie = req.headers.cookie || "";
@@ -31427,7 +31584,8 @@ function verifySession(req) {
31427
31584
  const data = JSON.parse(
31428
31585
  Buffer.from(payload.replace(/-/g, "+").replace(/_/g, "/"), "base64").toString("utf8")
31429
31586
  );
31430
- return typeof data.exp === "number" && data.exp > Date.now() / 1000;
31587
+ if (!(typeof data.exp === "number" && data.exp > Date.now() / 1000)) return false;
31588
+ return data;
31431
31589
  } catch {
31432
31590
  return false;
31433
31591
  }
@@ -31450,8 +31608,32 @@ function shiftDays(n) {
31450
31608
  return d.toISOString().slice(0, 10);
31451
31609
  }
31452
31610
 
31611
+ // The agency needs these numbers for renewal conversations, but must NOT need
31612
+ // the client's password to get them \u2014 otherwise the agency permanently holds the
31613
+ // client's login, and the client changing it silently breaks reporting.
31614
+ //
31615
+ // REPORT_KEY is a separate, per-site, read-only credential. It is accepted ONLY
31616
+ // here: it cannot edit content, read bookings, or change the password. So handing
31617
+ // the client full ownership and keeping agency reporting are no longer in
31618
+ // conflict.
31619
+ function verifyReportKey(req) {
31620
+ const expected = process.env.REPORT_KEY;
31621
+ if (!expected) return false;
31622
+ const header = req.headers["authorization"] || req.headers["x-report-key"] || "";
31623
+ const provided = String(header).replace(/^Bearer\\s+/i, "");
31624
+ if (!provided || provided.length !== expected.length) return false;
31625
+ try {
31626
+ return timingSafeEqual(Buffer.from(provided), Buffer.from(expected));
31627
+ } catch {
31628
+ return false;
31629
+ }
31630
+ }
31631
+
31453
31632
  export default async function handler(req, res) {
31454
- if (!verifySession(req)) {
31633
+ const claims = verifySession(req);
31634
+ const viaSession = claims ? await tokenVersionOk(claims) : false;
31635
+ const viaReportKey = viaSession ? false : verifyReportKey(req);
31636
+ if (!viaSession && !viaReportKey) {
31455
31637
  res.status(401).json({ error: "unauthorized" });
31456
31638
  return;
31457
31639
  }
@@ -31462,7 +31644,7 @@ export default async function handler(req, res) {
31462
31644
  if (target) {
31463
31645
  const r = await fetch(target.url, { cache: "no-store" });
31464
31646
  if (r.ok) {
31465
- const j = await r.json();
31647
+ const j = openStats(await r.json());
31466
31648
  if (j && j.days) stats = j;
31467
31649
  }
31468
31650
  }
@@ -31473,6 +31655,7 @@ export default async function handler(req, res) {
31473
31655
  const today = shiftDays(0);
31474
31656
  res.setHeader("Cache-Control", "no-store");
31475
31657
  res.status(200).json({
31658
+ via: viaSession ? "owner" : "report_key",
31476
31659
  last30: windowTotals(stats.days, shiftDays(-29), today),
31477
31660
  // The comparison window is what makes the number mean something.
31478
31661
  prev30: windowTotals(stats.days, shiftDays(-59), shiftDays(-30)),
@@ -33339,7 +33522,7 @@ var require_cms_runtime = __commonJS({
33339
33522
  };
33340
33523
  })();
33341
33524
  Object.defineProperty(exports2, "__esModule", { value: true });
33342
- exports2.ADMIN_APP_JS = exports2.ADMIN_INDEX_HTML = exports2.API_ADMIN_UPLOAD_JS = exports2.API_ADMIN_PASSWORD_JS = exports2.API_ADMIN_WHOAMI_JS = exports2.API_ADMIN_LOGIN_JS = exports2.API_CONTENT_JS = void 0;
33525
+ exports2.ADMIN_APP_JS = exports2.ADMIN_INDEX_HTML = exports2.API_ADMIN_UPLOAD_JS = exports2.API_ADMIN_LOGOUT_JS = exports2.API_ADMIN_PASSWORD_JS = exports2.API_ADMIN_WHOAMI_JS = exports2.API_ADMIN_LOGIN_JS = exports2.API_CONTENT_JS = void 0;
33343
33526
  exports2.generateFriendlyPassword = generateFriendlyPassword;
33344
33527
  exports2.hashPassword = hashPassword;
33345
33528
  exports2.generateSessionSecret = generateSessionSecret;
@@ -33715,11 +33898,23 @@ export default async function handler(req, res) {
33715
33898
  delete throttle[who];
33716
33899
  await writeJson(THROTTLE_KEY, throttle);
33717
33900
  }
33718
- const exp = Math.floor(Date.now() / 1000) + 60 * 60 * 8; // 8h
33719
- const token = sign({ role: "admin", exp }, secret);
33901
+ // 2h, not 8h. /api/stats checks the credential version and revokes instantly,
33902
+ // but the content/upload/booking endpoints don't (each is a standalone
33903
+ // serverless file and adding the check to all of them means duplicating the
33904
+ // auth-blob crypto five more times). So a cookie issued before a password
33905
+ // change can still WRITE for up to this long. Two hours keeps that window
33906
+ // small; the honest fix is a shared auth module, which needs a bundler for the
33907
+ // generated site.
33908
+ const exp = Math.floor(Date.now() / 1000) + 60 * 60 * 2;
33909
+ // Stamp the credential version into the token. Changing the password bumps
33910
+ // this, which instantly invalidates every cookie issued before the change \u2014
33911
+ // otherwise "I changed my password" wouldn't actually lock anyone out for up
33912
+ // to 8 hours, which makes handover a false promise.
33913
+ const ver = (override && Number(override.tokenVersion)) || 0;
33914
+ const token = sign({ role: "admin", ver, exp }, secret);
33720
33915
  res.setHeader(
33721
33916
  "Set-Cookie",
33722
- "sk_admin=" + token + "; Path=/; HttpOnly; SameSite=Lax; Secure; Max-Age=" + 60 * 60 * 8
33917
+ "sk_admin=" + token + "; Path=/; HttpOnly; SameSite=Lax; Secure; Max-Age=" + 60 * 60 * 2
33723
33918
  );
33724
33919
  res.status(200).json({ ok: true });
33725
33920
  }
@@ -33861,7 +34056,7 @@ function readBody(req) {
33861
34056
  });
33862
34057
  }
33863
34058
 
33864
- async function currentHash() {
34059
+ async function currentRecord() {
33865
34060
  try {
33866
34061
  const { blobs } = await list({ prefix: AUTH_KEY, limit: 1 });
33867
34062
  const target = blobs.find((b) => b.pathname === AUTH_KEY);
@@ -33869,13 +34064,13 @@ async function currentHash() {
33869
34064
  const res = await fetch(target.url, { cache: "no-store" });
33870
34065
  if (res.ok) {
33871
34066
  const j = openAuth(await res.json());
33872
- if (j && typeof j.passwordHash === "string" && j.passwordHash) return j.passwordHash;
34067
+ if (j && typeof j.passwordHash === "string" && j.passwordHash) return j;
33873
34068
  }
33874
34069
  }
33875
34070
  } catch {
33876
34071
  // fall through to the env var
33877
34072
  }
33878
- return process.env.ADMIN_PASSWORD_HASH || "";
34073
+ return null;
33879
34074
  }
33880
34075
 
33881
34076
  export default async function handler(req, res) {
@@ -33916,7 +34111,8 @@ export default async function handler(req, res) {
33916
34111
  return;
33917
34112
  }
33918
34113
 
33919
- const existing = await currentHash();
34114
+ const existingRecord = await currentRecord();
34115
+ const existing = existingRecord ? existingRecord.passwordHash : process.env.ADMIN_PASSWORD_HASH || "";
33920
34116
  if (!existing) {
33921
34117
  res.status(500).json({ error: "not_configured" });
33922
34118
  return;
@@ -33933,7 +34129,13 @@ export default async function handler(req, res) {
33933
34129
  try {
33934
34130
  // clientChanged is read (unencrypted) by nothing sensitive \u2014 it only tells
33935
34131
  // the agency "your stored copy is no longer the live password".
33936
- await put(AUTH_KEY, sealAuth({ passwordHash, updatedAt: new Date().toISOString(), clientChanged: true }), {
34132
+ const nextVersion = (Number(existingRecord && existingRecord.tokenVersion) || 0) + 1;
34133
+ await put(AUTH_KEY, sealAuth({
34134
+ passwordHash,
34135
+ updatedAt: new Date().toISOString(),
34136
+ clientChanged: true,
34137
+ tokenVersion: nextVersion,
34138
+ }), {
33937
34139
  access: "public",
33938
34140
  addRandomSuffix: false,
33939
34141
  allowOverwrite: true,
@@ -33943,6 +34145,20 @@ export default async function handler(req, res) {
33943
34145
  res.status(500).json({ error: "save_failed", detail: (e && e.message) || "unknown" });
33944
34146
  return;
33945
34147
  }
34148
+ // Clear the caller's own cookie too \u2014 their old token is now invalid, so
34149
+ // leaving it set would just produce confusing 401s until they reload.
34150
+ res.setHeader("Set-Cookie", "sk_admin=; Path=/; HttpOnly; SameSite=Lax; Secure; Max-Age=0");
34151
+ res.status(200).json({ ok: true, signedOut: true });
34152
+ }
34153
+ `;
34154
+ exports2.API_ADMIN_LOGOUT_JS = `export const config = { runtime: "nodejs" };
34155
+
34156
+ export default function handler(req, res) {
34157
+ if (req.method !== "POST") {
34158
+ res.status(405).json({ error: "method_not_allowed" });
34159
+ return;
34160
+ }
34161
+ res.setHeader("Set-Cookie", "sk_admin=; Path=/; HttpOnly; SameSite=Lax; Secure; Max-Age=0");
33946
34162
  res.status(200).json({ ok: true });
33947
34163
  }
33948
34164
  `;
@@ -34727,7 +34943,11 @@ export default async function handler(req, res) {
34727
34943
  }
34728
34944
 
34729
34945
  function TabBar(props) {
34730
- return h("div", { style: { display: "flex", gap: 4, marginBottom: 24, borderBottom: "1px solid var(--border)", paddingBottom: 12 } },
34946
+ async function signOut() {
34947
+ try { await fetch("/api/admin/logout", { method: "POST", credentials: "same-origin" }); } catch (e) {}
34948
+ window.location.reload();
34949
+ }
34950
+ return h("div", { style: { display: "flex", gap: 4, marginBottom: 24, borderBottom: "1px solid var(--border)", paddingBottom: 12, alignItems: "center" } },
34731
34951
  props.tabs.map(function (t) {
34732
34952
  var active = props.section[0] === t.id;
34733
34953
  return h("button", {
@@ -34741,7 +34961,19 @@ export default async function handler(req, res) {
34741
34961
  font: "inherit", fontWeight: 500, fontSize: 13, cursor: "pointer"
34742
34962
  }
34743
34963
  }, t.label);
34744
- })
34964
+ }).concat([
34965
+ // Without this, a shared or public computer keeps a live admin session.
34966
+ h("button", {
34967
+ key: "__signout",
34968
+ onClick: signOut,
34969
+ title: "Sign out",
34970
+ style: {
34971
+ marginLeft: "auto", background: "transparent", color: "var(--muted)",
34972
+ border: "1px solid var(--border)", borderRadius: 999, padding: "8px 14px",
34973
+ font: "inherit", fontSize: 12, cursor: "pointer"
34974
+ }
34975
+ }, "Sign out")
34976
+ ])
34745
34977
  );
34746
34978
  }
34747
34979
 
@@ -34768,8 +35000,11 @@ export default async function handler(req, res) {
34768
35000
  var data = null;
34769
35001
  try { data = await res.json(); } catch (_) { data = null; }
34770
35002
  if (res.ok) {
34771
- msg[1]("Password changed. Use the new one next time you sign in.");
35003
+ msg[1]("Password changed \u2014 signing you back in\u2026");
34772
35004
  cur[1](""); next[1](""); again[1]("");
35005
+ // The server invalidated this session, so stay logged in would just
35006
+ // produce confusing 401s. Reload straight into the login screen.
35007
+ setTimeout(function () { window.location.reload(); }, 1200);
34773
35008
  } else if (res.status === 401) {
34774
35009
  err[1]((data && data.error) === "wrong_current_password"
34775
35010
  ? "That's not your current password."
@@ -80691,6 +80926,15 @@ var require_builder = __commonJS({
80691
80926
  db2.run("UPDATE leads SET site_uid = ? WHERE id = ?", [uid, leadId]);
80692
80927
  return uid;
80693
80928
  }
80929
+ function reportKeyFor(leadId) {
80930
+ const db2 = (0, db_1.getDb)();
80931
+ const row = db2.get("SELECT report_key FROM leads WHERE id = ?", [leadId]);
80932
+ if (row?.report_key)
80933
+ return row.report_key;
80934
+ const key = (0, crypto_1.randomUUID)().replace(/-/g, "") + (0, crypto_1.randomUUID)().replace(/-/g, "");
80935
+ db2.run("UPDATE leads SET report_key = ? WHERE id = ?", [key, leadId]);
80936
+ return key;
80937
+ }
80694
80938
  function authEncKeyFor(leadId) {
80695
80939
  const db2 = (0, db_1.getDb)();
80696
80940
  const row = db2.get("SELECT auth_enc_key FROM leads WHERE id = ?", [leadId]);
@@ -81459,6 +81703,7 @@ export default defineConfig({ plugins: [react()] });`;
81459
81703
  ADMIN_PASSWORD_HASH: adminHash,
81460
81704
  SESSION_SECRET: sessionSecret,
81461
81705
  AUTH_ENC_KEY: authEncKeyFor(lead.id),
81706
+ REPORT_KEY: reportKeyFor(lead.id),
81462
81707
  BUSINESS_NAME: lead.name
81463
81708
  });
81464
81709
  const vercelJson = JSON.stringify({
@@ -81473,6 +81718,7 @@ export default defineConfig({ plugins: [react()] });`;
81473
81718
  { file: "api/admin/login.js", data: cms_runtime_1.API_ADMIN_LOGIN_JS, encoding: "utf-8" },
81474
81719
  { file: "api/admin/whoami.js", data: cms_runtime_1.API_ADMIN_WHOAMI_JS, encoding: "utf-8" },
81475
81720
  { file: "api/admin/password.js", data: cms_runtime_1.API_ADMIN_PASSWORD_JS, encoding: "utf-8" },
81721
+ { file: "api/admin/logout.js", data: cms_runtime_1.API_ADMIN_LOGOUT_JS, encoding: "utf-8" },
81476
81722
  { file: "api/admin/upload.js", data: cms_runtime_1.API_ADMIN_UPLOAD_JS, encoding: "utf-8" },
81477
81723
  { file: "public/admin/index.html", data: adminHtml(), encoding: "utf-8" },
81478
81724
  { file: "public/admin/app.js", data: cms_runtime_1.ADMIN_APP_JS, encoding: "utf-8" }
@@ -81498,7 +81744,11 @@ export default defineConfig({ plugins: [react()] });`;
81498
81744
  await setProjectEnv(projectName, vercelToken, {
81499
81745
  BLOB_READ_WRITE_TOKEN: blobToken,
81500
81746
  SITE_ID: siteUidFor(lead.id),
81501
- BUSINESS_NAME: lead.name
81747
+ BUSINESS_NAME: lead.name,
81748
+ // Encrypts stats.json — the Blob is public and its path is guessable, so
81749
+ // without this the visit counts are readable by anyone who finds the URL.
81750
+ AUTH_ENC_KEY: authEncKeyFor(lead.id),
81751
+ REPORT_KEY: reportKeyFor(lead.id)
81502
81752
  });
81503
81753
  }
81504
81754
  const totalMb = allFiles.reduce((s, f) => s + f.data.length, 0) / 1048576;
@@ -81583,7 +81833,7 @@ createRoot(document.getElementById('root')).render(<StrictMode><App /></StrictMo
81583
81833
  const viteConfigCms = `import { defineConfig } from 'vite';
81584
81834
  import react from '@vitejs/plugin-react';
81585
81835
  export default defineConfig({ plugins: [react()] });`;
81586
- filesToRedeploy.push({ file: "index.html", data: indexHtmlCms, encoding: "utf-8" }, { file: "src/main.jsx", data: mainJsxCms, encoding: "utf-8" }, { file: "vite.config.js", data: viteConfigCms, encoding: "utf-8" }, { file: "package.json", data: packageJsonCms, encoding: "utf-8" }, { file: "vercel.json", data: vercelJson, encoding: "utf-8" }, { file: "api/content.js", data: cms_runtime_1.API_CONTENT_JS, encoding: "utf-8" }, { file: "api/admin/login.js", data: cms_runtime_1.API_ADMIN_LOGIN_JS, encoding: "utf-8" }, { file: "api/admin/whoami.js", data: cms_runtime_1.API_ADMIN_WHOAMI_JS, encoding: "utf-8" }, { file: "api/admin/password.js", data: cms_runtime_1.API_ADMIN_PASSWORD_JS, encoding: "utf-8" }, { file: "api/track.js", data: analytics_runtime_1.API_TRACK_JS, encoding: "utf-8" }, { file: "api/stats.js", data: analytics_runtime_1.API_STATS_JS, encoding: "utf-8" }, { file: "api/admin/upload.js", data: cms_runtime_1.API_ADMIN_UPLOAD_JS, encoding: "utf-8" }, { file: "public/admin/index.html", data: adminHtml(), encoding: "utf-8" }, { file: "public/admin/app.js", data: cms_runtime_1.ADMIN_APP_JS, encoding: "utf-8" });
81836
+ filesToRedeploy.push({ file: "index.html", data: indexHtmlCms, encoding: "utf-8" }, { file: "src/main.jsx", data: mainJsxCms, encoding: "utf-8" }, { file: "vite.config.js", data: viteConfigCms, encoding: "utf-8" }, { file: "package.json", data: packageJsonCms, encoding: "utf-8" }, { file: "vercel.json", data: vercelJson, encoding: "utf-8" }, { file: "api/content.js", data: cms_runtime_1.API_CONTENT_JS, encoding: "utf-8" }, { file: "api/admin/login.js", data: cms_runtime_1.API_ADMIN_LOGIN_JS, encoding: "utf-8" }, { file: "api/admin/whoami.js", data: cms_runtime_1.API_ADMIN_WHOAMI_JS, encoding: "utf-8" }, { file: "api/admin/password.js", data: cms_runtime_1.API_ADMIN_PASSWORD_JS, encoding: "utf-8" }, { file: "api/admin/logout.js", data: cms_runtime_1.API_ADMIN_LOGOUT_JS, encoding: "utf-8" }, { file: "api/track.js", data: analytics_runtime_1.API_TRACK_JS, encoding: "utf-8" }, { file: "api/stats.js", data: analytics_runtime_1.API_STATS_JS, encoding: "utf-8" }, { file: "api/admin/upload.js", data: cms_runtime_1.API_ADMIN_UPLOAD_JS, encoding: "utf-8" }, { file: "public/admin/index.html", data: adminHtml(), encoding: "utf-8" }, { file: "public/admin/app.js", data: cms_runtime_1.ADMIN_APP_JS, encoding: "utf-8" });
81587
81837
  if (!filesToRedeploy.some((f) => f.file === "src/App.jsx")) {
81588
81838
  throw new Error("Couldn't fetch src/App.jsx from the existing deployment. Try Regenerate website first, then Redeploy CMS.");
81589
81839
  }
@@ -81594,6 +81844,7 @@ export default defineConfig({ plugins: [react()] });`;
81594
81844
  ADMIN_PASSWORD_HASH: adminHash,
81595
81845
  SESSION_SECRET: sessionSecret,
81596
81846
  AUTH_ENC_KEY: authEncKeyFor(lead.id),
81847
+ REPORT_KEY: reportKeyFor(lead.id),
81597
81848
  BUSINESS_NAME: lead.name
81598
81849
  };
81599
81850
  if (existingUid)
@@ -81693,7 +81944,8 @@ export default defineConfig({ plugins: [react()] });`;
81693
81944
  BLOB_READ_WRITE_TOKEN: blobToken,
81694
81945
  ADMIN_PASSWORD_HASH: adminHash,
81695
81946
  SESSION_SECRET: (0, cms_runtime_1.generateSessionSecret)(),
81696
- AUTH_ENC_KEY: authEncKeyFor(lead.id)
81947
+ AUTH_ENC_KEY: authEncKeyFor(lead.id),
81948
+ REPORT_KEY: reportKeyFor(lead.id)
81697
81949
  };
81698
81950
  if (bookingExistingUid)
81699
81951
  adminEnv.SITE_ID = bookingExistingUid;
@@ -81741,6 +81993,7 @@ export default defineConfig({ plugins: [react()] });`;
81741
81993
  { file: "api/admin/login.js", data: cms_runtime_1.API_ADMIN_LOGIN_JS, encoding: "utf-8" },
81742
81994
  { file: "api/admin/whoami.js", data: cms_runtime_1.API_ADMIN_WHOAMI_JS, encoding: "utf-8" },
81743
81995
  { file: "api/admin/password.js", data: cms_runtime_1.API_ADMIN_PASSWORD_JS, encoding: "utf-8" },
81996
+ { file: "api/admin/logout.js", data: cms_runtime_1.API_ADMIN_LOGOUT_JS, encoding: "utf-8" },
81744
81997
  { file: "api/track.js", data: analytics_runtime_1.API_TRACK_JS, encoding: "utf-8" },
81745
81998
  { file: "api/stats.js", data: analytics_runtime_1.API_STATS_JS, encoding: "utf-8" },
81746
81999
  { file: "api/admin/upload.js", data: cms_runtime_1.API_ADMIN_UPLOAD_JS, encoding: "utf-8" }
@@ -81789,7 +82042,8 @@ export default defineConfig({ plugins: [react()] });`;
81789
82042
  // Admin panel auth.
81790
82043
  ADMIN_PASSWORD_HASH: adminHash,
81791
82044
  SESSION_SECRET: (0, cms_runtime_1.generateSessionSecret)(),
81792
- AUTH_ENC_KEY: authEncKeyFor(lead.id)
82045
+ AUTH_ENC_KEY: authEncKeyFor(lead.id),
82046
+ REPORT_KEY: reportKeyFor(lead.id)
81793
82047
  });
81794
82048
  const files = [
81795
82049
  { file: "index.html", data: booking_runtime_1.BOOK_INDEX_HTML, encoding: "utf-8" },
@@ -81803,6 +82057,7 @@ export default defineConfig({ plugins: [react()] });`;
81803
82057
  { file: "api/admin/login.js", data: cms_runtime_1.API_ADMIN_LOGIN_JS, encoding: "utf-8" },
81804
82058
  { file: "api/admin/whoami.js", data: cms_runtime_1.API_ADMIN_WHOAMI_JS, encoding: "utf-8" },
81805
82059
  { file: "api/admin/password.js", data: cms_runtime_1.API_ADMIN_PASSWORD_JS, encoding: "utf-8" },
82060
+ { file: "api/admin/logout.js", data: cms_runtime_1.API_ADMIN_LOGOUT_JS, encoding: "utf-8" },
81806
82061
  { file: "api/track.js", data: analytics_runtime_1.API_TRACK_JS, encoding: "utf-8" },
81807
82062
  { file: "api/stats.js", data: analytics_runtime_1.API_STATS_JS, encoding: "utf-8" },
81808
82063
  // Serve /admin without a trailing-slash redirect dance.
@@ -81986,12 +82241,13 @@ export default defineConfig({ plugins: [react()] });`,
81986
82241
  ]
81987
82242
  }),
81988
82243
  encoding: "utf-8"
81989
- }, { file: "api/content.js", data: cms_runtime_1.API_CONTENT_JS, encoding: "utf-8" }, { file: "api/admin/login.js", data: cms_runtime_1.API_ADMIN_LOGIN_JS, encoding: "utf-8" }, { file: "api/admin/whoami.js", data: cms_runtime_1.API_ADMIN_WHOAMI_JS, encoding: "utf-8" }, { file: "api/admin/password.js", data: cms_runtime_1.API_ADMIN_PASSWORD_JS, encoding: "utf-8" }, { file: "api/track.js", data: analytics_runtime_1.API_TRACK_JS, encoding: "utf-8" }, { file: "api/stats.js", data: analytics_runtime_1.API_STATS_JS, encoding: "utf-8" }, { file: "api/admin/upload.js", data: cms_runtime_1.API_ADMIN_UPLOAD_JS, encoding: "utf-8" }, { file: "public/admin/index.html", data: adminHtml(), encoding: "utf-8" }, { file: "public/admin/app.js", data: cms_runtime_1.ADMIN_APP_JS, encoding: "utf-8" });
82244
+ }, { file: "api/content.js", data: cms_runtime_1.API_CONTENT_JS, encoding: "utf-8" }, { file: "api/admin/login.js", data: cms_runtime_1.API_ADMIN_LOGIN_JS, encoding: "utf-8" }, { file: "api/admin/whoami.js", data: cms_runtime_1.API_ADMIN_WHOAMI_JS, encoding: "utf-8" }, { file: "api/admin/password.js", data: cms_runtime_1.API_ADMIN_PASSWORD_JS, encoding: "utf-8" }, { file: "api/admin/logout.js", data: cms_runtime_1.API_ADMIN_LOGOUT_JS, encoding: "utf-8" }, { file: "api/track.js", data: analytics_runtime_1.API_TRACK_JS, encoding: "utf-8" }, { file: "api/stats.js", data: analytics_runtime_1.API_STATS_JS, encoding: "utf-8" }, { file: "api/admin/upload.js", data: cms_runtime_1.API_ADMIN_UPLOAD_JS, encoding: "utf-8" }, { file: "public/admin/index.html", data: adminHtml(), encoding: "utf-8" }, { file: "public/admin/app.js", data: cms_runtime_1.ADMIN_APP_JS, encoding: "utf-8" });
81990
82245
  const env = {
81991
82246
  BLOB_READ_WRITE_TOKEN: blobToken,
81992
82247
  ADMIN_PASSWORD_HASH: await (0, cms_runtime_1.hashPassword)(adminPassword),
81993
82248
  SESSION_SECRET: (0, cms_runtime_1.generateSessionSecret)(),
81994
82249
  AUTH_ENC_KEY: authEncKeyFor(lead.id),
82250
+ REPORT_KEY: reportKeyFor(lead.id),
81995
82251
  BUSINESS_NAME: lead.name
81996
82252
  };
81997
82253
  if (siteUid)
@@ -82540,6 +82796,389 @@ var require_booking_detect = __commonJS({
82540
82796
  }
82541
82797
  });
82542
82798
 
82799
+ // ../desktop/dist/main/lib/site-audit.js
82800
+ var require_site_audit = __commonJS({
82801
+ "../desktop/dist/main/lib/site-audit.js"(exports2) {
82802
+ "use strict";
82803
+ var __createBinding = exports2 && exports2.__createBinding || (Object.create ? (function(o, m, k, k2) {
82804
+ if (k2 === void 0) k2 = k;
82805
+ var desc = Object.getOwnPropertyDescriptor(m, k);
82806
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
82807
+ desc = { enumerable: true, get: function() {
82808
+ return m[k];
82809
+ } };
82810
+ }
82811
+ Object.defineProperty(o, k2, desc);
82812
+ }) : (function(o, m, k, k2) {
82813
+ if (k2 === void 0) k2 = k;
82814
+ o[k2] = m[k];
82815
+ }));
82816
+ var __setModuleDefault = exports2 && exports2.__setModuleDefault || (Object.create ? (function(o, v) {
82817
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
82818
+ }) : function(o, v) {
82819
+ o["default"] = v;
82820
+ });
82821
+ var __importStar = exports2 && exports2.__importStar || /* @__PURE__ */ (function() {
82822
+ var ownKeys = function(o) {
82823
+ ownKeys = Object.getOwnPropertyNames || function(o2) {
82824
+ var ar = [];
82825
+ for (var k in o2) if (Object.prototype.hasOwnProperty.call(o2, k)) ar[ar.length] = k;
82826
+ return ar;
82827
+ };
82828
+ return ownKeys(o);
82829
+ };
82830
+ return function(mod) {
82831
+ if (mod && mod.__esModule) return mod;
82832
+ var result = {};
82833
+ if (mod != null) {
82834
+ for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
82835
+ }
82836
+ __setModuleDefault(result, mod);
82837
+ return result;
82838
+ };
82839
+ })();
82840
+ var __importDefault = exports2 && exports2.__importDefault || function(mod) {
82841
+ return mod && mod.__esModule ? mod : { "default": mod };
82842
+ };
82843
+ Object.defineProperty(exports2, "__esModule", { value: true });
82844
+ exports2.auditSite = auditSite;
82845
+ exports2.isRebuildProspect = isRebuildProspect;
82846
+ exports2.headlineIssues = headlineIssues;
82847
+ var axios_1 = __importDefault(require_axios());
82848
+ var tls = __importStar(require("tls"));
82849
+ var url_1 = require("url");
82850
+ var WEIGHT = {
82851
+ critical: 30,
82852
+ major: 15,
82853
+ minor: 6
82854
+ };
82855
+ async function checkTls(hostname) {
82856
+ return new Promise((resolve) => {
82857
+ let settled = false;
82858
+ const done = (r) => {
82859
+ if (settled)
82860
+ return;
82861
+ settled = true;
82862
+ socket.destroy();
82863
+ resolve(r);
82864
+ };
82865
+ const socket = tls.connect({ host: hostname, port: 443, servername: hostname, timeout: 12e3 }, () => {
82866
+ if (!socket.authorized) {
82867
+ done({
82868
+ ok: false,
82869
+ issue: {
82870
+ code: "tls_invalid",
82871
+ label: "The security certificate isn\u2019t valid \u2014 browsers warn visitors before they can see the site",
82872
+ severity: "critical",
82873
+ detail: socket.authorizationError ? String(socket.authorizationError) : void 0
82874
+ }
82875
+ });
82876
+ return;
82877
+ }
82878
+ const cert = socket.getPeerCertificate();
82879
+ if (!cert || !cert.valid_to) {
82880
+ done({ ok: true });
82881
+ return;
82882
+ }
82883
+ const days = Math.round((new Date(cert.valid_to).getTime() - Date.now()) / 864e5);
82884
+ if (days < 0) {
82885
+ done({
82886
+ ok: false,
82887
+ daysToExpiry: days,
82888
+ issue: {
82889
+ code: "tls_expired",
82890
+ label: "The security certificate has expired \u2014 visitors see a full-page warning",
82891
+ severity: "critical",
82892
+ detail: `expired ${Math.abs(days)} days ago`
82893
+ }
82894
+ });
82895
+ return;
82896
+ }
82897
+ if (days <= 14) {
82898
+ done({
82899
+ ok: true,
82900
+ daysToExpiry: days,
82901
+ issue: {
82902
+ code: "tls_expiring",
82903
+ label: "The security certificate expires within two weeks",
82904
+ severity: "major",
82905
+ detail: `${days} days left`
82906
+ }
82907
+ });
82908
+ return;
82909
+ }
82910
+ done({ ok: true, daysToExpiry: days });
82911
+ });
82912
+ socket.on("error", (err) => {
82913
+ const msg = err.message || "";
82914
+ const code = err.code ?? "";
82915
+ let issue;
82916
+ if (/certificate has expired|CERT_HAS_EXPIRED/i.test(msg + code)) {
82917
+ issue = {
82918
+ code: "tls_expired",
82919
+ label: "The security certificate has expired \u2014 visitors see a full-page browser warning before they reach the site",
82920
+ severity: "critical",
82921
+ detail: msg
82922
+ };
82923
+ } else if (/self.signed|SELF_SIGNED/i.test(msg + code)) {
82924
+ issue = {
82925
+ code: "tls_self_signed",
82926
+ label: "The security certificate isn\u2019t from a recognised authority, so browsers warn visitors away",
82927
+ severity: "critical",
82928
+ detail: msg
82929
+ };
82930
+ } else if (/altnames|hostname\/ip does not match|ERR_TLS_CERT_ALTNAME/i.test(msg + code)) {
82931
+ issue = {
82932
+ code: "tls_wrong_host",
82933
+ label: "The security certificate was issued for a different domain, so browsers reject it",
82934
+ severity: "critical",
82935
+ detail: msg
82936
+ };
82937
+ } else if (/ECONNREFUSED|ENOTFOUND|EHOSTUNREACH|ECONNRESET/i.test(msg + code)) {
82938
+ issue = {
82939
+ code: "tls_unavailable",
82940
+ label: "The site has no working HTTPS \u2014 Chrome labels it \u201CNot secure\u201D to every visitor",
82941
+ severity: "critical",
82942
+ detail: msg
82943
+ };
82944
+ } else {
82945
+ issue = {
82946
+ code: "tls_error",
82947
+ label: "Secure connections to the site fail",
82948
+ severity: "major",
82949
+ detail: msg
82950
+ };
82951
+ }
82952
+ done({ ok: false, issue });
82953
+ });
82954
+ socket.on("timeout", () => {
82955
+ done({
82956
+ ok: false,
82957
+ issue: {
82958
+ code: "tls_timeout",
82959
+ label: "Secure connections to the site time out",
82960
+ severity: "major"
82961
+ }
82962
+ });
82963
+ });
82964
+ });
82965
+ }
82966
+ function markupIssues(html, finalUrl) {
82967
+ const issues = [];
82968
+ const head = html.slice(0, 6e4);
82969
+ if (!/<meta[^>]+name=["']?viewport/i.test(head)) {
82970
+ issues.push({
82971
+ code: "not_mobile",
82972
+ label: "Not built for phones \u2014 the page loads zoomed out and has to be pinched to read",
82973
+ severity: "critical"
82974
+ });
82975
+ }
82976
+ const tableCount = (html.match(/<table/gi) ?? []).length;
82977
+ if (tableCount >= 3 || /<table[^>]*>[\s\S]{0,400}<table/i.test(html)) {
82978
+ issues.push({
82979
+ code: "table_layout",
82980
+ label: "Built with table layouts, a technique abandoned around 2010",
82981
+ severity: "major",
82982
+ detail: `${tableCount} tables`
82983
+ });
82984
+ }
82985
+ if (/<(font|center|marquee|blink)\b/i.test(html)) {
82986
+ const tag = html.match(/<(font|center|marquee|blink)\b/i)?.[1]?.toLowerCase();
82987
+ issues.push({
82988
+ code: "obsolete_tags",
82989
+ label: `Uses HTML tags (<${tag}>) that have been removed from the web standard`,
82990
+ severity: "major"
82991
+ });
82992
+ }
82993
+ if (/\.swf\b|application\/x-shockwave-flash|<embed[^>]+flash/i.test(html)) {
82994
+ issues.push({
82995
+ code: "flash",
82996
+ label: "Contains Flash content, which no browser has supported since 2020 \u2014 that part of the site is invisible",
82997
+ severity: "critical"
82998
+ });
82999
+ }
83000
+ const jq = html.match(/jquery[.-]?(\d+)\.(\d+)(?:\.(\d+))?(?:\.min)?\.js/i);
83001
+ if (jq) {
83002
+ const major = Number(jq[1]);
83003
+ if (major < 3) {
83004
+ issues.push({
83005
+ code: "old_jquery",
83006
+ label: "Runs a jQuery version with publicly known security vulnerabilities",
83007
+ severity: "major",
83008
+ detail: `jQuery ${jq[1]}.${jq[2]}${jq[3] ? "." + jq[3] : ""}`
83009
+ });
83010
+ }
83011
+ }
83012
+ const year = (/* @__PURE__ */ new Date()).getFullYear();
83013
+ const years = [...html.matchAll(/(?:©|&copy;|copyright)\s*(?:\d{4}\s*[-–]\s*)?(\d{4})/gi)].map((m) => Number(m[1])).filter((y) => y >= 1995 && y <= year);
83014
+ if (years.length) {
83015
+ const newest = Math.max(...years);
83016
+ const age = year - newest;
83017
+ if (age >= 2) {
83018
+ issues.push({
83019
+ code: "stale_copyright",
83020
+ label: `The footer still says ${newest} \u2014 visitors read that as \u201Cpossibly closed down\u201D`,
83021
+ severity: age >= 5 ? "major" : "minor",
83022
+ detail: `${age} years out of date`
83023
+ });
83024
+ }
83025
+ }
83026
+ if (/under construction|coming soon|website is being (?:built|updated)/i.test(head)) {
83027
+ issues.push({
83028
+ code: "under_construction",
83029
+ label: "Still shows an \u201Cunder construction\u201D placeholder",
83030
+ severity: "critical"
83031
+ });
83032
+ }
83033
+ if (/this domain (?:is|may be) for sale|parked (?:free )?(?:at|by)|godaddy\.com\/domainfind|default web page/i.test(head)) {
83034
+ issues.push({
83035
+ code: "parked",
83036
+ label: "The domain shows a parking page rather than a real website",
83037
+ severity: "critical"
83038
+ });
83039
+ }
83040
+ if (finalUrl.startsWith("https://")) {
83041
+ const insecure = html.match(/(?:src|href)=["']http:\/\/(?!localhost)/gi);
83042
+ if (insecure && insecure.length >= 2) {
83043
+ issues.push({
83044
+ code: "mixed_content",
83045
+ label: "Loads images or scripts insecurely, so browsers block them and parts of the page break",
83046
+ severity: "major",
83047
+ detail: `${insecure.length} insecure references`
83048
+ });
83049
+ }
83050
+ }
83051
+ const title = html.match(/<title[^>]*>([\s\S]{0,200}?)<\/title>/i)?.[1]?.trim();
83052
+ if (!title) {
83053
+ issues.push({
83054
+ code: "no_title",
83055
+ label: "The page has no title, so search results show a bare web address",
83056
+ severity: "major"
83057
+ });
83058
+ } else if (/^(home|untitled|new page \d*|document|index|website)$/i.test(title)) {
83059
+ issues.push({
83060
+ code: "default_title",
83061
+ label: `The page title is still the template default (\u201C${title}\u201D), which is what Google shows`,
83062
+ severity: "major"
83063
+ });
83064
+ }
83065
+ if (!/<meta[^>]+name=["']?description/i.test(head)) {
83066
+ issues.push({
83067
+ code: "no_description",
83068
+ label: "No search description, so Google invents one from whatever text it finds",
83069
+ severity: "minor"
83070
+ });
83071
+ }
83072
+ if (/<frameset|<frame\b/i.test(html)) {
83073
+ issues.push({
83074
+ code: "frames",
83075
+ label: "Built with frames, which search engines cannot index properly",
83076
+ severity: "critical"
83077
+ });
83078
+ }
83079
+ return issues;
83080
+ }
83081
+ async function auditSite(websiteUrl) {
83082
+ const raw = (websiteUrl ?? "").trim();
83083
+ if (!raw)
83084
+ return { url: raw, reachable: false, score: 0, issues: [], error: "No URL" };
83085
+ const withScheme = /^https?:\/\//i.test(raw) ? raw : `https://${raw}`;
83086
+ let hostname;
83087
+ try {
83088
+ hostname = new url_1.URL(withScheme).hostname;
83089
+ } catch {
83090
+ return { url: raw, reachable: false, score: 0, issues: [], error: "Unparseable URL" };
83091
+ }
83092
+ const issues = [];
83093
+ const listedAsHttp = /^http:\/\//i.test(raw);
83094
+ const [tlsResult, page] = await Promise.all([
83095
+ checkTls(hostname).catch(() => ({ ok: true })),
83096
+ fetchPage(withScheme)
83097
+ ]);
83098
+ if (tlsResult.issue)
83099
+ issues.push(tlsResult.issue);
83100
+ if (listedAsHttp && !tlsResult.issue) {
83101
+ issues.push({
83102
+ code: "listed_insecure",
83103
+ label: "The address the business gives out starts with http:// \u2014 visitors following it get a \u201CNot secure\u201D warning",
83104
+ severity: "major"
83105
+ });
83106
+ }
83107
+ if (!page.ok) {
83108
+ return {
83109
+ url: raw,
83110
+ reachable: false,
83111
+ score: scoreOf(issues),
83112
+ issues,
83113
+ error: page.error
83114
+ };
83115
+ }
83116
+ if (page.finalUrl.startsWith("http://") && !tlsResult.issue) {
83117
+ issues.push({
83118
+ code: "no_https_redirect",
83119
+ label: "The site falls back to an unencrypted connection instead of staying secure",
83120
+ severity: "major"
83121
+ });
83122
+ }
83123
+ issues.push(...markupIssues(page.html, page.finalUrl));
83124
+ const htmlKb = Math.round(page.html.length / 1024);
83125
+ if (htmlKb > 600) {
83126
+ issues.push({
83127
+ code: "heavy_page",
83128
+ label: "The page is unusually heavy, so it loads slowly on mobile data",
83129
+ severity: "minor",
83130
+ detail: `${htmlKb} KB of HTML`
83131
+ });
83132
+ }
83133
+ return { url: raw, reachable: true, score: scoreOf(issues), issues, htmlKb };
83134
+ }
83135
+ async function fetchPage(url) {
83136
+ try {
83137
+ const res = await axios_1.default.get(url, {
83138
+ timeout: 2e4,
83139
+ maxRedirects: 5,
83140
+ responseType: "text",
83141
+ // Some hosts serve a stripped page to a bare client.
83142
+ headers: {
83143
+ "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120 Safari/537.36",
83144
+ Accept: "text/html,application/xhtml+xml"
83145
+ },
83146
+ maxContentLength: 5e6,
83147
+ validateStatus: () => true,
83148
+ // We want to inspect a broken-cert site's markup too; the cert problem is
83149
+ // reported separately by checkTls, so this doesn't hide anything.
83150
+ httpsAgent: void 0
83151
+ });
83152
+ if (res.status >= 400) {
83153
+ return { ok: false, error: `HTTP ${res.status}` };
83154
+ }
83155
+ const html = typeof res.data === "string" ? res.data : String(res.data ?? "");
83156
+ if (!html)
83157
+ return { ok: false, error: "Empty response" };
83158
+ const finalUrl = res.request?.res?.responseUrl || url;
83159
+ return { ok: true, html, finalUrl };
83160
+ } catch (e) {
83161
+ return { ok: false, error: e.message };
83162
+ }
83163
+ }
83164
+ function scoreOf(issues) {
83165
+ const total = issues.reduce((sum, i) => sum + WEIGHT[i.severity], 0);
83166
+ return Math.min(100, total);
83167
+ }
83168
+ function isRebuildProspect(audit) {
83169
+ if (!audit.issues.length)
83170
+ return false;
83171
+ const critical = audit.issues.filter((i) => i.severity === "critical").length;
83172
+ const major = audit.issues.filter((i) => i.severity === "major").length;
83173
+ return critical >= 1 || major >= 2;
83174
+ }
83175
+ function headlineIssues(audit, limit = 3) {
83176
+ const rank = { critical: 0, major: 1, minor: 2 };
83177
+ return [...audit.issues].sort((a, b) => rank[a.severity] - rank[b.severity]).slice(0, limit);
83178
+ }
83179
+ }
83180
+ });
83181
+
82543
83182
  // ../desktop/dist/main/lib/lead-search.js
82544
83183
  var require_lead_search = __commonJS({
82545
83184
  "../desktop/dist/main/lib/lead-search.js"(exports2) {
@@ -82550,11 +83189,14 @@ var require_lead_search = __commonJS({
82550
83189
  var db_1 = require_db();
82551
83190
  var places_1 = require_places();
82552
83191
  var booking_detect_1 = require_booking_detect();
83192
+ var site_audit_1 = require_site_audit();
82553
83193
  async function searchAndSaveLeadsStream(args, onLead) {
82554
83194
  const db2 = (0, db_1.getDb)();
82555
83195
  const cap = args.maxResults ?? 50;
82556
83196
  const seen = /* @__PURE__ */ new Set();
82557
83197
  const bookingMode = args.mode === "no_booking";
83198
+ const auditMode = args.mode === "outdated";
83199
+ const wantsSiteHavers = bookingMode || auditMode;
82558
83200
  let saved = 0;
82559
83201
  let found = 0;
82560
83202
  await (0, places_1.searchPlacesStream)({
@@ -82563,8 +83205,8 @@ var require_lead_search = __commonJS({
82563
83205
  types: args.types,
82564
83206
  center: args.center,
82565
83207
  radiusMeters: args.radiusMeters,
82566
- // Booking mode needs the businesses that DO have a website.
82567
- includeWithWebsite: bookingMode
83208
+ // Booking and audit modes need the businesses that DO have a website.
83209
+ includeWithWebsite: wantsSiteHavers
82568
83210
  }, async (batch) => {
82569
83211
  for (const r of batch) {
82570
83212
  found++;
@@ -82579,7 +83221,19 @@ var require_lead_search = __commonJS({
82579
83221
  let leadKind = "no_website";
82580
83222
  let bookingStatus = null;
82581
83223
  let bookingPlatform = null;
82582
- if (bookingMode) {
83224
+ let siteScore = null;
83225
+ let siteIssues = null;
83226
+ if (auditMode) {
83227
+ if (!r.website)
83228
+ continue;
83229
+ args.onProgress?.(`Auditing ${r.name}'s website\u2026`);
83230
+ const audit = await (0, site_audit_1.auditSite)(r.website);
83231
+ if (!(0, site_audit_1.isRebuildProspect)(audit))
83232
+ continue;
83233
+ siteScore = audit.score;
83234
+ siteIssues = JSON.stringify(audit.issues);
83235
+ leadKind = "outdated";
83236
+ } else if (bookingMode) {
82583
83237
  if (!r.website)
82584
83238
  continue;
82585
83239
  args.onProgress?.(`Checking ${r.name}'s booking setup\u2026`);
@@ -82593,8 +83247,8 @@ var require_lead_search = __commonJS({
82593
83247
  continue;
82594
83248
  }
82595
83249
  db2.run(`INSERT INTO leads
82596
- (place_id, name, type, city, address, phone, website, rating, review_count, lat, lng, score, price_level, business_status, status, lead_kind, booking_status, booking_platform)
82597
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'discovered', ?, ?, ?)`, [
83250
+ (place_id, name, type, city, address, phone, website, rating, review_count, lat, lng, score, price_level, business_status, status, lead_kind, booking_status, booking_platform, site_score, site_issues, audited_at)
83251
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'discovered', ?, ?, ?, ?, ?, ?)`, [
82598
83252
  r.place_id,
82599
83253
  r.name,
82600
83254
  r.type,
@@ -82611,7 +83265,10 @@ var require_lead_search = __commonJS({
82611
83265
  r.business_status,
82612
83266
  leadKind,
82613
83267
  bookingStatus,
82614
- bookingPlatform
83268
+ bookingPlatform,
83269
+ siteScore,
83270
+ siteIssues,
83271
+ siteScore === null ? null : (/* @__PURE__ */ new Date()).toISOString()
82615
83272
  ]);
82616
83273
  const row = db2.get("SELECT id FROM leads WHERE place_id = ?", [r.place_id]);
82617
83274
  saved++;
@@ -83148,8 +83805,33 @@ var require_site_stats = __commonJS({
83148
83805
  exports2.fetchSiteStats = fetchSiteStats;
83149
83806
  exports2.pctChange = pctChange;
83150
83807
  var axios_1 = __importDefault(require_axios());
83151
- async function fetchSiteStats(siteUrl, adminPassword) {
83808
+ async function fetchSiteStats(siteUrl, adminPassword, reportKey) {
83152
83809
  const base = siteUrl.replace(/\/$/, "");
83810
+ if (reportKey) {
83811
+ try {
83812
+ const res = await axios_1.default.get(`${base}/api/stats`, {
83813
+ timeout: 2e4,
83814
+ headers: { Authorization: `Bearer ${reportKey}` },
83815
+ validateStatus: () => true
83816
+ });
83817
+ if (res.status === 200)
83818
+ return { ok: true, data: res.data };
83819
+ if (res.status === 404) {
83820
+ return {
83821
+ ok: false,
83822
+ error: "This site was built before analytics shipped. Run `sproutkit regenerate` on it to add them."
83823
+ };
83824
+ }
83825
+ } catch (e) {
83826
+ return { ok: false, error: `Couldn't reach the site: ${e.message}` };
83827
+ }
83828
+ }
83829
+ if (!adminPassword) {
83830
+ return {
83831
+ ok: false,
83832
+ error: "No report key on this site and no stored admin password. Rebuild it (`sproutkit regenerate`) to provision a read-only report key."
83833
+ };
83834
+ }
83153
83835
  let cookie;
83154
83836
  try {
83155
83837
  const login = await axios_1.default.post(`${base}/api/admin/login`, { password: adminPassword }, {
@@ -94983,6 +95665,7 @@ var require_chat_tools = __commonJS({
94983
95665
  var lead_search_1 = require_lead_search();
94984
95666
  var places_1 = require_places();
94985
95667
  var booking_detect_1 = require_booking_detect();
95668
+ var site_audit_1 = require_site_audit();
94986
95669
  var followup_1 = require_followup();
94987
95670
  var invoicing_1 = require_invoicing();
94988
95671
  var agency_1 = require_agency();
@@ -95174,6 +95857,44 @@ var require_chat_tools = __commonJS({
95174
95857
  required: ["id"]
95175
95858
  }
95176
95859
  },
95860
+ {
95861
+ name: "audit_sites",
95862
+ description: "AUDIT existing websites in an area and save the bad ones as rebuild prospects. Unlike find_leads (which looks for businesses with NO website), this finds businesses that DO have a website and checks it for real, nameable faults: expired or missing HTTPS certificates, no mobile support, table layouts, Flash, obsolete jQuery, parked or under-construction pages, stale copyright years. Each prospect is scored (higher = worse) and its specific problems are stored so outreach can cite them. Use this when the user wants to find outdated, insecure or badly built websites to pitch a rebuild against. Costs the same Places lookup as find_leads plus one fetch per site.",
95863
+ input_schema: {
95864
+ type: "object",
95865
+ properties: {
95866
+ location: {
95867
+ type: "string",
95868
+ description: "Town, postcode, area or address to search, e.g. 'Leeds LS1' or 'Shoreditch, London'."
95869
+ },
95870
+ categories: {
95871
+ type: "array",
95872
+ items: { type: "string" },
95873
+ description: "Business types to search, e.g. ['barber shop','cafe','plumber']."
95874
+ },
95875
+ radiusMeters: {
95876
+ type: "number",
95877
+ description: "Search radius in metres. Defaults to 10000."
95878
+ },
95879
+ limit: {
95880
+ type: "number",
95881
+ description: "Maximum prospects to save. Defaults to 25."
95882
+ }
95883
+ },
95884
+ required: ["location", "categories"]
95885
+ }
95886
+ },
95887
+ {
95888
+ name: "audit_one_site",
95889
+ description: "Audit a SINGLE website by URL and report its faults, without saving anything. Use this when the user names a specific site and asks what's wrong with it, or to check a business's site before pitching them.",
95890
+ input_schema: {
95891
+ type: "object",
95892
+ properties: {
95893
+ url: { type: "string", description: "The website to audit, e.g. 'thebarbers.co.uk'." },
95894
+ id: { type: "number", description: "Or a lead id \u2014 audits that lead's website and stores the result." }
95895
+ }
95896
+ }
95897
+ },
95177
95898
  {
95178
95899
  name: "hand_over_site",
95179
95900
  description: "HAND OVER a finished site to the paying client: removes the DEMO/preview banner, optionally attaches the client's own domain, and returns every credential in one place (site URL, /admin panel + password, booking page + its password, and the DNS records the client must set at their registrar). This is the paid-deliverable step \u2014 the banner exists to stop clients using a free demo as their real site, so only run this when the user says the client has paid / signed off / is going live. Requires the lead to already have a built site or booking page.",
@@ -95946,24 +96667,97 @@ Interested?`;
95946
96667
  }
95947
96668
  case "site_report": {
95948
96669
  const id = Number(input.id);
95949
- const lead = db2.get("SELECT id, name, site_url, booking_url, admin_url, admin_password, booking_admin_password FROM leads WHERE id = ?", [id]);
96670
+ const lead = db2.get(`SELECT id, name, site_url, booking_url, admin_url, admin_password,
96671
+ booking_admin_password, report_key
96672
+ FROM leads WHERE id = ?`, [id]);
95950
96673
  if (!lead)
95951
96674
  throw new Error(`Lead #${id} not found`);
95952
96675
  const base = lead.site_url || lead.booking_url;
95953
96676
  if (!base)
95954
96677
  return { ok: false, error: `#${id} ${lead.name} has no live site yet.` };
95955
96678
  const password = lead.admin_password || lead.booking_admin_password;
95956
- if (!password) {
96679
+ if (!password && !lead.report_key) {
95957
96680
  return {
95958
96681
  ok: false,
95959
- error: `No admin password stored for #${id}, so I can't read its stats. Rebuild or hand over the site to provision one.`
96682
+ error: `No report key or admin password stored for #${id}, so I can't read its stats. Run \`sproutkit regenerate ${id}\` to provision a read-only report key.`
95960
96683
  };
95961
96684
  }
95962
- const stats = await (0, site_stats_1.fetchSiteStats)(base, password);
96685
+ const stats = await (0, site_stats_1.fetchSiteStats)(base, password, lead.report_key);
95963
96686
  if (!stats.ok)
95964
96687
  return { ok: false, error: stats.error, business: lead.name };
95965
96688
  return { ok: true, id, business: lead.name, url: base, ...stats.data };
95966
96689
  }
96690
+ case "audit_sites": {
96691
+ const location = String(input.location ?? "").trim();
96692
+ const categories = input.categories ?? [];
96693
+ if (!location || categories.length === 0) {
96694
+ return { ok: false, error: "Give me a location and at least one business category." };
96695
+ }
96696
+ const placesKey = getDecryptedSetting("places_key");
96697
+ if (!placesKey)
96698
+ throw new Error("Google Places API key not set in Settings");
96699
+ ctx.emitProgress?.({
96700
+ channel: "leads:searching",
96701
+ payload: { msg: `Auditing websites in ${location}\u2026` }
96702
+ });
96703
+ const matches = await (0, places_1.geocodeForward)(location, 1);
96704
+ const place = matches[0];
96705
+ const result = await (0, lead_search_1.searchAndSaveLeadsStream)({
96706
+ placesKey,
96707
+ city: location,
96708
+ types: categories,
96709
+ maxResults: Math.min(Number(input.limit ?? 25), 100),
96710
+ center: place ? { lat: place.lat, lng: place.lng } : void 0,
96711
+ radiusMeters: Number(input.radiusMeters ?? 1e4),
96712
+ mode: "outdated"
96713
+ }, (lead) => ctx.emitProgress?.({ channel: "leads:found", payload: { lead } }));
96714
+ ctx.emitProgress?.({ channel: "leads:changed", payload: { reason: "audit_sites" } });
96715
+ const saved = db2.all(`SELECT id, name, website, site_score, site_issues
96716
+ FROM leads
96717
+ WHERE lead_kind = 'outdated' AND audited_at IS NOT NULL
96718
+ ORDER BY audited_at DESC, site_score DESC
96719
+ LIMIT ?`, [Number(input.limit ?? 25)]);
96720
+ return {
96721
+ ok: true,
96722
+ found: result.found,
96723
+ saved: result.saved,
96724
+ prospects: saved.map((r) => ({
96725
+ id: r.id,
96726
+ business: r.name,
96727
+ website: r.website,
96728
+ score: r.site_score,
96729
+ issues: JSON.parse(r.site_issues || "[]").slice(0, 3).map((i) => `${i.severity}: ${i.label}`)
96730
+ }))
96731
+ };
96732
+ }
96733
+ case "audit_one_site": {
96734
+ let url = input.url ?? "";
96735
+ let leadId = null;
96736
+ if (input.id != null) {
96737
+ leadId = Number(input.id);
96738
+ const lead = db2.get("SELECT id, name, website FROM leads WHERE id = ?", [leadId]);
96739
+ if (!lead)
96740
+ throw new Error(`Lead #${leadId} not found`);
96741
+ if (!lead.website)
96742
+ return { ok: false, error: `#${leadId} ${lead.name} has no website on record.` };
96743
+ url = lead.website;
96744
+ }
96745
+ if (!url)
96746
+ return { ok: false, error: "Give me a website URL or a lead id." };
96747
+ const audit = await (0, site_audit_1.auditSite)(url);
96748
+ if (leadId !== null) {
96749
+ db2.run("UPDATE leads SET site_score = ?, site_issues = ?, audited_at = ? WHERE id = ?", [audit.score, JSON.stringify(audit.issues), (/* @__PURE__ */ new Date()).toISOString(), leadId]);
96750
+ }
96751
+ return {
96752
+ ok: true,
96753
+ url: audit.url,
96754
+ reachable: audit.reachable,
96755
+ score: audit.score,
96756
+ worthRebuilding: (0, site_audit_1.isRebuildProspect)(audit),
96757
+ issues: audit.issues,
96758
+ error: audit.error
96759
+ };
96760
+ }
95967
96761
  case "hand_over_site": {
95968
96762
  const id = Number(input.id);
95969
96763
  const lead = db2.get("SELECT id, name, site_url, booking_url FROM leads WHERE id = ?", [id]);
@@ -96997,7 +97791,7 @@ var require_package2 = __commonJS({
96997
97791
  "package.json"(exports2, module2) {
96998
97792
  module2.exports = {
96999
97793
  name: "sproutkit-code",
97000
- version: "1.6.1",
97794
+ version: "1.8.0",
97001
97795
  description: "Sproutkit in your terminal \u2014 find leads, build sites, run outreach, all via chat.",
97002
97796
  bin: {
97003
97797
  sproutkit: "./dist/index.js"
@@ -98869,6 +99663,135 @@ async function cmdReport(argv) {
98869
99663
  process.exitCode = 1;
98870
99664
  }
98871
99665
  }
99666
+ async function cmdAudit(argv) {
99667
+ const { flags, rest } = parseArgs(argv);
99668
+ const json = !!flags.json;
99669
+ const SEV_COLOUR = {
99670
+ critical: c.red,
99671
+ major: c.amber,
99672
+ minor: c.dim
99673
+ };
99674
+ const asId = Number(flags.id ?? rest[0]);
99675
+ const singleUrl = flags.url ? String(flags.url) : void 0;
99676
+ const looksLikeUrl = singleUrl || rest[0] && /\./.test(String(rest[0])) && !flags.categories;
99677
+ if (singleUrl || !Number.isNaN(asId) && rest.length === 1 && !flags.categories || looksLikeUrl) {
99678
+ const status2 = json ? void 0 : new Status();
99679
+ const target = singleUrl ?? (Number.isNaN(asId) ? String(rest[0]) : `lead #${asId}`);
99680
+ if (status2) status2.start(`Auditing ${target}`);
99681
+ try {
99682
+ const r = await runTool(
99683
+ "audit_one_site",
99684
+ Number.isNaN(asId) || singleUrl ? { url: singleUrl ?? String(rest[0]) } : { id: asId },
99685
+ makeCtx({ json, status: status2 })
99686
+ );
99687
+ if (r && r.ok === false) {
99688
+ if (status2) status2.done(false, r.error ?? "Couldn't audit it");
99689
+ if (json) emitJson(r);
99690
+ process.exitCode = 1;
99691
+ return;
99692
+ }
99693
+ if (status2) {
99694
+ status2.done(
99695
+ true,
99696
+ r.issues && r.issues.length ? `${r.issues.length} issue${r.issues.length === 1 ? "" : "s"} \xB7 score ${r.score}` : "No problems found"
99697
+ );
99698
+ }
99699
+ if (json) {
99700
+ emitJson(r);
99701
+ return;
99702
+ }
99703
+ say();
99704
+ say(` ${c.bold(String(r.url))}` + c.dim(` \xB7 score ${r.score}/100`));
99705
+ if (!r.issues?.length) {
99706
+ say(c.dim(" Nothing worth pitching against \u2014 this site is in decent shape."));
99707
+ say();
99708
+ return;
99709
+ }
99710
+ say();
99711
+ for (const i of r.issues) {
99712
+ const tint = SEV_COLOUR[i.severity] ?? c.dim;
99713
+ say(` ${tint("\u25CF")} ${i.label}${i.detail ? c.dim(` (${i.detail})`) : ""}`);
99714
+ }
99715
+ say();
99716
+ say(
99717
+ r.worthRebuilding ? c.green(" Worth approaching \u2014 these are concrete problems you can name.") : c.dim(" Minor only. Probably not worth a rebuild pitch.")
99718
+ );
99719
+ say();
99720
+ } catch (e) {
99721
+ if (status2) status2.done(false, "Audit failed");
99722
+ json ? emitJson({ ok: false, error: e.message }) : say(c.red(" " + e.message));
99723
+ process.exitCode = 1;
99724
+ }
99725
+ return;
99726
+ }
99727
+ const location = rest.join(" ").trim() || (flags.location ? String(flags.location) : "");
99728
+ if (!location) {
99729
+ const err = 'Usage: sproutkit audit "<area>" --categories "barber shop,cafe"\n single site: sproutkit audit <leadId> \xB7 sproutkit audit --url thebarbers.co.uk';
99730
+ json ? emitJson({ ok: false, error: err }) : say(c.red(" " + err));
99731
+ process.exitCode = 1;
99732
+ return;
99733
+ }
99734
+ const categories = flags.categories ? String(flags.categories).split(",").map((t) => t.trim()).filter(Boolean) : [];
99735
+ if (categories.length === 0) {
99736
+ const err = 'Which trades? e.g. --categories "barber shop,cafe,plumber"';
99737
+ json ? emitJson({ ok: false, error: err }) : say(c.red(" " + err));
99738
+ process.exitCode = 1;
99739
+ return;
99740
+ }
99741
+ const status = json ? void 0 : new Status();
99742
+ if (status) status.start(`Auditing websites in ${location}`);
99743
+ try {
99744
+ const r = await runTool(
99745
+ "audit_sites",
99746
+ {
99747
+ location,
99748
+ categories,
99749
+ radiusMeters: flags.radius ? Number(flags.radius) : void 0,
99750
+ limit: flags.limit ? Number(flags.limit) : void 0
99751
+ },
99752
+ makeCtx({ json, status })
99753
+ );
99754
+ if (r && r.ok === false) {
99755
+ if (status) status.done(false, r.error ?? "Audit failed");
99756
+ if (json) emitJson(r);
99757
+ process.exitCode = 1;
99758
+ return;
99759
+ }
99760
+ if (status) status.done(true, `${r.saved} rebuild prospect${r.saved === 1 ? "" : "s"} from ${r.found} checked`);
99761
+ autoSync();
99762
+ if (json) {
99763
+ emitJson(r);
99764
+ return;
99765
+ }
99766
+ const list = r.prospects ?? [];
99767
+ if (!list.length) {
99768
+ say();
99769
+ say(c.dim(` Checked ${r.found} businesses \u2014 none had a site bad enough to pitch against.`));
99770
+ say(c.dim(" That's a good sign for the area; try a different trade or a wider radius."));
99771
+ say();
99772
+ return;
99773
+ }
99774
+ say();
99775
+ say(c.dim(" Worst first \u2014 these are the easiest conversations to start."));
99776
+ say();
99777
+ for (const p of list) {
99778
+ say(` ${c.greenBold("#" + p.id)} ${c.bold(p.business)} ${c.dim(p.website.replace(/^https?:\/\//, ""))}`);
99779
+ say(` ${c.amber("score " + p.score + "/100")}`);
99780
+ for (const issue of p.issues) {
99781
+ const [sev, ...restText] = issue.split(": ");
99782
+ const tint = SEV_COLOUR[sev] ?? c.dim;
99783
+ say(` ${tint("\u25CF")} ${restText.join(": ")}`);
99784
+ }
99785
+ say();
99786
+ }
99787
+ say(c.dim(" Build one: sproutkit build <id> \xB7 see everything: sproutkit audit <id>"));
99788
+ say();
99789
+ } catch (e) {
99790
+ if (status) status.done(false, "Audit failed");
99791
+ json ? emitJson({ ok: false, error: e.message }) : say(c.red(" " + e.message));
99792
+ process.exitCode = 1;
99793
+ }
99794
+ }
98872
99795
  async function cmdRegenerate(argv) {
98873
99796
  const { flags, rest } = parseArgs(argv);
98874
99797
  const json = !!flags.json;
@@ -99131,6 +100054,8 @@ When a request is missing information the command needs, ask a SHORT, STRUCTURED
99131
100054
  COMMANDS:
99132
100055
  - sproutkit find "<area>" --categories "<type>,<type>" --json \u2014 find website-less leads
99133
100056
  - sproutkit find "<area>" --categories "\u2026" --booking --json \u2014 find businesses that HAVE a site but take bookings via WhatsApp/phone only (booking-page prospects)
100057
+ - sproutkit audit "<area>" --categories "\u2026" --json \u2014 find businesses whose EXISTING site is insecure/outdated
100058
+ - sproutkit audit <id> | --url <site> --json \u2014 audit one site, list its faults
99134
100059
  - sproutkit list [--status <s>] [--city <c>] [--limit 100] \u2014 formatted table; SHOW IT VERBATIM
99135
100060
  - sproutkit list --json \u2014 same data as JSON, for reading ids only
99136
100061
  - sproutkit build <id> --json [--instructions "..."] [--cms] [--multipage] \u2014 build a demo site (--multipage = real Home/Services/About/Contact pages, not one scroll)
@@ -99330,7 +100255,7 @@ async function main() {
99330
100255
  closeTty();
99331
100256
  return;
99332
100257
  }
99333
- if (arg === "find" || arg === "list" || arg === "build" || arg === "build-all" || arg === "regenerate" || arg === "cms" || arg === "book" || arg === "handover" || arg === "brand" || arg === "invoice" || arg === "letter" || arg === "reply" || arg === "followups" || arg === "report" || arg === "outreach" || arg === "pipeline") {
100258
+ if (arg === "find" || arg === "list" || arg === "build" || arg === "build-all" || arg === "regenerate" || arg === "cms" || arg === "book" || arg === "handover" || arg === "audit" || arg === "brand" || arg === "invoice" || arg === "letter" || arg === "reply" || arg === "followups" || arg === "report" || arg === "outreach" || arg === "pipeline") {
99334
100259
  const subArgs = process.argv.slice(3);
99335
100260
  if (subArgs.includes("--help") || subArgs.includes("-h")) {
99336
100261
  const USAGE = {
@@ -99370,6 +100295,16 @@ async function main() {
99370
100295
  ' --instructions "\u2026" e.g. "45-min slots, add beard trim \xA312"',
99371
100296
  " --email <addr> owner email for booking notifications"
99372
100297
  ],
100298
+ audit: [
100299
+ 'sproutkit audit "<area>" --categories "barber shop,cafe"',
100300
+ " finds businesses that HAVE a website but whose site is costing them",
100301
+ " work \u2014 no HTTPS, unusable on phones, built in 2008 \u2014 and scores it",
100302
+ " --radius <m> search radius (default 10000)",
100303
+ " --limit <n> max prospects to save (default 25)",
100304
+ "",
100305
+ " one site: sproutkit audit <leadId>",
100306
+ " sproutkit audit --url thebarbers.co.uk"
100307
+ ],
99373
100308
  brand: [
99374
100309
  'sproutkit brand --name "Your Studio" --email you@x.com [--vat-rate 20]',
99375
100310
  " your agency identity \u2014 goes on invoices, letters, the site footer",
@@ -99447,6 +100382,7 @@ async function main() {
99447
100382
  else if (arg === "cms") await cmdCms(subArgs);
99448
100383
  else if (arg === "book") await cmdBook(subArgs);
99449
100384
  else if (arg === "handover") await cmdHandover(subArgs);
100385
+ else if (arg === "audit") await cmdAudit(subArgs);
99450
100386
  else if (arg === "brand") await cmdBrand(subArgs);
99451
100387
  else if (arg === "invoice") await cmdInvoice(subArgs);
99452
100388
  else if (arg === "letter") await cmdLetter(subArgs);