sproutkit-code 1.3.0 → 1.5.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
@@ -183,6 +183,21 @@ var require_db = __commonJS({
183
183
  } catch {
184
184
  }
185
185
  }
186
+ for (const col of [
187
+ // Stable per-site key encrypting admin-auth.json in Blob. MUST survive
188
+ // redeploys: SESSION_SECRET is regenerated every deploy, so deriving from
189
+ // it would make the client's self-chosen password undecryptable each time.
190
+ "auth_enc_key TEXT",
191
+ "booking_admin_url TEXT",
192
+ "booking_admin_password TEXT",
193
+ "handed_over_at TEXT",
194
+ "custom_domain TEXT"
195
+ ]) {
196
+ try {
197
+ db2.exec(`ALTER TABLE leads ADD COLUMN ${col}`);
198
+ } catch {
199
+ }
200
+ }
186
201
  try {
187
202
  db2.exec("ALTER TABLE leads ADD COLUMN dead_at TEXT");
188
203
  } catch {
@@ -31050,6 +31065,121 @@ export default function App() {
31050
31065
  }
31051
31066
  });
31052
31067
 
31068
+ // ../desktop/dist/main/lib/vercel-domains.js
31069
+ var require_vercel_domains = __commonJS({
31070
+ "../desktop/dist/main/lib/vercel-domains.js"(exports2) {
31071
+ "use strict";
31072
+ var __importDefault = exports2 && exports2.__importDefault || function(mod) {
31073
+ return mod && mod.__esModule ? mod : { "default": mod };
31074
+ };
31075
+ Object.defineProperty(exports2, "__esModule", { value: true });
31076
+ exports2.attachDomain = attachDomain;
31077
+ exports2.domainStatus = domainStatus;
31078
+ var axios_1 = __importDefault(require_axios());
31079
+ function qs(teamId) {
31080
+ return teamId ? `?teamId=${encodeURIComponent(teamId)}` : "";
31081
+ }
31082
+ function isApex(domain) {
31083
+ const parts = domain.split(".");
31084
+ if (parts.length <= 2)
31085
+ return true;
31086
+ const twoPartSuffixes = ["co.uk", "org.uk", "me.uk", "ac.uk", "com.au", "co.nz", "co.za", "com.br"];
31087
+ return parts.length === 3 && twoPartSuffixes.includes(parts.slice(-2).join("."));
31088
+ }
31089
+ async function fetchConfig(domain, projectId, vercelToken, teamId) {
31090
+ try {
31091
+ const params = new URLSearchParams({ projectIdOrName: projectId });
31092
+ if (teamId)
31093
+ params.set("teamId", teamId);
31094
+ const res = await axios_1.default.get(`https://api.vercel.com/v6/domains/${encodeURIComponent(domain)}/config?${params.toString()}`, { headers: { Authorization: `Bearer ${vercelToken}` }, timeout: 2e4 });
31095
+ return res.data;
31096
+ } catch {
31097
+ return null;
31098
+ }
31099
+ }
31100
+ function recordsFrom(domain, cfg) {
31101
+ const apex = isApex(domain);
31102
+ if (!cfg)
31103
+ return [];
31104
+ if (apex) {
31105
+ const ip = cfg.recommendedIPv4?.find((r) => r.rank === 1)?.value?.[0] ?? cfg.recommendedIPv4?.[0]?.value?.[0];
31106
+ if (!ip)
31107
+ return [];
31108
+ return [{ type: "A", name: "@", value: ip, note: "root domain" }];
31109
+ }
31110
+ const target = cfg.recommendedCNAME?.find((r) => r.rank === 1)?.value ?? cfg.recommendedCNAME?.[0]?.value;
31111
+ if (!target)
31112
+ return [];
31113
+ const sub = domain.split(".")[0];
31114
+ return [{ type: "CNAME", name: sub, value: target }];
31115
+ }
31116
+ async function attachDomain(args) {
31117
+ const { vercelToken, projectId, teamId } = args;
31118
+ const domain = args.domain.trim().toLowerCase().replace(/^https?:\/\//, "").replace(/\/.*$/, "");
31119
+ if (!/^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)+$/.test(domain)) {
31120
+ throw new Error(`"${args.domain}" doesn't look like a domain name (expected e.g. thesalon.co.uk).`);
31121
+ }
31122
+ let verified = false;
31123
+ let alreadyAttached = false;
31124
+ let apexName = domain;
31125
+ let ownershipChallenges = [];
31126
+ let warning;
31127
+ try {
31128
+ const res = await axios_1.default.post(`https://api.vercel.com/v10/projects/${encodeURIComponent(projectId)}/domains${qs(teamId)}`, { name: domain }, { headers: { Authorization: `Bearer ${vercelToken}` }, timeout: 2e4 });
31129
+ verified = !!res.data.verified;
31130
+ apexName = res.data.apexName ?? domain;
31131
+ ownershipChallenges = (res.data.verification ?? []).filter((v) => v.type?.toUpperCase() === "TXT").map((v) => ({ type: "TXT", name: v.domain, value: v.value, note: v.reason }));
31132
+ } catch (e) {
31133
+ const err = e;
31134
+ const status = err.response?.status;
31135
+ const msg = err.response?.data?.error?.message ?? e.message;
31136
+ if (status === 400) {
31137
+ alreadyAttached = true;
31138
+ } else if (status === 409) {
31139
+ throw new Error(`${domain} is already attached to a different Vercel project or account. Remove it there first, then retry. (${msg})`);
31140
+ } else if (status === 402) {
31141
+ throw new Error(`Vercel needs a payment method on the account before adding a custom domain. (${msg})`);
31142
+ } else if (status === 403) {
31143
+ throw new Error(`Your Vercel token doesn't have permission to add ${domain}. (${msg})`);
31144
+ } else {
31145
+ throw new Error(`Couldn't attach ${domain}: ${msg}`);
31146
+ }
31147
+ }
31148
+ const cfg = await fetchConfig(domain, projectId, vercelToken, teamId);
31149
+ const records = recordsFrom(domain, cfg);
31150
+ if (!records.length) {
31151
+ warning = "Couldn't read the DNS records from Vercel. Open the project's Domains tab in the Vercel dashboard to see the exact A/CNAME values.";
31152
+ }
31153
+ return {
31154
+ domain,
31155
+ apexDomain: apexName,
31156
+ verified,
31157
+ dnsConfigured: cfg ? cfg.misconfigured === false : false,
31158
+ alreadyAttached,
31159
+ records,
31160
+ ownershipChallenges,
31161
+ warning
31162
+ };
31163
+ }
31164
+ async function domainStatus(args) {
31165
+ const { domain, projectId, vercelToken, teamId } = args;
31166
+ let verified = false;
31167
+ try {
31168
+ const res = await axios_1.default.get(`https://api.vercel.com/v9/projects/${encodeURIComponent(projectId)}/domains/${encodeURIComponent(domain)}${qs(teamId)}`, { headers: { Authorization: `Bearer ${vercelToken}` }, timeout: 2e4 });
31169
+ verified = !!res.data.verified;
31170
+ } catch {
31171
+ }
31172
+ const cfg = await fetchConfig(domain, projectId, vercelToken, teamId);
31173
+ return {
31174
+ domain,
31175
+ verified,
31176
+ dnsConfigured: cfg ? cfg.misconfigured === false : false,
31177
+ records: recordsFrom(domain, cfg)
31178
+ };
31179
+ }
31180
+ }
31181
+ });
31182
+
31053
31183
  // ../../node_modules/bcryptjs/umd/index.js
31054
31184
  var require_umd = __commonJS({
31055
31185
  "../../node_modules/bcryptjs/umd/index.js"(exports2, module2) {
@@ -32878,10 +33008,11 @@ var require_cms_runtime = __commonJS({
32878
33008
  };
32879
33009
  })();
32880
33010
  Object.defineProperty(exports2, "__esModule", { value: true });
32881
- exports2.ADMIN_APP_JS = exports2.ADMIN_INDEX_HTML = exports2.API_ADMIN_UPLOAD_JS = exports2.API_ADMIN_WHOAMI_JS = exports2.API_ADMIN_LOGIN_JS = exports2.API_CONTENT_JS = void 0;
33011
+ 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;
32882
33012
  exports2.generateFriendlyPassword = generateFriendlyPassword;
32883
33013
  exports2.hashPassword = hashPassword;
32884
33014
  exports2.generateSessionSecret = generateSessionSecret;
33015
+ var node_crypto_1 = require("node:crypto");
32885
33016
  exports2.API_CONTENT_JS = `import { list, put } from "@vercel/blob";
32886
33017
 
32887
33018
  // Per-site Blob namespace so many CMS sites share one store (one token per
@@ -33041,10 +33172,131 @@ export default async function handler(req, res) {
33041
33172
  }
33042
33173
  `;
33043
33174
  exports2.API_ADMIN_LOGIN_JS = `import bcrypt from "bcryptjs";
33044
- import { createHmac } from "node:crypto";
33175
+ import { createHmac, createHash, createCipheriv, createDecipheriv, randomBytes } from "node:crypto";
33176
+ import { list, put } from "@vercel/blob";
33045
33177
 
33046
33178
  export const config = { runtime: "nodejs" };
33047
33179
 
33180
+ // Per-site Blob namespace, matching the other runtime files.
33181
+ const PFX = process.env.SITE_ID ? "sites/" + process.env.SITE_ID + "/" : "";
33182
+ // The client's self-chosen password, if they've changed it. ADMIN_PASSWORD_HASH
33183
+ // is a Vercel env var and the site can't rewrite its own env, so an override
33184
+ // hash lives in Blob instead and takes precedence when present.
33185
+ const AUTH_KEY = PFX + "admin-auth.json";
33186
+ // Failed-attempt counters. Blob is the only shared state a serverless function
33187
+ // has \u2014 there's no memory between invocations. Best-effort by nature: two
33188
+ // concurrent attempts can read the same counter before either writes. That
33189
+ // still turns an unlimited online guessing attack into a slow one, which is the
33190
+ // point; bcrypt's own cost does the rest.
33191
+ const THROTTLE_KEY = PFX + "admin-throttle.json";
33192
+
33193
+ const MAX_FAILS = 8; // per IP, per window
33194
+ const WINDOW_MS = 15 * 60 * 1000;
33195
+ const LOCKOUT_MS = 15 * 60 * 1000;
33196
+
33197
+
33198
+ // admin-auth.json holds a bcrypt hash and lives in a PUBLIC Blob store at a
33199
+ // non-random path, so treat its URL as readable by anyone who learns it (the
33200
+ // store hostname leaks via photo URLs; only the per-site UUID stands between an
33201
+ // outsider and this file). A leaked bcrypt hash is crackable offline where our
33202
+ // login rate limit gives zero protection \u2014 so the contents are encrypted with
33203
+ // AES-256-GCM under a key derived from AUTH_ENC_KEY, which only ever exists as
33204
+ // a server-side env var. A public read then yields opaque bytes.
33205
+ function authKey() {
33206
+ const material = process.env.AUTH_ENC_KEY || "";
33207
+ if (!material) return null;
33208
+ return createHash("sha256").update("sproutkit-admin-auth:" + material).digest();
33209
+ }
33210
+
33211
+ function sealAuth(obj) {
33212
+ const key = authKey();
33213
+ const plain = JSON.stringify(obj);
33214
+ if (!key) return JSON.stringify({ v: 0, plain: obj });
33215
+ const iv = randomBytes(12);
33216
+ const cipher = createCipheriv("aes-256-gcm", key, iv);
33217
+ const ct = Buffer.concat([cipher.update(plain, "utf8"), cipher.final()]);
33218
+ return JSON.stringify({
33219
+ v: 1,
33220
+ iv: iv.toString("base64"),
33221
+ tag: cipher.getAuthTag().toString("base64"),
33222
+ ct: ct.toString("base64"),
33223
+ });
33224
+ }
33225
+
33226
+ function openAuth(raw) {
33227
+ if (!raw || typeof raw !== "object") return null;
33228
+ // v0 = written before encryption shipped (or with no key available).
33229
+ if (raw.v === 0 && raw.plain) return raw.plain;
33230
+ if (raw.v !== 1) return null;
33231
+ const key = authKey();
33232
+ if (!key) return null;
33233
+ try {
33234
+ const decipher = createDecipheriv("aes-256-gcm", key, Buffer.from(raw.iv, "base64"));
33235
+ decipher.setAuthTag(Buffer.from(raw.tag, "base64"));
33236
+ const out = Buffer.concat([
33237
+ decipher.update(Buffer.from(raw.ct, "base64")),
33238
+ decipher.final(),
33239
+ ]).toString("utf8");
33240
+ return JSON.parse(out);
33241
+ } catch {
33242
+ // Wrong key or tampered payload \u2014 fall back to the env-var password rather
33243
+ // than locking the owner out.
33244
+ return null;
33245
+ }
33246
+ }
33247
+
33248
+ async function readJson(key, fallback) {
33249
+ try {
33250
+ const { blobs } = await list({ prefix: key, limit: 1 });
33251
+ const target = blobs.find((b) => b.pathname === key);
33252
+ if (!target) return fallback;
33253
+ // cache: no-store \u2014 a stale throttle count would let attempts through.
33254
+ const res = await fetch(target.url, { cache: "no-store" });
33255
+ if (!res.ok) return fallback;
33256
+ return await res.json();
33257
+ } catch {
33258
+ return fallback;
33259
+ }
33260
+ }
33261
+
33262
+ async function writeJson(key, value) {
33263
+ try {
33264
+ await put(key, JSON.stringify(value), {
33265
+ access: "public",
33266
+ addRandomSuffix: false,
33267
+ allowOverwrite: true,
33268
+ contentType: "application/json",
33269
+ });
33270
+ } catch {
33271
+ // Non-fatal: failing to persist a counter must never lock out a real owner.
33272
+ }
33273
+ }
33274
+
33275
+ // Prefer headers Vercel sets itself. The leftmost x-forwarded-for entry is
33276
+ // client-controlled, so it's the last resort \u2014 and note a spoofer can only
33277
+ // throttle the identity they invent, not a real owner.
33278
+ function clientKey(req) {
33279
+ const h = req.headers;
33280
+ const raw =
33281
+ h["x-vercel-forwarded-for"] ||
33282
+ h["x-real-ip"] ||
33283
+ String(h["x-forwarded-for"] || "").split(",")[0].trim() ||
33284
+ "unknown";
33285
+ // Hashed because this file is world-readable in public Blob; we never want to
33286
+ // publish a list of visitor IPs.
33287
+ return createHash("sha256").update(String(raw)).digest("hex").slice(0, 16);
33288
+ }
33289
+
33290
+ function prune(state, now) {
33291
+ const out = {};
33292
+ for (const [k, v] of Object.entries(state || {})) {
33293
+ if (!v || typeof v !== "object") continue;
33294
+ const fresh = (v.until && v.until > now) || (v.first && now - v.first < WINDOW_MS);
33295
+ if (fresh) out[k] = v;
33296
+ }
33297
+ return out;
33298
+ }
33299
+
33048
33300
  function b64url(buf) {
33049
33301
  return Buffer.from(buf).toString("base64").replace(/\\+/g, "-").replace(/\\//g, "_").replace(/=+$/, "");
33050
33302
  }
@@ -33071,12 +33323,30 @@ export default async function handler(req, res) {
33071
33323
  res.status(405).json({ error: "method_not_allowed" });
33072
33324
  return;
33073
33325
  }
33074
- const hash = process.env.ADMIN_PASSWORD_HASH;
33075
33326
  const secret = process.env.SESSION_SECRET;
33076
- if (!hash || !secret) {
33327
+ const envHash = process.env.ADMIN_PASSWORD_HASH;
33328
+ if (!envHash || !secret) {
33077
33329
  res.status(500).json({ error: "not_configured" });
33078
33330
  return;
33079
33331
  }
33332
+
33333
+ const now = Date.now();
33334
+ const who = clientKey(req);
33335
+ let throttle = prune(await readJson(THROTTLE_KEY, {}), now);
33336
+ const entry = throttle[who];
33337
+ if (entry && entry.until && entry.until > now) {
33338
+ const retryAfter = Math.ceil((entry.until - now) / 1000);
33339
+ res.setHeader("Retry-After", String(retryAfter));
33340
+ res.status(429).json({ error: "too_many_attempts", retryAfter });
33341
+ return;
33342
+ }
33343
+
33344
+ // The client's own password wins over the one we generated at build time.
33345
+ const override = openAuth(await readJson(AUTH_KEY, null));
33346
+ const hash =
33347
+ override && typeof override.passwordHash === "string" && override.passwordHash
33348
+ ? override.passwordHash
33349
+ : envHash;
33080
33350
  let body = req.body;
33081
33351
  if (typeof body === "string") {
33082
33352
  try { body = JSON.parse(body); } catch { body = null; }
@@ -33095,10 +33365,25 @@ export default async function handler(req, res) {
33095
33365
  }
33096
33366
  const ok = await bcrypt.compare(password, hash);
33097
33367
  if (!ok) {
33368
+ const prev = throttle[who] && throttle[who].first && now - throttle[who].first < WINDOW_MS
33369
+ ? throttle[who]
33370
+ : { fails: 0, first: now };
33371
+ const fails = (prev.fails || 0) + 1;
33372
+ const next = { fails, first: prev.first || now };
33373
+ if (fails >= MAX_FAILS) next.until = now + LOCKOUT_MS;
33374
+ throttle[who] = next;
33375
+ await writeJson(THROTTLE_KEY, throttle);
33098
33376
  await new Promise((r) => setTimeout(r, 300));
33099
- res.status(401).json({ error: "invalid_password" });
33377
+ const remaining = Math.max(0, MAX_FAILS - fails);
33378
+ res.status(401).json({ error: "invalid_password", remaining });
33100
33379
  return;
33101
33380
  }
33381
+
33382
+ // Clean slate on success so a forgetful owner isn't punished later.
33383
+ if (throttle[who]) {
33384
+ delete throttle[who];
33385
+ await writeJson(THROTTLE_KEY, throttle);
33386
+ }
33102
33387
  const exp = Math.floor(Date.now() / 1000) + 60 * 60 * 8; // 8h
33103
33388
  const token = sign({ role: "admin", exp }, secret);
33104
33389
  res.setHeader(
@@ -33144,6 +33429,191 @@ export default function handler(req, res) {
33144
33429
  }
33145
33430
  res.status(200).json({ ok: true });
33146
33431
  }
33432
+ `;
33433
+ exports2.API_ADMIN_PASSWORD_JS = `import bcrypt from "bcryptjs";
33434
+ import {
33435
+ createHmac,
33436
+ timingSafeEqual,
33437
+ createHash,
33438
+ createCipheriv,
33439
+ createDecipheriv,
33440
+ randomBytes,
33441
+ } from "node:crypto";
33442
+ import { list, put } from "@vercel/blob";
33443
+
33444
+ export const config = { runtime: "nodejs" };
33445
+
33446
+ const PFX = process.env.SITE_ID ? "sites/" + process.env.SITE_ID + "/" : "";
33447
+ const AUTH_KEY = PFX + "admin-auth.json";
33448
+
33449
+ const MIN_LENGTH = 10;
33450
+
33451
+
33452
+ // admin-auth.json holds a bcrypt hash and lives in a PUBLIC Blob store at a
33453
+ // non-random path, so treat its URL as readable by anyone who learns it (the
33454
+ // store hostname leaks via photo URLs; only the per-site UUID stands between an
33455
+ // outsider and this file). A leaked bcrypt hash is crackable offline where our
33456
+ // login rate limit gives zero protection \u2014 so the contents are encrypted with
33457
+ // AES-256-GCM under a key derived from AUTH_ENC_KEY, which only ever exists as
33458
+ // a server-side env var. A public read then yields opaque bytes.
33459
+ function authKey() {
33460
+ const material = process.env.AUTH_ENC_KEY || "";
33461
+ if (!material) return null;
33462
+ return createHash("sha256").update("sproutkit-admin-auth:" + material).digest();
33463
+ }
33464
+
33465
+ function sealAuth(obj) {
33466
+ const key = authKey();
33467
+ const plain = JSON.stringify(obj);
33468
+ if (!key) return JSON.stringify({ v: 0, plain: obj });
33469
+ const iv = randomBytes(12);
33470
+ const cipher = createCipheriv("aes-256-gcm", key, iv);
33471
+ const ct = Buffer.concat([cipher.update(plain, "utf8"), cipher.final()]);
33472
+ return JSON.stringify({
33473
+ v: 1,
33474
+ iv: iv.toString("base64"),
33475
+ tag: cipher.getAuthTag().toString("base64"),
33476
+ ct: ct.toString("base64"),
33477
+ });
33478
+ }
33479
+
33480
+ function openAuth(raw) {
33481
+ if (!raw || typeof raw !== "object") return null;
33482
+ // v0 = written before encryption shipped (or with no key available).
33483
+ if (raw.v === 0 && raw.plain) return raw.plain;
33484
+ if (raw.v !== 1) return null;
33485
+ const key = authKey();
33486
+ if (!key) return null;
33487
+ try {
33488
+ const decipher = createDecipheriv("aes-256-gcm", key, Buffer.from(raw.iv, "base64"));
33489
+ decipher.setAuthTag(Buffer.from(raw.tag, "base64"));
33490
+ const out = Buffer.concat([
33491
+ decipher.update(Buffer.from(raw.ct, "base64")),
33492
+ decipher.final(),
33493
+ ]).toString("utf8");
33494
+ return JSON.parse(out);
33495
+ } catch {
33496
+ // Wrong key or tampered payload \u2014 fall back to the env-var password rather
33497
+ // than locking the owner out.
33498
+ return null;
33499
+ }
33500
+ }
33501
+
33502
+ function verifySession(req) {
33503
+ const cookie = req.headers.cookie || "";
33504
+ const match = cookie.match(/sk_admin=([^;]+)/);
33505
+ if (!match) return false;
33506
+ const secret = process.env.SESSION_SECRET;
33507
+ if (!secret) return false;
33508
+ const [payload, sig] = match[1].split(".");
33509
+ if (!payload || !sig) return false;
33510
+ try {
33511
+ const expected = createHmac("sha256", secret).update(payload).digest();
33512
+ const provided = Buffer.from(sig.replace(/-/g, "+").replace(/_/g, "/"), "base64");
33513
+ if (expected.length !== provided.length) return false;
33514
+ if (!timingSafeEqual(expected, provided)) return false;
33515
+ const data = JSON.parse(
33516
+ Buffer.from(payload.replace(/-/g, "+").replace(/_/g, "/"), "base64").toString("utf8")
33517
+ );
33518
+ return typeof data.exp === "number" && data.exp > Date.now() / 1000;
33519
+ } catch {
33520
+ return false;
33521
+ }
33522
+ }
33523
+
33524
+ function readBody(req) {
33525
+ return new Promise((resolve, reject) => {
33526
+ const chunks = [];
33527
+ req.on("data", (c) => chunks.push(c));
33528
+ req.on("end", () => resolve(Buffer.concat(chunks).toString("utf8")));
33529
+ req.on("error", reject);
33530
+ });
33531
+ }
33532
+
33533
+ async function currentHash() {
33534
+ try {
33535
+ const { blobs } = await list({ prefix: AUTH_KEY, limit: 1 });
33536
+ const target = blobs.find((b) => b.pathname === AUTH_KEY);
33537
+ if (target) {
33538
+ const res = await fetch(target.url, { cache: "no-store" });
33539
+ if (res.ok) {
33540
+ const j = openAuth(await res.json());
33541
+ if (j && typeof j.passwordHash === "string" && j.passwordHash) return j.passwordHash;
33542
+ }
33543
+ }
33544
+ } catch {
33545
+ // fall through to the env var
33546
+ }
33547
+ return process.env.ADMIN_PASSWORD_HASH || "";
33548
+ }
33549
+
33550
+ export default async function handler(req, res) {
33551
+ if (req.method !== "POST") {
33552
+ res.status(405).json({ error: "method_not_allowed" });
33553
+ return;
33554
+ }
33555
+ // Must already be logged in AND know the current password \u2014 a stolen session
33556
+ // cookie alone must not be enough to lock the real owner out.
33557
+ if (!verifySession(req)) {
33558
+ res.status(401).json({ error: "unauthorized" });
33559
+ return;
33560
+ }
33561
+ let body = req.body;
33562
+ if (typeof body === "string") {
33563
+ try { body = JSON.parse(body); } catch { body = null; }
33564
+ } else if (!body) {
33565
+ try {
33566
+ const raw = await readBody(req);
33567
+ body = raw ? JSON.parse(raw) : {};
33568
+ } catch {
33569
+ body = {};
33570
+ }
33571
+ }
33572
+ const currentPassword = String((body && body.currentPassword) || "");
33573
+ const newPassword = String((body && body.newPassword) || "");
33574
+
33575
+ if (newPassword.length < MIN_LENGTH) {
33576
+ res.status(400).json({ error: "too_short", minLength: MIN_LENGTH });
33577
+ return;
33578
+ }
33579
+ if (newPassword.length > 200) {
33580
+ res.status(400).json({ error: "too_long" });
33581
+ return;
33582
+ }
33583
+ if (newPassword === currentPassword) {
33584
+ res.status(400).json({ error: "unchanged" });
33585
+ return;
33586
+ }
33587
+
33588
+ const existing = await currentHash();
33589
+ if (!existing) {
33590
+ res.status(500).json({ error: "not_configured" });
33591
+ return;
33592
+ }
33593
+ const ok = await bcrypt.compare(currentPassword, existing);
33594
+ if (!ok) {
33595
+ await new Promise((r) => setTimeout(r, 300));
33596
+ res.status(401).json({ error: "wrong_current_password" });
33597
+ return;
33598
+ }
33599
+
33600
+ const salt = await bcrypt.genSalt(10);
33601
+ const passwordHash = await bcrypt.hash(newPassword, salt);
33602
+ try {
33603
+ // clientChanged is read (unencrypted) by nothing sensitive \u2014 it only tells
33604
+ // the agency "your stored copy is no longer the live password".
33605
+ await put(AUTH_KEY, sealAuth({ passwordHash, updatedAt: new Date().toISOString(), clientChanged: true }), {
33606
+ access: "public",
33607
+ addRandomSuffix: false,
33608
+ allowOverwrite: true,
33609
+ contentType: "application/json",
33610
+ });
33611
+ } catch (e) {
33612
+ res.status(500).json({ error: "save_failed", detail: (e && e.message) || "unknown" });
33613
+ return;
33614
+ }
33615
+ res.status(200).json({ ok: true });
33616
+ }
33147
33617
  `;
33148
33618
  exports2.API_ADMIN_UPLOAD_JS = `import { put } from "@vercel/blob";
33149
33619
 
@@ -33864,6 +34334,98 @@ export default async function handler(req, res) {
33864
34334
  );
33865
34335
  }
33866
34336
 
34337
+ function TabBar(props) {
34338
+ return h("div", { style: { display: "flex", gap: 4, marginBottom: 24, borderBottom: "1px solid var(--border)", paddingBottom: 12 } },
34339
+ props.tabs.map(function (t) {
34340
+ var active = props.section[0] === t.id;
34341
+ return h("button", {
34342
+ key: t.id,
34343
+ onClick: function () { props.section[1](t.id); },
34344
+ style: {
34345
+ background: active ? "var(--accent)" : "transparent",
34346
+ color: active ? "#000" : "var(--text)",
34347
+ border: "1px solid " + (active ? "var(--accent)" : "var(--border)"),
34348
+ borderRadius: 999, padding: "8px 16px",
34349
+ font: "inherit", fontWeight: 500, fontSize: 13, cursor: "pointer"
34350
+ }
34351
+ }, t.label);
34352
+ })
34353
+ );
34354
+ }
34355
+
34356
+ // Lets the owner replace the password we generated for them. Without this the
34357
+ // agency permanently knows the client's login, which isn't acceptable for a
34358
+ // panel holding customer names and phone numbers.
34359
+ function PasswordPanel() {
34360
+ var cur = useState(""); var next = useState(""); var again = useState("");
34361
+ var busy = useState(false); var msg = useState(null); var err = useState("");
34362
+
34363
+ async function submit(e) {
34364
+ e.preventDefault();
34365
+ err[1](""); msg[1](null);
34366
+ if (next[0].length < 10) { err[1]("New password must be at least 10 characters."); return; }
34367
+ if (next[0] !== again[0]) { err[1]("The two new passwords don't match."); return; }
34368
+ busy[1](true);
34369
+ try {
34370
+ var res = await fetch("/api/admin/password", {
34371
+ method: "POST",
34372
+ credentials: "same-origin",
34373
+ headers: { "Content-Type": "application/json" },
34374
+ body: JSON.stringify({ currentPassword: cur[0], newPassword: next[0] })
34375
+ });
34376
+ var data = null;
34377
+ try { data = await res.json(); } catch (_) { data = null; }
34378
+ if (res.ok) {
34379
+ msg[1]("Password changed. Use the new one next time you sign in.");
34380
+ cur[1](""); next[1](""); again[1]("");
34381
+ } else if (res.status === 401) {
34382
+ err[1]((data && data.error) === "wrong_current_password"
34383
+ ? "That's not your current password."
34384
+ : "Your session expired \u2014 reload and sign in again.");
34385
+ } else if (data && data.error === "too_short") {
34386
+ err[1]("New password must be at least " + (data.minLength || 10) + " characters.");
34387
+ } else if (data && data.error === "unchanged") {
34388
+ err[1]("That's already your password.");
34389
+ } else {
34390
+ err[1]("Couldn't change it (" + res.status + "). Try again.");
34391
+ }
34392
+ } catch (_) {
34393
+ err[1]("Network error \u2014 check your connection and try again.");
34394
+ }
34395
+ busy[1](false);
34396
+ }
34397
+
34398
+ var field = function (label, st, auto) {
34399
+ return h("label", { style: { display: "block", marginBottom: 12 } },
34400
+ h("div", { style: { fontSize: 12, color: "var(--muted)", marginBottom: 4 } }, label),
34401
+ h("input", {
34402
+ type: "password", value: st[0], autoComplete: auto,
34403
+ onChange: function (e) { st[1](e.target.value); },
34404
+ style: { width: "100%", maxWidth: 360 }
34405
+ })
34406
+ );
34407
+ };
34408
+
34409
+ return h("div", null,
34410
+ h("div", { className: "bar" },
34411
+ h("div", null,
34412
+ h("div", { style: { fontSize: 15, fontWeight: 600 } }, "Password"),
34413
+ h("div", { style: { fontSize: 12, color: "var(--muted)" } },
34414
+ "Change the password you were given")
34415
+ )
34416
+ ),
34417
+ h("form", { onSubmit: submit, style: { maxWidth: 400 } },
34418
+ field("Current password", cur, "current-password"),
34419
+ field("New password", next, "new-password"),
34420
+ field("Repeat new password", again, "new-password"),
34421
+ err[0] ? h("div", { style: { color: "#b91c1c", fontSize: 13, margin: "8px 0" } }, err[0]) : null,
34422
+ msg[0] ? h("div", { style: { color: "var(--accent)", fontSize: 13, margin: "8px 0" } }, msg[0]) : null,
34423
+ h("button", { type: "submit", className: "primary", disabled: busy[0] },
34424
+ busy[0] ? "Saving\u2026" : "Change password")
34425
+ )
34426
+ );
34427
+ }
34428
+
33867
34429
  function App() {
33868
34430
  var authed = useState(false);
33869
34431
  var section = useState("content");
@@ -33877,33 +34439,29 @@ export default async function handler(req, res) {
33877
34439
  // Bookings-only site: no Site content tab to switch to, and the public
33878
34440
  // booking page is the site root rather than /book.
33879
34441
  if (!ctl.available || !ctl.content) {
33880
- return h("div", { className: "app" }, h(BookingsPanel, { bookingPath: "/" }));
34442
+ return h("div", { className: "app" },
34443
+ h(TabBar, { tabs: [{ id: "bookings", label: "Bookings" }, { id: "password", label: "Password" }], section: section }),
34444
+ section[0] === "password"
34445
+ ? h(PasswordPanel, null)
34446
+ : h(BookingsPanel, { bookingPath: "/" })
34447
+ );
33881
34448
  }
33882
34449
 
33883
- // Top nav lets the owner flip between Site content and Bookings.
34450
+ // Top nav lets the owner flip between site content, bookings and password.
33884
34451
  return h("div", { className: "app" },
33885
- h("div", { style: { display: "flex", gap: 4, marginBottom: 24, borderBottom: "1px solid var(--border)", paddingBottom: 12 } },
33886
- [
34452
+ h(TabBar, {
34453
+ tabs: [
33887
34454
  { id: "content", label: "Site content" },
33888
- { id: "bookings", label: "Bookings" }
33889
- ].map(function (t) {
33890
- var active = section[0] === t.id;
33891
- return h("button", {
33892
- key: t.id,
33893
- onClick: function () { section[1](t.id); },
33894
- style: {
33895
- background: active ? "var(--accent)" : "transparent",
33896
- color: active ? "#000" : "var(--text)",
33897
- border: "1px solid " + (active ? "var(--accent)" : "var(--border)"),
33898
- borderRadius: 999, padding: "8px 16px",
33899
- font: "inherit", fontWeight: 500, fontSize: 13, cursor: "pointer"
33900
- }
33901
- }, t.label);
33902
- })
33903
- ),
34455
+ { id: "bookings", label: "Bookings" },
34456
+ { id: "password", label: "Password" }
34457
+ ],
34458
+ section: section
34459
+ }),
33904
34460
  section[0] === "content"
33905
34461
  ? h(Editor, { content: ctl.content, setContent: ctl.setContent })
33906
- : h(BookingsPanel, null)
34462
+ : section[0] === "password"
34463
+ ? h(PasswordPanel, null)
34464
+ : h(BookingsPanel, null)
33907
34465
  );
33908
34466
  }
33909
34467
 
@@ -33931,7 +34489,19 @@ export default async function handler(req, res) {
33931
34489
  "tidy",
33932
34490
  "warm",
33933
34491
  "wise",
33934
- "zesty"
34492
+ "zesty",
34493
+ "bold",
34494
+ "crisp",
34495
+ "fair",
34496
+ "glad",
34497
+ "jolly",
34498
+ "keen",
34499
+ "merry",
34500
+ "noble",
34501
+ "plucky",
34502
+ "spry",
34503
+ "tough",
34504
+ "vivid"
33935
34505
  ];
33936
34506
  var ANIMALS = [
33937
34507
  "badger",
@@ -33953,13 +34523,29 @@ export default async function handler(req, res) {
33953
34523
  "wolf",
33954
34524
  "zebra",
33955
34525
  "boar",
33956
- "crane"
34526
+ "crane",
34527
+ "bison",
34528
+ "dingo",
34529
+ "eagle",
34530
+ "falcon",
34531
+ "gecko",
34532
+ "ibex",
34533
+ "jackal",
34534
+ "kestrel",
34535
+ "marten",
34536
+ "osprey",
34537
+ "panda",
34538
+ "quail"
33957
34539
  ];
33958
34540
  function generateFriendlyPassword() {
33959
- const a = ADJECTIVES[Math.floor(Math.random() * ADJECTIVES.length)];
33960
- const b = ANIMALS[Math.floor(Math.random() * ANIMALS.length)];
33961
- const n = 10 + Math.floor(Math.random() * 89);
33962
- return `${a}-${b}-${n}`;
34541
+ const pick = (arr) => arr[(0, node_crypto_1.randomInt)(arr.length)];
34542
+ return [
34543
+ pick(ADJECTIVES),
34544
+ pick(ANIMALS),
34545
+ pick(ADJECTIVES),
34546
+ pick(ANIMALS),
34547
+ String(100 + (0, node_crypto_1.randomInt)(900))
34548
+ ].join("-");
33963
34549
  }
33964
34550
  async function hashPassword(plain) {
33965
34551
  const bcrypt = await Promise.resolve().then(() => __importStar(require_umd()));
@@ -34354,6 +34940,7 @@ export default async function handler(req, res) {
34354
34940
  </style>
34355
34941
  </head>
34356
34942
  <body>
34943
+ <!--SPROUTKIT_BANNER-->
34357
34944
  <div id="root"></div>
34358
34945
  <script src="https://unpkg.com/react@18/umd/react.production.min.js" crossorigin></script>
34359
34946
  <script src="https://unpkg.com/react-dom@18/umd/react-dom.production.min.js" crossorigin></script>
@@ -79674,6 +80261,7 @@ var require_builder = __commonJS({
79674
80261
  exports2.redeployCmsOnly = redeployCmsOnly;
79675
80262
  exports2.deployBookingOnly = deployBookingOnly2;
79676
80263
  exports2.deployStandaloneBooking = deployStandaloneBooking2;
80264
+ exports2.handoverSite = handoverSite2;
79677
80265
  var sdk_1 = __importDefault(require_sdk());
79678
80266
  var axios_1 = __importDefault(require_axios());
79679
80267
  var crypto_1 = require("crypto");
@@ -79685,6 +80273,7 @@ var require_builder = __commonJS({
79685
80273
  var path3 = __importStar(require("path"));
79686
80274
  var models_1 = require_models();
79687
80275
  var cms_template_1 = require_cms_template();
80276
+ var vercel_domains_1 = require_vercel_domains();
79688
80277
  var cms_runtime_1 = require_cms_runtime();
79689
80278
  var booking_runtime_1 = require_booking_runtime();
79690
80279
  function siteUidFor(leadId) {
@@ -79696,6 +80285,15 @@ var require_builder = __commonJS({
79696
80285
  db2.run("UPDATE leads SET site_uid = ? WHERE id = ?", [uid, leadId]);
79697
80286
  return uid;
79698
80287
  }
80288
+ function authEncKeyFor(leadId) {
80289
+ const db2 = (0, db_1.getDb)();
80290
+ const row = db2.get("SELECT auth_enc_key FROM leads WHERE id = ?", [leadId]);
80291
+ if (row?.auth_enc_key)
80292
+ return row.auth_enc_key;
80293
+ const key = (0, crypto_1.randomUUID)() + (0, crypto_1.randomUUID)();
80294
+ db2.run("UPDATE leads SET auth_enc_key = ? WHERE id = ?", [key, leadId]);
80295
+ return key;
80296
+ }
79699
80297
  var STOCK_HOSTS = [
79700
80298
  "unsplash.com",
79701
80299
  "images.unsplash.com",
@@ -79759,7 +80357,18 @@ var require_builder = __commonJS({
79759
80357
  const pattern = new RegExp(`https?:\\/\\/[^\\s"'\`)]*(${STOCK_HOSTS.map((h) => h.replace(/\./g, "\\.")).join("|")})[^\\s"'\`)]*`, "gi");
79760
80358
  return code.replace(pattern, "");
79761
80359
  }
79762
- function demoBanner(businessName) {
80360
+ function bannerFor(lead) {
80361
+ let handedOver = false;
80362
+ try {
80363
+ const row = (0, db_1.getDb)().get("SELECT handed_over_at FROM leads WHERE id = ?", [lead.id]);
80364
+ handedOver = !!row?.handed_over_at;
80365
+ } catch {
80366
+ }
80367
+ return demoBanner(lead.name, handedOver);
80368
+ }
80369
+ function demoBanner(businessName, handedOver) {
80370
+ if (handedOver)
80371
+ return "";
79763
80372
  const esc = (v) => v.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
79764
80373
  const label = `Preview site for ${esc(businessName)}`;
79765
80374
  return `<div id="sproutkit-demo-banner" role="note" style="all:initial;display:block;box-sizing:border-box;width:100%;background:#1f2937;color:#f9fafb;font-family:ui-sans-serif,system-ui,-apple-system,'Segoe UI',Roboto,sans-serif;font-size:13px;line-height:1.4;padding:10px 16px;text-align:center;letter-spacing:.01em">
@@ -80339,7 +80948,7 @@ ${prompt}` : prompt;
80339
80948
  onProgress("Deploying to Vercel...");
80340
80949
  const indexHtml = `<!DOCTYPE html>
80341
80950
  <html lang="en"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>${lead.name}</title></head>
80342
- <body style="margin:0">${demoBanner(lead.name)}<div id="root"></div><script type="module" src="/src/main.jsx"></script></body></html>`;
80951
+ <body style="margin:0">${bannerFor(lead)}<div id="root"></div><script type="module" src="/src/main.jsx"></script></body></html>`;
80343
80952
  const mainJsx = `import { StrictMode } from 'react';
80344
80953
  import { createRoot } from 'react-dom/client';
80345
80954
  import App from './App.jsx';
@@ -80432,6 +81041,7 @@ export default defineConfig({ plugins: [react()] });`;
80432
81041
  SITE_ID: cmsSiteUid,
80433
81042
  ADMIN_PASSWORD_HASH: adminHash,
80434
81043
  SESSION_SECRET: sessionSecret,
81044
+ AUTH_ENC_KEY: authEncKeyFor(lead.id),
80435
81045
  BUSINESS_NAME: lead.name
80436
81046
  });
80437
81047
  const vercelJson = JSON.stringify({
@@ -80445,6 +81055,7 @@ export default defineConfig({ plugins: [react()] });`;
80445
81055
  { file: "api/content.js", data: cms_runtime_1.API_CONTENT_JS, encoding: "utf-8" },
80446
81056
  { file: "api/admin/login.js", data: cms_runtime_1.API_ADMIN_LOGIN_JS, encoding: "utf-8" },
80447
81057
  { file: "api/admin/whoami.js", data: cms_runtime_1.API_ADMIN_WHOAMI_JS, encoding: "utf-8" },
81058
+ { file: "api/admin/password.js", data: cms_runtime_1.API_ADMIN_PASSWORD_JS, encoding: "utf-8" },
80448
81059
  { file: "api/admin/upload.js", data: cms_runtime_1.API_ADMIN_UPLOAD_JS, encoding: "utf-8" },
80449
81060
  { file: "public/admin/index.html", data: cms_runtime_1.ADMIN_INDEX_HTML, encoding: "utf-8" },
80450
81061
  { file: "public/admin/app.js", data: cms_runtime_1.ADMIN_APP_JS, encoding: "utf-8" }
@@ -80534,7 +81145,7 @@ export default defineConfig({ plugins: [react()] });`;
80534
81145
  { source: "/book/", destination: "/book/index.html" }
80535
81146
  ]
80536
81147
  });
80537
- const indexHtmlCms = `<!DOCTYPE html><html lang="en"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>${lead.name}</title></head><body style="margin:0">${demoBanner(lead.name)}<div id="root"></div><script type="module" src="/src/main.jsx"></script></body></html>`;
81148
+ const indexHtmlCms = `<!DOCTYPE html><html lang="en"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>${lead.name}</title></head><body style="margin:0">${bannerFor(lead)}<div id="root"></div><script type="module" src="/src/main.jsx"></script></body></html>`;
80538
81149
  const mainJsxCms = `import { StrictMode } from 'react';
80539
81150
  import { createRoot } from 'react-dom/client';
80540
81151
  import App from './App.jsx';
@@ -80542,7 +81153,7 @@ createRoot(document.getElementById('root')).render(<StrictMode><App /></StrictMo
80542
81153
  const viteConfigCms = `import { defineConfig } from 'vite';
80543
81154
  import react from '@vitejs/plugin-react';
80544
81155
  export default defineConfig({ plugins: [react()] });`;
80545
- 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/upload.js", data: cms_runtime_1.API_ADMIN_UPLOAD_JS, encoding: "utf-8" }, { file: "public/admin/index.html", data: cms_runtime_1.ADMIN_INDEX_HTML, encoding: "utf-8" }, { file: "public/admin/app.js", data: cms_runtime_1.ADMIN_APP_JS, encoding: "utf-8" });
81156
+ 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/upload.js", data: cms_runtime_1.API_ADMIN_UPLOAD_JS, encoding: "utf-8" }, { file: "public/admin/index.html", data: cms_runtime_1.ADMIN_INDEX_HTML, encoding: "utf-8" }, { file: "public/admin/app.js", data: cms_runtime_1.ADMIN_APP_JS, encoding: "utf-8" });
80546
81157
  if (!filesToRedeploy.some((f) => f.file === "src/App.jsx")) {
80547
81158
  throw new Error("Couldn't fetch src/App.jsx from the existing deployment. Try Regenerate website first, then Redeploy CMS.");
80548
81159
  }
@@ -80552,6 +81163,7 @@ export default defineConfig({ plugins: [react()] });`;
80552
81163
  BLOB_READ_WRITE_TOKEN: blobToken,
80553
81164
  ADMIN_PASSWORD_HASH: adminHash,
80554
81165
  SESSION_SECRET: sessionSecret,
81166
+ AUTH_ENC_KEY: authEncKeyFor(lead.id),
80555
81167
  BUSINESS_NAME: lead.name
80556
81168
  };
80557
81169
  if (existingUid)
@@ -80611,7 +81223,7 @@ export default defineConfig({ plugins: [react()] });`;
80611
81223
  snapshot[appJsxIndex] = { ...snapshot[appJsxIndex], data: modifiedApp };
80612
81224
  saveSnapshot(lead.id, snapshot);
80613
81225
  const files = snapshot.map((f) => ({ file: f.file, data: f.data, encoding: f.encoding }));
80614
- const indexHtml = `<!DOCTYPE html><html lang="en"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>${lead.name}</title></head><body style="margin:0">${demoBanner(lead.name)}<div id="root"></div><script type="module" src="/src/main.jsx"></script></body></html>`;
81226
+ const indexHtml = `<!DOCTYPE html><html lang="en"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>${lead.name}</title></head><body style="margin:0">${bannerFor(lead)}<div id="root"></div><script type="module" src="/src/main.jsx"></script></body></html>`;
80615
81227
  const mainJsx = `import { StrictMode } from 'react';
80616
81228
  import { createRoot } from 'react-dom/client';
80617
81229
  import App from './App.jsx';
@@ -80650,7 +81262,8 @@ export default defineConfig({ plugins: [react()] });`;
80650
81262
  const adminEnv = {
80651
81263
  BLOB_READ_WRITE_TOKEN: blobToken,
80652
81264
  ADMIN_PASSWORD_HASH: adminHash,
80653
- SESSION_SECRET: (0, cms_runtime_1.generateSessionSecret)()
81265
+ SESSION_SECRET: (0, cms_runtime_1.generateSessionSecret)(),
81266
+ AUTH_ENC_KEY: authEncKeyFor(lead.id)
80654
81267
  };
80655
81268
  if (bookingExistingUid)
80656
81269
  adminEnv.SITE_ID = bookingExistingUid;
@@ -80683,7 +81296,13 @@ export default defineConfig({ plugins: [react()] });`;
80683
81296
  { file: "vercel.json", data: vercelJson, encoding: "utf-8" },
80684
81297
  { file: "api/bookings.js", data: booking_runtime_1.API_BOOKINGS_JS, encoding: "utf-8" },
80685
81298
  { file: "api/booking-config.js", data: booking_runtime_1.API_BOOKING_CONFIG_JS, encoding: "utf-8" },
80686
- { file: "public/book/index.html", data: booking_runtime_1.BOOK_INDEX_HTML, encoding: "utf-8" },
81299
+ {
81300
+ file: "public/book/index.html",
81301
+ // The parent site's index.html already carries the banner; this page
81302
+ // just needs the placeholder removed.
81303
+ data: booking_runtime_1.BOOK_INDEX_HTML.replace("<!--SPROUTKIT_BANNER-->", ""),
81304
+ encoding: "utf-8"
81305
+ },
80687
81306
  { file: "public/book/app.js", data: booking_runtime_1.BOOK_APP_JS, encoding: "utf-8" },
80688
81307
  // Refresh the CMS admin app (contains the Bookings tab now)
80689
81308
  { file: "public/admin/index.html", data: cms_runtime_1.ADMIN_INDEX_HTML, encoding: "utf-8" },
@@ -80691,6 +81310,7 @@ export default defineConfig({ plugins: [react()] });`;
80691
81310
  { file: "api/content.js", data: cms_runtime_1.API_CONTENT_JS, encoding: "utf-8" },
80692
81311
  { file: "api/admin/login.js", data: cms_runtime_1.API_ADMIN_LOGIN_JS, encoding: "utf-8" },
80693
81312
  { file: "api/admin/whoami.js", data: cms_runtime_1.API_ADMIN_WHOAMI_JS, encoding: "utf-8" },
81313
+ { file: "api/admin/password.js", data: cms_runtime_1.API_ADMIN_PASSWORD_JS, encoding: "utf-8" },
80694
81314
  { file: "api/admin/upload.js", data: cms_runtime_1.API_ADMIN_UPLOAD_JS, encoding: "utf-8" }
80695
81315
  );
80696
81316
  onProgress("Deploying\u2026");
@@ -80711,8 +81331,7 @@ export default defineConfig({ plugins: [react()] });`;
80711
81331
  async function deployStandaloneBooking2(args) {
80712
81332
  const { lead, vercelToken, blobToken, resendKey, ownerEmail, bookingConfig, onProgress } = args;
80713
81333
  const adminPassword = args.reuseAdminPassword ?? (0, cms_runtime_1.generateFriendlyPassword)();
80714
- const slug = lead.name.toLowerCase().replace(/[^a-z0-9]/g, "-").replace(/-+/g, "-").slice(0, 26);
80715
- const projectName = `sprout-book-${lead.id}-${slug}`;
81334
+ const projectName = standaloneBookingProjectName(lead);
80716
81335
  onProgress("Seeding booking config\u2026");
80717
81336
  const standaloneSiteUid = siteUidFor(lead.id);
80718
81337
  try {
@@ -80737,7 +81356,8 @@ export default defineConfig({ plugins: [react()] });`;
80737
81356
  SITE_ID: standaloneSiteUid,
80738
81357
  // Admin panel auth.
80739
81358
  ADMIN_PASSWORD_HASH: adminHash,
80740
- SESSION_SECRET: (0, cms_runtime_1.generateSessionSecret)()
81359
+ SESSION_SECRET: (0, cms_runtime_1.generateSessionSecret)(),
81360
+ AUTH_ENC_KEY: authEncKeyFor(lead.id)
80741
81361
  });
80742
81362
  const files = [
80743
81363
  { file: "index.html", data: booking_runtime_1.BOOK_INDEX_HTML, encoding: "utf-8" },
@@ -80750,6 +81370,7 @@ export default defineConfig({ plugins: [react()] });`;
80750
81370
  { file: "public/admin/app.js", data: cms_runtime_1.ADMIN_APP_JS, encoding: "utf-8" },
80751
81371
  { file: "api/admin/login.js", data: cms_runtime_1.API_ADMIN_LOGIN_JS, encoding: "utf-8" },
80752
81372
  { file: "api/admin/whoami.js", data: cms_runtime_1.API_ADMIN_WHOAMI_JS, encoding: "utf-8" },
81373
+ { file: "api/admin/password.js", data: cms_runtime_1.API_ADMIN_PASSWORD_JS, encoding: "utf-8" },
80753
81374
  // Serve /admin without a trailing-slash redirect dance.
80754
81375
  {
80755
81376
  file: "vercel.json",
@@ -80776,7 +81397,7 @@ export default defineConfig({ plugins: [react()] });`;
80776
81397
  ];
80777
81398
  files[0] = {
80778
81399
  file: "index.html",
80779
- data: booking_runtime_1.BOOK_INDEX_HTML.replace(/\/book\/app\.js/g, "/app.js"),
81400
+ data: booking_runtime_1.BOOK_INDEX_HTML.replace(/\/book\/app\.js/g, "/app.js").replace("<!--SPROUTKIT_BANNER-->", bannerFor(lead)),
80780
81401
  encoding: "utf-8"
80781
81402
  };
80782
81403
  onProgress("Deploying booking site\u2026");
@@ -80809,6 +81430,150 @@ export default defineConfig({ plugins: [react()] });`;
80809
81430
  admin: { url: `${publicUrl}/admin`, password: adminPassword }
80810
81431
  };
80811
81432
  }
81433
+ async function handoverSite2(args) {
81434
+ const { lead, vercelToken, blobToken, domain, teamId, onProgress } = args;
81435
+ const db2 = (0, db_1.getDb)();
81436
+ const row = db2.get(`SELECT site_url, booking_url, admin_url, admin_password,
81437
+ booking_admin_url, booking_admin_password, handed_over_at, site_uid
81438
+ FROM leads WHERE id = ?`, [lead.id]);
81439
+ if (!row)
81440
+ throw new Error("Lead not found");
81441
+ if (!row.site_url && !row.booking_url) {
81442
+ throw new Error("Nothing to hand over \u2014 this lead has no site and no booking page yet. Build one first.");
81443
+ }
81444
+ const alreadyHandedOver = !!row.handed_over_at;
81445
+ db2.run("UPDATE leads SET handed_over_at = COALESCE(handed_over_at, datetime('now')) WHERE id = ?", [
81446
+ lead.id
81447
+ ]);
81448
+ let url = row.site_url ?? row.booking_url ?? "";
81449
+ let bannerRemoved = false;
81450
+ if (row.site_url) {
81451
+ try {
81452
+ onProgress("Rebuilding site without the demo banner\u2026");
81453
+ const result = await redeployWithoutBanner({
81454
+ lead,
81455
+ vercelToken,
81456
+ blobToken: blobToken ?? null,
81457
+ hasCms: !!row.admin_url,
81458
+ adminPassword: row.admin_password,
81459
+ siteUid: row.site_uid,
81460
+ onProgress
81461
+ });
81462
+ url = result.url;
81463
+ bannerRemoved = true;
81464
+ } catch (e) {
81465
+ db2.run("UPDATE leads SET handed_over_at = ? WHERE id = ?", [row.handed_over_at, lead.id]);
81466
+ throw new Error(`Couldn't rebuild the site without the banner: ${e.message}`);
81467
+ }
81468
+ }
81469
+ let domainInfo;
81470
+ if (domain) {
81471
+ onProgress(`Attaching ${domain}\u2026`);
81472
+ const projectId = row.site_url ? projectNameFor(lead) : standaloneBookingProjectName(lead);
81473
+ const attached = await (0, vercel_domains_1.attachDomain)({ domain, projectId, vercelToken, teamId });
81474
+ domainInfo = {
81475
+ domain: attached.domain,
81476
+ verified: attached.verified,
81477
+ dnsConfigured: attached.dnsConfigured,
81478
+ records: attached.records,
81479
+ ownershipChallenges: attached.ownershipChallenges,
81480
+ warning: attached.warning
81481
+ };
81482
+ db2.run("UPDATE leads SET custom_domain = ? WHERE id = ?", [attached.domain, lead.id]);
81483
+ }
81484
+ return {
81485
+ url,
81486
+ admin: row.admin_url && row.admin_password ? { url: row.admin_url, password: row.admin_password } : void 0,
81487
+ bookingUrl: row.booking_url ?? void 0,
81488
+ bookingAdmin: row.booking_admin_url && row.booking_admin_password ? { url: row.booking_admin_url, password: row.booking_admin_password } : void 0,
81489
+ domain: domainInfo,
81490
+ bannerRemoved,
81491
+ alreadyHandedOver
81492
+ };
81493
+ }
81494
+ async function redeployWithoutBanner(args) {
81495
+ const { lead, vercelToken, blobToken, hasCms, adminPassword, siteUid, onProgress } = args;
81496
+ const projectName = projectNameFor(lead);
81497
+ onProgress("Loading site source\u2026");
81498
+ const snapshot = loadSnapshot(lead.id);
81499
+ if (!snapshot || !snapshot.some((f) => f.file === "src/App.jsx")) {
81500
+ throw new Error("No local snapshot of this site. Run `sproutkit regenerate` once to save one, then hand over again.");
81501
+ }
81502
+ const files = snapshot.map((f) => ({
81503
+ file: f.file,
81504
+ data: f.data,
81505
+ encoding: f.encoding
81506
+ }));
81507
+ const multipage = /react-router-dom/.test(snapshot.find((f) => f.file === "src/App.jsx")?.data ?? "");
81508
+ const deps = { react: "^18.3.1", "react-dom": "^18.3.1" };
81509
+ if (multipage)
81510
+ deps["react-router-dom"] = "^6.26.2";
81511
+ if (hasCms) {
81512
+ deps["@vercel/blob"] = "^0.23.4";
81513
+ deps.bcryptjs = "^2.4.3";
81514
+ }
81515
+ const indexHtml = `<!DOCTYPE html><html lang="en"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>${lead.name}</title></head><body style="margin:0">${bannerFor(lead)}<div id="root"></div><script type="module" src="/src/main.jsx"></script></body></html>`;
81516
+ files.push({ file: "index.html", data: indexHtml, encoding: "utf-8" }, {
81517
+ file: "src/main.jsx",
81518
+ data: `import { StrictMode } from 'react';
81519
+ import { createRoot } from 'react-dom/client';
81520
+ import App from './App.jsx';
81521
+ createRoot(document.getElementById('root')).render(<StrictMode><App /></StrictMode>);`,
81522
+ encoding: "utf-8"
81523
+ }, {
81524
+ file: "vite.config.js",
81525
+ data: `import { defineConfig } from 'vite';
81526
+ import react from '@vitejs/plugin-react';
81527
+ export default defineConfig({ plugins: [react()] });`,
81528
+ encoding: "utf-8"
81529
+ }, {
81530
+ file: "package.json",
81531
+ data: JSON.stringify({
81532
+ name: "sprout-site",
81533
+ private: true,
81534
+ version: "0.1.0",
81535
+ type: "module",
81536
+ scripts: { build: "vite build", dev: "vite" },
81537
+ dependencies: deps,
81538
+ devDependencies: { "@vitejs/plugin-react": "^4.3.1", vite: "^5.4.2" }
81539
+ }),
81540
+ encoding: "utf-8"
81541
+ });
81542
+ if (hasCms && adminPassword && blobToken) {
81543
+ onProgress("Preserving the admin panel\u2026");
81544
+ files.push({
81545
+ file: "vercel.json",
81546
+ data: JSON.stringify({
81547
+ rewrites: [
81548
+ { source: "/admin", destination: "/admin/index.html" },
81549
+ { source: "/admin/", destination: "/admin/index.html" },
81550
+ { source: "/book", destination: "/book/index.html" },
81551
+ { source: "/book/", destination: "/book/index.html" }
81552
+ ]
81553
+ }),
81554
+ encoding: "utf-8"
81555
+ }, { 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/upload.js", data: cms_runtime_1.API_ADMIN_UPLOAD_JS, encoding: "utf-8" }, { file: "public/admin/index.html", data: cms_runtime_1.ADMIN_INDEX_HTML, encoding: "utf-8" }, { file: "public/admin/app.js", data: cms_runtime_1.ADMIN_APP_JS, encoding: "utf-8" });
81556
+ const env = {
81557
+ BLOB_READ_WRITE_TOKEN: blobToken,
81558
+ ADMIN_PASSWORD_HASH: await (0, cms_runtime_1.hashPassword)(adminPassword),
81559
+ SESSION_SECRET: (0, cms_runtime_1.generateSessionSecret)(),
81560
+ AUTH_ENC_KEY: authEncKeyFor(lead.id),
81561
+ BUSINESS_NAME: lead.name
81562
+ };
81563
+ if (siteUid)
81564
+ env.SITE_ID = siteUid;
81565
+ await setProjectEnv(projectName, vercelToken, env);
81566
+ }
81567
+ onProgress("Deploying\u2026");
81568
+ const res = await postVercelDeployment({ name: projectName, files, projectSettings: { framework: "vite" }, target: "production" }, vercelToken, onProgress);
81569
+ const url = await stableProjectUrl({
81570
+ projectName,
81571
+ vercelToken,
81572
+ fallbackUrl: `https://${res.data.url}`
81573
+ });
81574
+ onProgress(`Live at ${url}`);
81575
+ return { url };
81576
+ }
80812
81577
  function hasBookLink(code) {
80813
81578
  return /(href\s*=\s*["'`{]*\s*["'`]?\/book\/?["'`}]?)|(location(?:\.href)?\s*=\s*["'`]\/book["'`])/i.test(code);
80814
81579
  }
@@ -80950,6 +81715,10 @@ Return this exact shape:
80950
81715
  return null;
80951
81716
  }
80952
81717
  }
81718
+ function standaloneBookingProjectName(lead) {
81719
+ const slug = lead.name.toLowerCase().replace(/[^a-z0-9]/g, "-").replace(/-+/g, "-").slice(0, 26);
81720
+ return `sprout-book-${lead.id}-${slug}`;
81721
+ }
80953
81722
  function projectNameFor(lead) {
80954
81723
  const slug = lead.name.toLowerCase().replace(/[^a-z0-9]/g, "-").replace(/-+/g, "-").slice(0, 30);
80955
81724
  return `sprout-${lead.id}-${slug}`;
@@ -93359,6 +94128,24 @@ var require_chat_tools = __commonJS({
93359
94128
  },
93360
94129
  requiresConfirmation: true
93361
94130
  },
94131
+ {
94132
+ name: "hand_over_site",
94133
+ 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.",
94134
+ input_schema: {
94135
+ type: "object",
94136
+ properties: {
94137
+ id: { type: "number", description: "Lead id to hand over." },
94138
+ domain: {
94139
+ type: "string",
94140
+ description: "Optional: the client's own domain, e.g. 'thesalon.co.uk' or 'www.thesalon.co.uk'. Attaches it to the project and returns the exact DNS records to configure. Omit to keep the site on its .vercel.app URL."
94141
+ }
94142
+ },
94143
+ required: ["id"]
94144
+ },
94145
+ // Commercial + hard to walk back: it unbrands a live client site and marks
94146
+ // the lead won. Always ask first.
94147
+ requiresConfirmation: true
94148
+ },
93362
94149
  {
93363
94150
  name: "booking_site_for_business",
93364
94151
  description: "ONE-SHOT: build a standalone booking site for a business the user NAMES, even if it isn't a lead yet. Give it the business name (include the town/area if known, e.g. 'Kings Barbers, Leeds'). It looks the business up on Google Places, reads their existing website to understand the business and check whether they already take bookings online, saves them as a lead, and deploys a booking page on its own URL for them to link from their site. Use this for requests like 'build a booking site for The Oak Barbers' when that business isn't in the pipeline. Costs a Places lookup + a Vercel deploy.",
@@ -94013,6 +94800,25 @@ Interested?`;
94013
94800
  });
94014
94801
  return { ok: true, id, ...result };
94015
94802
  }
94803
+ case "hand_over_site": {
94804
+ const id = Number(input.id);
94805
+ const lead = db2.get("SELECT id, name, site_url, booking_url FROM leads WHERE id = ?", [id]);
94806
+ if (!lead)
94807
+ throw new Error(`Lead #${id} not found`);
94808
+ if (!lead.site_url && !lead.booking_url) {
94809
+ return {
94810
+ ok: false,
94811
+ error: `#${id} ${lead.name} has no site or booking page yet \u2014 build one before handing it over.`
94812
+ };
94813
+ }
94814
+ const result = await ctx.invokeIpc("site:handover", {
94815
+ leadId: id,
94816
+ domain: input.domain
94817
+ });
94818
+ db2.run("UPDATE leads SET status = 'won' WHERE id = ?", [id]);
94819
+ ctx.emitProgress?.({ channel: "leads:changed", payload: { reason: "handover" } });
94820
+ return { ok: true, id, business: lead.name, ...result };
94821
+ }
94016
94822
  case "provision_cms": {
94017
94823
  const id = Number(input.id);
94018
94824
  const result = await ctx.invokeIpc("cms:provision", id);
@@ -95027,7 +95833,7 @@ var require_package2 = __commonJS({
95027
95833
  "package.json"(exports2, module2) {
95028
95834
  module2.exports = {
95029
95835
  name: "sproutkit-code",
95030
- version: "1.3.0",
95836
+ version: "1.5.0",
95031
95837
  description: "Sproutkit in your terminal \u2014 find leads, build sites, run outreach, all via chat.",
95032
95838
  bin: {
95033
95839
  sproutkit: "./dist/index.js"
@@ -95087,6 +95893,7 @@ var provisionCmsForLead = cms.provisionCmsForLead;
95087
95893
  var generatePreset = bookingPreset.generatePreset;
95088
95894
  var deployBookingOnly = builder.deployBookingOnly;
95089
95895
  var deployStandaloneBooking = builder.deployStandaloneBooking;
95896
+ var handoverSite = builder.handoverSite;
95090
95897
  var createBlobStore = vercelStore.createBlobStore;
95091
95898
  var refreshModels = models.refreshModels;
95092
95899
  var refreshIfStale = models.refreshIfStale;
@@ -95405,6 +96212,43 @@ var StatusBoard = class {
95405
96212
  var import_fs = __toESM(require("fs"));
95406
96213
  var import_path = __toESM(require("path"));
95407
96214
  var import_os2 = __toESM(require("os"));
96215
+ function bundledCommandsDir() {
96216
+ const candidates = [
96217
+ import_path.default.join(__dirname, "commands"),
96218
+ import_path.default.join(__dirname, "..", "dist", "commands"),
96219
+ import_path.default.join(__dirname, "..", "commands")
96220
+ ];
96221
+ for (const c2 of candidates) {
96222
+ if (import_fs.default.existsSync(import_path.default.join(c2, "sproutkit.md"))) return c2;
96223
+ }
96224
+ return candidates[0];
96225
+ }
96226
+ function commandsTargetDir() {
96227
+ return import_path.default.join(import_os2.default.homedir(), ".claude", "commands");
96228
+ }
96229
+ function installCommands() {
96230
+ try {
96231
+ const src = bundledCommandsDir();
96232
+ if (!import_fs.default.existsSync(src)) {
96233
+ return { ok: false, error: `Command source not found (looked in ${src}).` };
96234
+ }
96235
+ const dest = commandsTargetDir();
96236
+ import_fs.default.mkdirSync(dest, { recursive: true });
96237
+ let count = 0;
96238
+ for (const f of import_fs.default.readdirSync(src)) {
96239
+ if (!f.endsWith(".md")) continue;
96240
+ if (f !== "sproutkit.md" && !f.startsWith("sproutkit-")) continue;
96241
+ import_fs.default.copyFileSync(import_path.default.join(src, f), import_path.default.join(dest, f));
96242
+ count++;
96243
+ }
96244
+ return { ok: true, count, path: dest };
96245
+ } catch (e) {
96246
+ return { ok: false, error: e.message };
96247
+ }
96248
+ }
96249
+ function commandsInstalled() {
96250
+ return import_fs.default.existsSync(import_path.default.join(commandsTargetDir(), "sproutkit.md"));
96251
+ }
95408
96252
  function bundledSkillDir() {
95409
96253
  const candidates = [
95410
96254
  import_path.default.join(__dirname, "skill"),
@@ -95514,10 +96358,16 @@ async function offerSkillInstall(force) {
95514
96358
  const res = installSkill();
95515
96359
  if (res.ok) {
95516
96360
  say(c.green(` \u2713 Skill installed to ${res.path}`));
95517
- say(c.dim(" Now run `claude` in any terminal and ask it to find or build leads."));
95518
96361
  } else {
95519
96362
  say(c.amber(` Couldn't install the skill: ${res.error}`));
95520
96363
  }
96364
+ const cmds = installCommands();
96365
+ if (cmds.ok) {
96366
+ say(c.green(` \u2713 ${cmds.count} slash commands installed \u2014 type / in Claude Code`));
96367
+ say(c.dim(" /sproutkit, /sproutkit-find, /sproutkit-build, /sproutkit-handover \u2026"));
96368
+ } else {
96369
+ say(c.amber(` Couldn't install the slash commands: ${cmds.error}`));
96370
+ }
95521
96371
  say();
95522
96372
  }
95523
96373
  async function onboardLicense(force) {
@@ -95948,12 +96798,12 @@ function makeInvokeIpc(emit) {
95948
96798
  resendKey: resendKey || "",
95949
96799
  ownerEmail: p.ownerEmail ?? "",
95950
96800
  bookingConfig: config,
95951
- reuseAdminPassword: lead.admin_password ?? null,
96801
+ reuseAdminPassword: lead.booking_admin_password ?? null,
95952
96802
  onProgress: (msg) => emit("bookings:progress", { leadId: p.leadId, msg })
95953
96803
  });
95954
96804
  db2.run(
95955
96805
  `UPDATE leads SET booking_url = ?, owner_email = ?, booking_archetype = ?, booking_config = ?,
95956
- admin_url = ?, admin_password = ?,
96806
+ booking_admin_url = ?, booking_admin_password = ?,
95957
96807
  status = CASE WHEN status = 'discovered' THEN 'site_built' ELSE status END
95958
96808
  WHERE id = ?`,
95959
96809
  [
@@ -95969,6 +96819,21 @@ function makeInvokeIpc(emit) {
95969
96819
  emit("bookings:done", { leadId: p.leadId, ...result });
95970
96820
  return result;
95971
96821
  }
96822
+ if (channel === "site:handover") {
96823
+ const p = payload;
96824
+ const db2 = getDb();
96825
+ const lead = db2.get("SELECT * FROM leads WHERE id = ?", [p.leadId]);
96826
+ if (!lead) throw new Error("Lead not found");
96827
+ const vercelToken = getSetting("vercel_token");
96828
+ if (!vercelToken) throw new Error("Vercel token not set");
96829
+ return await handoverSite({
96830
+ lead,
96831
+ vercelToken,
96832
+ blobToken: getSetting("blob_token"),
96833
+ domain: p.domain ?? null,
96834
+ onProgress: (msg) => emit("sites:progress", { leadId: p.leadId, msg })
96835
+ });
96836
+ }
95972
96837
  throw new Error(`channel not supported in CLI: ${channel}`);
95973
96838
  };
95974
96839
  }
@@ -96076,6 +96941,24 @@ async function cmdList(argv) {
96076
96941
  emitJson(rows);
96077
96942
  return;
96078
96943
  }
96944
+ if (flags.compact) {
96945
+ for (const r of rows) {
96946
+ process.stdout.write(
96947
+ JSON.stringify({
96948
+ id: r.id,
96949
+ name: r.name,
96950
+ city: r.city,
96951
+ type: r.type,
96952
+ status: r.status,
96953
+ phone: r.phone || void 0,
96954
+ site_url: r.site_url || void 0,
96955
+ booking_url: r.booking_url || void 0,
96956
+ lead_kind: r.lead_kind || void 0
96957
+ }) + "\n"
96958
+ );
96959
+ }
96960
+ return;
96961
+ }
96079
96962
  if (rows.length === 0) {
96080
96963
  say(c.dim(' No leads yet. Try: sproutkit find "barbers in <area>"'));
96081
96964
  return;
@@ -96307,6 +97190,90 @@ async function cmdPipeline(argv) {
96307
97190
  }
96308
97191
  say();
96309
97192
  }
97193
+ async function cmdHandover(argv) {
97194
+ const { flags, rest } = parseArgs(argv);
97195
+ const json = !!flags.json;
97196
+ const id = Number(flags.id ?? rest[0]);
97197
+ if (!id || Number.isNaN(id)) {
97198
+ const err = "Usage: sproutkit handover <leadId> [--domain thesalon.co.uk]";
97199
+ json ? emitJson({ ok: false, error: err }) : say(c.red(" " + err));
97200
+ process.exitCode = 1;
97201
+ return;
97202
+ }
97203
+ const domain = flags.domain ? String(flags.domain) : void 0;
97204
+ if (!flags.yes && !flags.y) {
97205
+ const lead = await runTool("get_lead", { id }, makeCtx({ json: true }));
97206
+ const label = lead?.name ? `${lead.name} (#${id})` : `lead #${id}`;
97207
+ const msg = `This removes the DEMO banner from ${label} and marks them won \u2014 only do it once the client has paid. Re-run with --yes to confirm: sproutkit handover ${id}${domain ? ` --domain ${domain}` : ""} --yes`;
97208
+ json ? emitJson({ ok: false, error: msg, needsConfirmation: true }) : say(c.amber(" " + msg));
97209
+ process.exitCode = 1;
97210
+ return;
97211
+ }
97212
+ const status = json ? void 0 : new Status();
97213
+ if (status) status.start(`Handing over lead #${id}`);
97214
+ try {
97215
+ const r = await runTool("hand_over_site", { id, domain }, makeCtx({ json, status }));
97216
+ if (r && r.ok === false) {
97217
+ if (status) status.done(false, r.error ?? "Handover failed");
97218
+ if (json) emitJson(r);
97219
+ process.exitCode = 1;
97220
+ return;
97221
+ }
97222
+ if (status) status.done(true, r.bannerRemoved ? "Demo banner removed" : "Handed over");
97223
+ autoSync();
97224
+ if (json) {
97225
+ emitJson(r);
97226
+ return;
97227
+ }
97228
+ say();
97229
+ say(c.bold(` Handover \u2014 ${r.business ?? `lead #${id}`}`));
97230
+ if (r.alreadyHandedOver) say(c.dim(" (already handed over previously \u2014 re-running is safe)"));
97231
+ say();
97232
+ if (r.url) say(` Website: ${r.url}`);
97233
+ if (r.admin) {
97234
+ say(` Admin panel: ${r.admin.url}`);
97235
+ say(` Password: ${c.bold(r.admin.password)}`);
97236
+ say(c.dim(" (unless the client has already changed it in the panel)"));
97237
+ }
97238
+ if (r.bookingUrl) say(` Booking page: ${r.bookingUrl}`);
97239
+ if (r.bookingAdmin) {
97240
+ say(` Booking admin: ${r.bookingAdmin.url}`);
97241
+ say(` Password: ${c.bold(r.bookingAdmin.password)}`);
97242
+ }
97243
+ if (r.domain) {
97244
+ const d = r.domain;
97245
+ say();
97246
+ say(c.bold(` Domain \u2014 ${d.domain}`));
97247
+ if (d.dnsConfigured) {
97248
+ say(c.green(" DNS is already pointing at us. Nothing to do."));
97249
+ } else {
97250
+ say(c.dim(" Ask the client to add these records at their domain registrar:"));
97251
+ if (d.records.length) {
97252
+ for (const rec of d.records) {
97253
+ say(` ${rec.type.padEnd(6)} ${rec.name.padEnd(6)} ${rec.value}${rec.note ? c.dim(` (${rec.note})`) : ""}`);
97254
+ }
97255
+ }
97256
+ if (d.ownershipChallenges.length) {
97257
+ say(c.dim(" Plus this record to prove ownership:"));
97258
+ for (const rec of d.ownershipChallenges) {
97259
+ say(` ${rec.type.padEnd(6)} ${rec.name} ${rec.value}`);
97260
+ }
97261
+ }
97262
+ if (d.warning) say(c.amber(" " + d.warning));
97263
+ say(c.dim(" DNS can take minutes to 48h. Re-check with: sproutkit handover " + id + " --domain " + d.domain));
97264
+ }
97265
+ }
97266
+ if (!r.bannerRemoved && r.bookingUrl && !r.url) {
97267
+ say();
97268
+ say(c.dim(" Booking-only client: the demo banner comes off next time the booking site deploys."));
97269
+ }
97270
+ say();
97271
+ } catch (e) {
97272
+ if (status) status.done(false, "Handover failed");
97273
+ json ? emitJson({ ok: false, error: e.message }) : say(c.red(" " + e.message));
97274
+ process.exitCode = 1;
97275
+ }
97276
+ }
96310
97277
  async function cmdRegenerate(argv) {
96311
97278
  const { flags, rest } = parseArgs(argv);
96312
97279
  const json = !!flags.json;
@@ -96364,6 +97331,7 @@ async function cmdCms(argv) {
96364
97331
  if (status) status.done(true, result.publicUrl ? `CMS live at ${result.publicUrl}` : `CMS set up for #${id}`);
96365
97332
  autoSync();
96366
97333
  if (json) emitJson(result);
97334
+ else sayAdminHandover(result.adminUrl, result.adminPassword);
96367
97335
  } catch (e) {
96368
97336
  if (status) status.done(false, "CMS setup failed");
96369
97337
  json ? emitJson({ ok: false, error: e.message }) : say(c.red(" " + e.message));
@@ -96377,6 +97345,8 @@ function sayAdminHandover(adminUrl, adminPassword) {
96377
97345
  say(` Admin panel: ${adminUrl}`);
96378
97346
  say(` Password: ${adminPassword}`);
96379
97347
  say(c.dim(" Send these to the client \u2014 they manage bookings and content there."));
97348
+ say(c.dim(" They can change this password under Password in the panel; after that"));
97349
+ say(c.dim(" your copy is out of date and only they know the live one."));
96380
97350
  }
96381
97351
  async function cmdBook(argv) {
96382
97352
  const { flags, rest } = parseArgs(argv);
@@ -96676,6 +97646,14 @@ async function runDoctor() {
96676
97646
  fix: "Run 'sproutkit setup' and say yes to installing the skill, so `claude` can drive Sproutkit."
96677
97647
  }
96678
97648
  );
97649
+ rows.push(
97650
+ commandsInstalled() ? { level: "ok", label: "Slash commands", detail: commandsTargetDir() } : {
97651
+ level: "warn",
97652
+ label: "Slash commands",
97653
+ detail: "not installed",
97654
+ fix: "Run 'sproutkit setup' so /sproutkit-find, /sproutkit-build etc. appear when you type / in Claude Code."
97655
+ }
97656
+ );
96679
97657
  say();
96680
97658
  say(c.greenBold(" Sproutkit doctor"));
96681
97659
  say(c.dim(` Backend: ${backend === "claude_code" ? "Claude subscription" : "Anthropic API key"}`));
@@ -96741,7 +97719,7 @@ async function main() {
96741
97719
  closeTty();
96742
97720
  return;
96743
97721
  }
96744
- if (arg === "find" || arg === "list" || arg === "build" || arg === "build-all" || arg === "regenerate" || arg === "cms" || arg === "book" || arg === "outreach" || arg === "pipeline") {
97722
+ if (arg === "find" || arg === "list" || arg === "build" || arg === "build-all" || arg === "regenerate" || arg === "cms" || arg === "book" || arg === "handover" || arg === "outreach" || arg === "pipeline") {
96745
97723
  const subArgs = process.argv.slice(3);
96746
97724
  if (subArgs.includes("--help") || subArgs.includes("-h")) {
96747
97725
  const USAGE = {
@@ -96753,7 +97731,7 @@ async function main() {
96753
97731
  " --json machine-readable output"
96754
97732
  ],
96755
97733
  list: [
96756
- "sproutkit list [--status <s>] [--city <c>] [--limit <n>] [--json]",
97734
+ "sproutkit list [--status <s>] [--city <c>] [--limit <n>] [--json|--compact]",
96757
97735
  " statuses: discovered, site_built, contacted, replied, won, dead"
96758
97736
  ],
96759
97737
  build: [
@@ -96781,6 +97759,13 @@ async function main() {
96781
97759
  ' --instructions "\u2026" e.g. "45-min slots, add beard trim \xA312"',
96782
97760
  " --email <addr> owner email for booking notifications"
96783
97761
  ],
97762
+ handover: [
97763
+ "sproutkit handover <leadId> [--domain thesalon.co.uk]",
97764
+ " the paid-deliverable step: removes the DEMO banner, attaches the",
97765
+ " client's own domain, and prints every credential in one place",
97766
+ " --domain <domain> client's domain; prints the DNS records to set",
97767
+ " --yes required \u2014 confirms the client has paid"
97768
+ ],
96784
97769
  outreach: [
96785
97770
  "sproutkit outreach [--ids 3,5] [--channel sms|whatsapp] [--json]",
96786
97771
  " Per lead: phone -> a messaging link you open; no phone -> emailed (needs SMTP).",
@@ -96815,6 +97800,7 @@ async function main() {
96815
97800
  else if (arg === "regenerate") await cmdRegenerate(subArgs);
96816
97801
  else if (arg === "cms") await cmdCms(subArgs);
96817
97802
  else if (arg === "book") await cmdBook(subArgs);
97803
+ else if (arg === "handover") await cmdHandover(subArgs);
96818
97804
  else if (arg === "pipeline") await cmdPipeline(subArgs);
96819
97805
  else await cmdOutreach(subArgs);
96820
97806
  closeTty();
@@ -96880,6 +97866,10 @@ async function main() {
96880
97866
  if (res.ok) say(c.dim(" Installed the Sproutkit skill for Claude Code."));
96881
97867
  else say(c.amber(` Couldn't install the Sproutkit skill: ${res.error}`));
96882
97868
  }
97869
+ if (!commandsInstalled()) {
97870
+ const res = installCommands();
97871
+ if (res.ok) say(c.dim(` Installed ${res.count} /sproutkit slash commands.`));
97872
+ }
96883
97873
  await notifyIfOutdated();
96884
97874
  say();
96885
97875
  say(c.greenBold(" Sproutkit") + c.dim(" \u2014 starting Claude Code"));