sproutkit-code 1.2.2 → 1.4.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.
@@ -0,0 +1,18 @@
1
+ ---
2
+ description: Build a booking site (for a lead, or any business by name)
3
+ argument-hint: "<leadId> | \"Business Name, Town\" [--standalone]"
4
+ allowed-tools: Bash(sproutkit *)
5
+ ---
6
+ Build a booking site. The operator asked: $ARGUMENTS
7
+
8
+ Use `sproutkit book`. Notes:
9
+ - A lead id adds booking onto a site Sproutkit already built (same domain, /book).
10
+ - `--standalone` gives a booking site on its OWN url — for a client who already
11
+ has their own website and just needs bookings.
12
+ - A business NAME instead of an id works even if they aren't a lead yet: it looks
13
+ them up, reads their site, and builds a booking page in one shot.
14
+ - `--archetype slots|reservation|enquiry`, `--instructions "…"`, `--email owner@x`.
15
+
16
+ Booking sites include a client `/admin` panel. The result carries `adminUrl` and
17
+ `adminPassword` — ALWAYS pass both on to the operator. The password is shown only
18
+ at build time, so if you swallow it the client can't log in.
@@ -0,0 +1,18 @@
1
+ ---
2
+ description: Build a demo website for a lead
3
+ argument-hint: "<leadId> [--multipage] [instructions]"
4
+ allowed-tools: Bash(sproutkit *)
5
+ ---
6
+ Build a site. The operator asked: $ARGUMENTS
7
+
8
+ Use `sproutkit build <leadId>`. Notes:
9
+ - `--multipage` gives real separate pages instead of one long scroll.
10
+ - Put any styling or content steer in `--instructions "…"`.
11
+ - `--skip-qa` only if they want speed over polish.
12
+ - No lead id given? Show `sproutkit list --status discovered` and ask which one.
13
+
14
+ Every generated site carries a DEMO banner by design — that's what stops clients
15
+ using the free preview as their real site. Don't try to remove it here; that's
16
+ what `/sproutkit-handover` does once they've paid.
17
+
18
+ When it's done, give the operator the live URL.
@@ -0,0 +1,15 @@
1
+ ---
2
+ description: Find local businesses with no website (or no online booking)
3
+ argument-hint: "[area] e.g. Leeds LS1, or \"Swansea --no-booking\""
4
+ allowed-tools: Bash(sproutkit *)
5
+ ---
6
+ Find new leads. The operator asked for: $ARGUMENTS
7
+
8
+ Run `sproutkit find` with that area. Notes:
9
+ - Default finds businesses with NO website. If they mention booking, WhatsApp
10
+ bookings, or "has a site already", add `--mode no_booking` instead.
11
+ - Pass `--categories` only if they named specific trades.
12
+ - Results stream in as each category returns, so let it finish before summarising.
13
+
14
+ Then report how many were found and the most promising few, with a one-line
15
+ reason each. Don't build anything yet.
@@ -0,0 +1,22 @@
1
+ ---
2
+ description: Hand a finished site to a paying client (removes the DEMO banner)
3
+ argument-hint: "<leadId> [--domain thesalon.co.uk]"
4
+ allowed-tools: Bash(sproutkit *)
5
+ ---
6
+ Hand over a site. The operator asked: $ARGUMENTS
7
+
8
+ This is the PAID-DELIVERABLE step. It removes the DEMO banner from a live site,
9
+ attaches the client's domain, and marks the lead won. The banner is what stops a
10
+ client using a free preview as their real site, so:
11
+
12
+ 1. Confirm the operator is telling you this client has PAID or signed off. If
13
+ that isn't explicit, ask — don't infer it from the site looking finished.
14
+ 2. Then run `sproutkit handover <leadId> --yes` (add `--domain x.co.uk` if given).
15
+ The command refuses without `--yes` by design.
16
+
17
+ Afterwards, relay the whole credentials sheet verbatim: site URL, /admin panel
18
+ and password, booking page and its password, and any DNS records the client has
19
+ to set at their registrar. That output is the client handover — don't summarise
20
+ the passwords away.
21
+
22
+ To re-check whether a domain's DNS has propagated, run the same command again.
@@ -0,0 +1,16 @@
1
+ ---
2
+ description: Show current leads and their stage
3
+ argument-hint: "[filter] e.g. site_built, or Leeds"
4
+ allowed-tools: Bash(sproutkit *)
5
+ ---
6
+ Leads:
7
+
8
+ !`sproutkit list --compact 2>/dev/null`
9
+
10
+ The operator asked to see: $ARGUMENTS
11
+
12
+ Every lead is listed above (one JSON object per line, nothing truncated).
13
+
14
+ Show the matching leads as a compact table — id, name, town, stage, and whether
15
+ they have a site / booking page. If they didn't narrow it down, group by stage.
16
+ Read-only: don't build, contact, or change anything.
@@ -0,0 +1,17 @@
1
+ ---
2
+ description: Prepare outreach messages for leads with a built site
3
+ argument-hint: "[--ids 3,5] [--channel sms|whatsapp|email]"
4
+ allowed-tools: Bash(sproutkit *)
5
+ ---
6
+ Prepare outreach. The operator asked: $ARGUMENTS
7
+
8
+ This CONTACTS REAL BUSINESSES and moves leads to Contacted, so:
9
+
10
+ 1. First run `sproutkit list --status site_built --json` and show exactly who is
11
+ about to be contacted, with a count.
12
+ 2. Ask the operator to confirm before running `sproutkit outreach`.
13
+ 3. Only then run it. Never add `--ids` you invented — use the ones they gave.
14
+
15
+ Routing is per-lead automatically: mobile number → SMS/WhatsApp, otherwise email
16
+ (harvesting the address if needed). Only pass `--channel` if they asked for a
17
+ specific one.
@@ -0,0 +1,20 @@
1
+ ---
2
+ description: Run the full pipeline — build then prepare outreach, in parallel
3
+ argument-hint: "[x3] [--book] [--limit 10]"
4
+ allowed-tools: Bash(sproutkit *)
5
+ ---
6
+ Run the pipeline. The operator asked: $ARGUMENTS
7
+
8
+ This builds sites AND prepares outreach to real businesses, for many leads at
9
+ once. Before running:
10
+
11
+ 1. Show which leads are queued (`sproutkit list --status discovered --json`) and
12
+ the count.
13
+ 2. Confirm with the operator.
14
+ 3. Run `sproutkit pipeline x<N>` where N is the concurrency they asked for
15
+ (default 3). Add `--book` to include a booking page, `--limit`/`--ids` to
16
+ scope it.
17
+
18
+ It prints a live board and can be stopped with the stop key. When it finishes,
19
+ report how many succeeded, and pass on any client admin panel credentials it
20
+ printed.
@@ -0,0 +1,15 @@
1
+ ---
2
+ description: Sproutkit — show the pipeline and what to do next
3
+ allowed-tools: Bash(sproutkit *)
4
+ ---
5
+ Current pipeline:
6
+
7
+ !`sproutkit list --compact 2>/dev/null`
8
+
9
+ Setup check:
10
+
11
+ !`sproutkit doctor 2>&1 | tail -20`
12
+
13
+ Summarise the pipeline for the operator in a few lines: how many leads at each
14
+ stage, and the single most useful next action. Mention any setup problem the
15
+ doctor output reveals. Don't run anything else unless asked.
package/dist/index.js CHANGED
@@ -183,6 +183,17 @@ var require_db = __commonJS({
183
183
  } catch {
184
184
  }
185
185
  }
186
+ for (const col of [
187
+ "booking_admin_url TEXT",
188
+ "booking_admin_password TEXT",
189
+ "handed_over_at TEXT",
190
+ "custom_domain TEXT"
191
+ ]) {
192
+ try {
193
+ db2.exec(`ALTER TABLE leads ADD COLUMN ${col}`);
194
+ } catch {
195
+ }
196
+ }
186
197
  try {
187
198
  db2.exec("ALTER TABLE leads ADD COLUMN dead_at TEXT");
188
199
  } catch {
@@ -31050,6 +31061,121 @@ export default function App() {
31050
31061
  }
31051
31062
  });
31052
31063
 
31064
+ // ../desktop/dist/main/lib/vercel-domains.js
31065
+ var require_vercel_domains = __commonJS({
31066
+ "../desktop/dist/main/lib/vercel-domains.js"(exports2) {
31067
+ "use strict";
31068
+ var __importDefault = exports2 && exports2.__importDefault || function(mod) {
31069
+ return mod && mod.__esModule ? mod : { "default": mod };
31070
+ };
31071
+ Object.defineProperty(exports2, "__esModule", { value: true });
31072
+ exports2.attachDomain = attachDomain;
31073
+ exports2.domainStatus = domainStatus;
31074
+ var axios_1 = __importDefault(require_axios());
31075
+ function qs(teamId) {
31076
+ return teamId ? `?teamId=${encodeURIComponent(teamId)}` : "";
31077
+ }
31078
+ function isApex(domain) {
31079
+ const parts = domain.split(".");
31080
+ if (parts.length <= 2)
31081
+ return true;
31082
+ const twoPartSuffixes = ["co.uk", "org.uk", "me.uk", "ac.uk", "com.au", "co.nz", "co.za", "com.br"];
31083
+ return parts.length === 3 && twoPartSuffixes.includes(parts.slice(-2).join("."));
31084
+ }
31085
+ async function fetchConfig(domain, projectId, vercelToken, teamId) {
31086
+ try {
31087
+ const params = new URLSearchParams({ projectIdOrName: projectId });
31088
+ if (teamId)
31089
+ params.set("teamId", teamId);
31090
+ const res = await axios_1.default.get(`https://api.vercel.com/v6/domains/${encodeURIComponent(domain)}/config?${params.toString()}`, { headers: { Authorization: `Bearer ${vercelToken}` }, timeout: 2e4 });
31091
+ return res.data;
31092
+ } catch {
31093
+ return null;
31094
+ }
31095
+ }
31096
+ function recordsFrom(domain, cfg) {
31097
+ const apex = isApex(domain);
31098
+ if (!cfg)
31099
+ return [];
31100
+ if (apex) {
31101
+ const ip = cfg.recommendedIPv4?.find((r) => r.rank === 1)?.value?.[0] ?? cfg.recommendedIPv4?.[0]?.value?.[0];
31102
+ if (!ip)
31103
+ return [];
31104
+ return [{ type: "A", name: "@", value: ip, note: "root domain" }];
31105
+ }
31106
+ const target = cfg.recommendedCNAME?.find((r) => r.rank === 1)?.value ?? cfg.recommendedCNAME?.[0]?.value;
31107
+ if (!target)
31108
+ return [];
31109
+ const sub = domain.split(".")[0];
31110
+ return [{ type: "CNAME", name: sub, value: target }];
31111
+ }
31112
+ async function attachDomain(args) {
31113
+ const { vercelToken, projectId, teamId } = args;
31114
+ const domain = args.domain.trim().toLowerCase().replace(/^https?:\/\//, "").replace(/\/.*$/, "");
31115
+ if (!/^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)+$/.test(domain)) {
31116
+ throw new Error(`"${args.domain}" doesn't look like a domain name (expected e.g. thesalon.co.uk).`);
31117
+ }
31118
+ let verified = false;
31119
+ let alreadyAttached = false;
31120
+ let apexName = domain;
31121
+ let ownershipChallenges = [];
31122
+ let warning;
31123
+ try {
31124
+ 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 });
31125
+ verified = !!res.data.verified;
31126
+ apexName = res.data.apexName ?? domain;
31127
+ ownershipChallenges = (res.data.verification ?? []).filter((v) => v.type?.toUpperCase() === "TXT").map((v) => ({ type: "TXT", name: v.domain, value: v.value, note: v.reason }));
31128
+ } catch (e) {
31129
+ const err = e;
31130
+ const status = err.response?.status;
31131
+ const msg = err.response?.data?.error?.message ?? e.message;
31132
+ if (status === 400) {
31133
+ alreadyAttached = true;
31134
+ } else if (status === 409) {
31135
+ throw new Error(`${domain} is already attached to a different Vercel project or account. Remove it there first, then retry. (${msg})`);
31136
+ } else if (status === 402) {
31137
+ throw new Error(`Vercel needs a payment method on the account before adding a custom domain. (${msg})`);
31138
+ } else if (status === 403) {
31139
+ throw new Error(`Your Vercel token doesn't have permission to add ${domain}. (${msg})`);
31140
+ } else {
31141
+ throw new Error(`Couldn't attach ${domain}: ${msg}`);
31142
+ }
31143
+ }
31144
+ const cfg = await fetchConfig(domain, projectId, vercelToken, teamId);
31145
+ const records = recordsFrom(domain, cfg);
31146
+ if (!records.length) {
31147
+ 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.";
31148
+ }
31149
+ return {
31150
+ domain,
31151
+ apexDomain: apexName,
31152
+ verified,
31153
+ dnsConfigured: cfg ? cfg.misconfigured === false : false,
31154
+ alreadyAttached,
31155
+ records,
31156
+ ownershipChallenges,
31157
+ warning
31158
+ };
31159
+ }
31160
+ async function domainStatus(args) {
31161
+ const { domain, projectId, vercelToken, teamId } = args;
31162
+ let verified = false;
31163
+ try {
31164
+ 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 });
31165
+ verified = !!res.data.verified;
31166
+ } catch {
31167
+ }
31168
+ const cfg = await fetchConfig(domain, projectId, vercelToken, teamId);
31169
+ return {
31170
+ domain,
31171
+ verified,
31172
+ dnsConfigured: cfg ? cfg.misconfigured === false : false,
31173
+ records: recordsFrom(domain, cfg)
31174
+ };
31175
+ }
31176
+ }
31177
+ });
31178
+
31053
31179
  // ../../node_modules/bcryptjs/umd/index.js
31054
31180
  var require_umd = __commonJS({
31055
31181
  "../../node_modules/bcryptjs/umd/index.js"(exports2, module2) {
@@ -33287,13 +33413,23 @@ export default async function handler(req, res) {
33287
33413
  var s = useState(null);
33288
33414
  var v = s[0], set = s[1];
33289
33415
  var loading = useState(true);
33416
+ // Standalone booking sites ship /api/bookings but NO /api/content. Treat a
33417
+ // missing content API as "this is a bookings-only site" instead of hanging
33418
+ // the whole panel on a permanent Loading\u2026 state.
33419
+ var available = useState(true);
33290
33420
  useEffect(function () {
33291
- fetch("/api/content").then(function (r) { return r.json(); }).then(function (d) {
33421
+ fetch("/api/content").then(function (r) {
33422
+ if (!r.ok) throw new Error("no content api");
33423
+ return r.json();
33424
+ }).then(function (d) {
33292
33425
  set(d);
33293
33426
  loading[1](false);
33427
+ }).catch(function () {
33428
+ available[1](false);
33429
+ loading[1](false);
33294
33430
  });
33295
33431
  }, []);
33296
- return { content: v, setContent: set, loading: loading[0] };
33432
+ return { content: v, setContent: set, loading: loading[0], available: available[0] };
33297
33433
  }
33298
33434
 
33299
33435
  function Login(props) {
@@ -33823,7 +33959,7 @@ export default async function handler(req, res) {
33823
33959
  );
33824
33960
  }
33825
33961
 
33826
- function BookingsPanel() {
33962
+ function BookingsPanel(props) {
33827
33963
  var sub = useState("incoming");
33828
33964
  var b = useBookings();
33829
33965
  var c = useBookingConfig();
@@ -33832,7 +33968,7 @@ export default async function handler(req, res) {
33832
33968
  h("div", null,
33833
33969
  h("div", { style: { fontSize: 15, fontWeight: 600 } }, "Bookings"),
33834
33970
  h("div", { style: { fontSize: 12, color: "var(--muted)" } },
33835
- h("a", { href: "/book", target: "_blank" }, "Public booking page \u2192")
33971
+ h("a", { href: props && props.bookingPath ? props.bookingPath : "/book", target: "_blank" }, "Public booking page \u2192")
33836
33972
  )
33837
33973
  ),
33838
33974
  h("div", { style: { display: "flex", gap: 4 } },
@@ -33861,9 +33997,14 @@ export default async function handler(req, res) {
33861
33997
  if (!authed[0]) {
33862
33998
  return h(Login, { onLogin: function () { authed[1](true); } });
33863
33999
  }
33864
- if (ctl.loading || !ctl.content) {
34000
+ if (ctl.loading) {
33865
34001
  return h("div", { className: "app" }, "Loading\u2026");
33866
34002
  }
34003
+ // Bookings-only site: no Site content tab to switch to, and the public
34004
+ // booking page is the site root rather than /book.
34005
+ if (!ctl.available || !ctl.content) {
34006
+ return h("div", { className: "app" }, h(BookingsPanel, { bookingPath: "/" }));
34007
+ }
33867
34008
 
33868
34009
  // Top nav lets the owner flip between Site content and Bookings.
33869
34010
  return h("div", { className: "app" },
@@ -34339,6 +34480,7 @@ export default async function handler(req, res) {
34339
34480
  </style>
34340
34481
  </head>
34341
34482
  <body>
34483
+ <!--SPROUTKIT_BANNER-->
34342
34484
  <div id="root"></div>
34343
34485
  <script src="https://unpkg.com/react@18/umd/react.production.min.js" crossorigin></script>
34344
34486
  <script src="https://unpkg.com/react-dom@18/umd/react-dom.production.min.js" crossorigin></script>
@@ -79659,6 +79801,7 @@ var require_builder = __commonJS({
79659
79801
  exports2.redeployCmsOnly = redeployCmsOnly;
79660
79802
  exports2.deployBookingOnly = deployBookingOnly2;
79661
79803
  exports2.deployStandaloneBooking = deployStandaloneBooking2;
79804
+ exports2.handoverSite = handoverSite2;
79662
79805
  var sdk_1 = __importDefault(require_sdk());
79663
79806
  var axios_1 = __importDefault(require_axios());
79664
79807
  var crypto_1 = require("crypto");
@@ -79670,6 +79813,7 @@ var require_builder = __commonJS({
79670
79813
  var path3 = __importStar(require("path"));
79671
79814
  var models_1 = require_models();
79672
79815
  var cms_template_1 = require_cms_template();
79816
+ var vercel_domains_1 = require_vercel_domains();
79673
79817
  var cms_runtime_1 = require_cms_runtime();
79674
79818
  var booking_runtime_1 = require_booking_runtime();
79675
79819
  function siteUidFor(leadId) {
@@ -79744,7 +79888,18 @@ var require_builder = __commonJS({
79744
79888
  const pattern = new RegExp(`https?:\\/\\/[^\\s"'\`)]*(${STOCK_HOSTS.map((h) => h.replace(/\./g, "\\.")).join("|")})[^\\s"'\`)]*`, "gi");
79745
79889
  return code.replace(pattern, "");
79746
79890
  }
79747
- function demoBanner(businessName) {
79891
+ function bannerFor(lead) {
79892
+ let handedOver = false;
79893
+ try {
79894
+ const row = (0, db_1.getDb)().get("SELECT handed_over_at FROM leads WHERE id = ?", [lead.id]);
79895
+ handedOver = !!row?.handed_over_at;
79896
+ } catch {
79897
+ }
79898
+ return demoBanner(lead.name, handedOver);
79899
+ }
79900
+ function demoBanner(businessName, handedOver) {
79901
+ if (handedOver)
79902
+ return "";
79748
79903
  const esc = (v) => v.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;");
79749
79904
  const label = `Preview site for ${esc(businessName)}`;
79750
79905
  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">
@@ -80324,7 +80479,7 @@ ${prompt}` : prompt;
80324
80479
  onProgress("Deploying to Vercel...");
80325
80480
  const indexHtml = `<!DOCTYPE html>
80326
80481
  <html lang="en"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>${lead.name}</title></head>
80327
- <body style="margin:0">${demoBanner(lead.name)}<div id="root"></div><script type="module" src="/src/main.jsx"></script></body></html>`;
80482
+ <body style="margin:0">${bannerFor(lead)}<div id="root"></div><script type="module" src="/src/main.jsx"></script></body></html>`;
80328
80483
  const mainJsx = `import { StrictMode } from 'react';
80329
80484
  import { createRoot } from 'react-dom/client';
80330
80485
  import App from './App.jsx';
@@ -80519,7 +80674,7 @@ export default defineConfig({ plugins: [react()] });`;
80519
80674
  { source: "/book/", destination: "/book/index.html" }
80520
80675
  ]
80521
80676
  });
80522
- 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>`;
80677
+ 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>`;
80523
80678
  const mainJsxCms = `import { StrictMode } from 'react';
80524
80679
  import { createRoot } from 'react-dom/client';
80525
80680
  import App from './App.jsx';
@@ -80596,7 +80751,7 @@ export default defineConfig({ plugins: [react()] });`;
80596
80751
  snapshot[appJsxIndex] = { ...snapshot[appJsxIndex], data: modifiedApp };
80597
80752
  saveSnapshot(lead.id, snapshot);
80598
80753
  const files = snapshot.map((f) => ({ file: f.file, data: f.data, encoding: f.encoding }));
80599
- 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>`;
80754
+ 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>`;
80600
80755
  const mainJsx = `import { StrictMode } from 'react';
80601
80756
  import { createRoot } from 'react-dom/client';
80602
80757
  import App from './App.jsx';
@@ -80668,7 +80823,13 @@ export default defineConfig({ plugins: [react()] });`;
80668
80823
  { file: "vercel.json", data: vercelJson, encoding: "utf-8" },
80669
80824
  { file: "api/bookings.js", data: booking_runtime_1.API_BOOKINGS_JS, encoding: "utf-8" },
80670
80825
  { file: "api/booking-config.js", data: booking_runtime_1.API_BOOKING_CONFIG_JS, encoding: "utf-8" },
80671
- { file: "public/book/index.html", data: booking_runtime_1.BOOK_INDEX_HTML, encoding: "utf-8" },
80826
+ {
80827
+ file: "public/book/index.html",
80828
+ // The parent site's index.html already carries the banner; this page
80829
+ // just needs the placeholder removed.
80830
+ data: booking_runtime_1.BOOK_INDEX_HTML.replace("<!--SPROUTKIT_BANNER-->", ""),
80831
+ encoding: "utf-8"
80832
+ },
80672
80833
  { file: "public/book/app.js", data: booking_runtime_1.BOOK_APP_JS, encoding: "utf-8" },
80673
80834
  // Refresh the CMS admin app (contains the Bookings tab now)
80674
80835
  { file: "public/admin/index.html", data: cms_runtime_1.ADMIN_INDEX_HTML, encoding: "utf-8" },
@@ -80695,8 +80856,8 @@ export default defineConfig({ plugins: [react()] });`;
80695
80856
  }
80696
80857
  async function deployStandaloneBooking2(args) {
80697
80858
  const { lead, vercelToken, blobToken, resendKey, ownerEmail, bookingConfig, onProgress } = args;
80698
- const slug = lead.name.toLowerCase().replace(/[^a-z0-9]/g, "-").replace(/-+/g, "-").slice(0, 26);
80699
- const projectName = `sprout-book-${lead.id}-${slug}`;
80859
+ const adminPassword = args.reuseAdminPassword ?? (0, cms_runtime_1.generateFriendlyPassword)();
80860
+ const projectName = standaloneBookingProjectName(lead);
80700
80861
  onProgress("Seeding booking config\u2026");
80701
80862
  const standaloneSiteUid = siteUidFor(lead.id);
80702
80863
  try {
@@ -80712,18 +80873,39 @@ export default defineConfig({ plugins: [react()] });`;
80712
80873
  throw new Error(`Failed to seed booking config: ${e.message}`);
80713
80874
  }
80714
80875
  onProgress("Setting booking env vars\u2026");
80876
+ const adminHash = await (0, cms_runtime_1.hashPassword)(adminPassword);
80715
80877
  await setProjectEnv(projectName, vercelToken, {
80716
80878
  RESEND_API_KEY: resendKey,
80717
80879
  OWNER_EMAIL: ownerEmail,
80718
80880
  BUSINESS_NAME: lead.name,
80719
80881
  BLOB_READ_WRITE_TOKEN: blobToken,
80720
- SITE_ID: standaloneSiteUid
80882
+ SITE_ID: standaloneSiteUid,
80883
+ // Admin panel auth.
80884
+ ADMIN_PASSWORD_HASH: adminHash,
80885
+ SESSION_SECRET: (0, cms_runtime_1.generateSessionSecret)()
80721
80886
  });
80722
80887
  const files = [
80723
80888
  { file: "index.html", data: booking_runtime_1.BOOK_INDEX_HTML, encoding: "utf-8" },
80724
80889
  { file: "app.js", data: booking_runtime_1.BOOK_APP_JS, encoding: "utf-8" },
80725
80890
  { file: "api/bookings.js", data: booking_runtime_1.API_BOOKINGS_JS, encoding: "utf-8" },
80726
80891
  { file: "api/booking-config.js", data: booking_runtime_1.API_BOOKING_CONFIG_JS, encoding: "utf-8" },
80892
+ // The client's own dashboard: view bookings, edit services/hours. Same
80893
+ // admin the CMS sites use — it already has a Bookings tab.
80894
+ { file: "public/admin/index.html", data: cms_runtime_1.ADMIN_INDEX_HTML, encoding: "utf-8" },
80895
+ { file: "public/admin/app.js", data: cms_runtime_1.ADMIN_APP_JS, encoding: "utf-8" },
80896
+ { file: "api/admin/login.js", data: cms_runtime_1.API_ADMIN_LOGIN_JS, encoding: "utf-8" },
80897
+ { file: "api/admin/whoami.js", data: cms_runtime_1.API_ADMIN_WHOAMI_JS, encoding: "utf-8" },
80898
+ // Serve /admin without a trailing-slash redirect dance.
80899
+ {
80900
+ file: "vercel.json",
80901
+ data: JSON.stringify({
80902
+ rewrites: [
80903
+ { source: "/admin", destination: "/admin/index.html" },
80904
+ { source: "/admin/", destination: "/admin/index.html" }
80905
+ ]
80906
+ }),
80907
+ encoding: "utf-8"
80908
+ },
80727
80909
  {
80728
80910
  file: "package.json",
80729
80911
  data: JSON.stringify({
@@ -80731,14 +80913,15 @@ export default defineConfig({ plugins: [react()] });`;
80731
80913
  private: true,
80732
80914
  version: "0.1.0",
80733
80915
  type: "module",
80734
- dependencies: { "@vercel/blob": "^0.23.4" }
80916
+ // bcryptjs is required by api/admin/login.js.
80917
+ dependencies: { "@vercel/blob": "^0.23.4", bcryptjs: "^2.4.3" }
80735
80918
  }),
80736
80919
  encoding: "utf-8"
80737
80920
  }
80738
80921
  ];
80739
80922
  files[0] = {
80740
80923
  file: "index.html",
80741
- data: booking_runtime_1.BOOK_INDEX_HTML.replace(/\/book\/app\.js/g, "/app.js"),
80924
+ data: booking_runtime_1.BOOK_INDEX_HTML.replace(/\/book\/app\.js/g, "/app.js").replace("<!--SPROUTKIT_BANNER-->", bannerFor(lead)),
80742
80925
  encoding: "utf-8"
80743
80926
  };
80744
80927
  onProgress("Deploying booking site\u2026");
@@ -80765,7 +80948,154 @@ export default defineConfig({ plugins: [react()] });`;
80765
80948
  fallbackUrl: `https://${res.data.url}`
80766
80949
  });
80767
80950
  onProgress(`Live at ${publicUrl}`);
80768
- return { url: publicUrl, bookingUrl: publicUrl };
80951
+ return {
80952
+ url: publicUrl,
80953
+ bookingUrl: publicUrl,
80954
+ admin: { url: `${publicUrl}/admin`, password: adminPassword }
80955
+ };
80956
+ }
80957
+ async function handoverSite2(args) {
80958
+ const { lead, vercelToken, blobToken, domain, teamId, onProgress } = args;
80959
+ const db2 = (0, db_1.getDb)();
80960
+ const row = db2.get(`SELECT site_url, booking_url, admin_url, admin_password,
80961
+ booking_admin_url, booking_admin_password, handed_over_at, site_uid
80962
+ FROM leads WHERE id = ?`, [lead.id]);
80963
+ if (!row)
80964
+ throw new Error("Lead not found");
80965
+ if (!row.site_url && !row.booking_url) {
80966
+ throw new Error("Nothing to hand over \u2014 this lead has no site and no booking page yet. Build one first.");
80967
+ }
80968
+ const alreadyHandedOver = !!row.handed_over_at;
80969
+ db2.run("UPDATE leads SET handed_over_at = COALESCE(handed_over_at, datetime('now')) WHERE id = ?", [
80970
+ lead.id
80971
+ ]);
80972
+ let url = row.site_url ?? row.booking_url ?? "";
80973
+ let bannerRemoved = false;
80974
+ if (row.site_url) {
80975
+ try {
80976
+ onProgress("Rebuilding site without the demo banner\u2026");
80977
+ const result = await redeployWithoutBanner({
80978
+ lead,
80979
+ vercelToken,
80980
+ blobToken: blobToken ?? null,
80981
+ hasCms: !!row.admin_url,
80982
+ adminPassword: row.admin_password,
80983
+ siteUid: row.site_uid,
80984
+ onProgress
80985
+ });
80986
+ url = result.url;
80987
+ bannerRemoved = true;
80988
+ } catch (e) {
80989
+ db2.run("UPDATE leads SET handed_over_at = ? WHERE id = ?", [row.handed_over_at, lead.id]);
80990
+ throw new Error(`Couldn't rebuild the site without the banner: ${e.message}`);
80991
+ }
80992
+ }
80993
+ let domainInfo;
80994
+ if (domain) {
80995
+ onProgress(`Attaching ${domain}\u2026`);
80996
+ const projectId = row.site_url ? projectNameFor(lead) : standaloneBookingProjectName(lead);
80997
+ const attached = await (0, vercel_domains_1.attachDomain)({ domain, projectId, vercelToken, teamId });
80998
+ domainInfo = {
80999
+ domain: attached.domain,
81000
+ verified: attached.verified,
81001
+ dnsConfigured: attached.dnsConfigured,
81002
+ records: attached.records,
81003
+ ownershipChallenges: attached.ownershipChallenges,
81004
+ warning: attached.warning
81005
+ };
81006
+ db2.run("UPDATE leads SET custom_domain = ? WHERE id = ?", [attached.domain, lead.id]);
81007
+ }
81008
+ return {
81009
+ url,
81010
+ admin: row.admin_url && row.admin_password ? { url: row.admin_url, password: row.admin_password } : void 0,
81011
+ bookingUrl: row.booking_url ?? void 0,
81012
+ bookingAdmin: row.booking_admin_url && row.booking_admin_password ? { url: row.booking_admin_url, password: row.booking_admin_password } : void 0,
81013
+ domain: domainInfo,
81014
+ bannerRemoved,
81015
+ alreadyHandedOver
81016
+ };
81017
+ }
81018
+ async function redeployWithoutBanner(args) {
81019
+ const { lead, vercelToken, blobToken, hasCms, adminPassword, siteUid, onProgress } = args;
81020
+ const projectName = projectNameFor(lead);
81021
+ onProgress("Loading site source\u2026");
81022
+ const snapshot = loadSnapshot(lead.id);
81023
+ if (!snapshot || !snapshot.some((f) => f.file === "src/App.jsx")) {
81024
+ throw new Error("No local snapshot of this site. Run `sproutkit regenerate` once to save one, then hand over again.");
81025
+ }
81026
+ const files = snapshot.map((f) => ({
81027
+ file: f.file,
81028
+ data: f.data,
81029
+ encoding: f.encoding
81030
+ }));
81031
+ const multipage = /react-router-dom/.test(snapshot.find((f) => f.file === "src/App.jsx")?.data ?? "");
81032
+ const deps = { react: "^18.3.1", "react-dom": "^18.3.1" };
81033
+ if (multipage)
81034
+ deps["react-router-dom"] = "^6.26.2";
81035
+ if (hasCms) {
81036
+ deps["@vercel/blob"] = "^0.23.4";
81037
+ deps.bcryptjs = "^2.4.3";
81038
+ }
81039
+ 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>`;
81040
+ files.push({ file: "index.html", data: indexHtml, encoding: "utf-8" }, {
81041
+ file: "src/main.jsx",
81042
+ data: `import { StrictMode } from 'react';
81043
+ import { createRoot } from 'react-dom/client';
81044
+ import App from './App.jsx';
81045
+ createRoot(document.getElementById('root')).render(<StrictMode><App /></StrictMode>);`,
81046
+ encoding: "utf-8"
81047
+ }, {
81048
+ file: "vite.config.js",
81049
+ data: `import { defineConfig } from 'vite';
81050
+ import react from '@vitejs/plugin-react';
81051
+ export default defineConfig({ plugins: [react()] });`,
81052
+ encoding: "utf-8"
81053
+ }, {
81054
+ file: "package.json",
81055
+ data: JSON.stringify({
81056
+ name: "sprout-site",
81057
+ private: true,
81058
+ version: "0.1.0",
81059
+ type: "module",
81060
+ scripts: { build: "vite build", dev: "vite" },
81061
+ dependencies: deps,
81062
+ devDependencies: { "@vitejs/plugin-react": "^4.3.1", vite: "^5.4.2" }
81063
+ }),
81064
+ encoding: "utf-8"
81065
+ });
81066
+ if (hasCms && adminPassword && blobToken) {
81067
+ onProgress("Preserving the admin panel\u2026");
81068
+ files.push({
81069
+ file: "vercel.json",
81070
+ data: JSON.stringify({
81071
+ rewrites: [
81072
+ { source: "/admin", destination: "/admin/index.html" },
81073
+ { source: "/admin/", destination: "/admin/index.html" },
81074
+ { source: "/book", destination: "/book/index.html" },
81075
+ { source: "/book/", destination: "/book/index.html" }
81076
+ ]
81077
+ }),
81078
+ encoding: "utf-8"
81079
+ }, { 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" });
81080
+ const env = {
81081
+ BLOB_READ_WRITE_TOKEN: blobToken,
81082
+ ADMIN_PASSWORD_HASH: await (0, cms_runtime_1.hashPassword)(adminPassword),
81083
+ SESSION_SECRET: (0, cms_runtime_1.generateSessionSecret)(),
81084
+ BUSINESS_NAME: lead.name
81085
+ };
81086
+ if (siteUid)
81087
+ env.SITE_ID = siteUid;
81088
+ await setProjectEnv(projectName, vercelToken, env);
81089
+ }
81090
+ onProgress("Deploying\u2026");
81091
+ const res = await postVercelDeployment({ name: projectName, files, projectSettings: { framework: "vite" }, target: "production" }, vercelToken, onProgress);
81092
+ const url = await stableProjectUrl({
81093
+ projectName,
81094
+ vercelToken,
81095
+ fallbackUrl: `https://${res.data.url}`
81096
+ });
81097
+ onProgress(`Live at ${url}`);
81098
+ return { url };
80769
81099
  }
80770
81100
  function hasBookLink(code) {
80771
81101
  return /(href\s*=\s*["'`{]*\s*["'`]?\/book\/?["'`}]?)|(location(?:\.href)?\s*=\s*["'`]\/book["'`])/i.test(code);
@@ -80908,6 +81238,10 @@ Return this exact shape:
80908
81238
  return null;
80909
81239
  }
80910
81240
  }
81241
+ function standaloneBookingProjectName(lead) {
81242
+ const slug = lead.name.toLowerCase().replace(/[^a-z0-9]/g, "-").replace(/-+/g, "-").slice(0, 26);
81243
+ return `sprout-book-${lead.id}-${slug}`;
81244
+ }
80911
81245
  function projectNameFor(lead) {
80912
81246
  const slug = lead.name.toLowerCase().replace(/[^a-z0-9]/g, "-").replace(/-+/g, "-").slice(0, 30);
80913
81247
  return `sprout-${lead.id}-${slug}`;
@@ -93317,6 +93651,24 @@ var require_chat_tools = __commonJS({
93317
93651
  },
93318
93652
  requiresConfirmation: true
93319
93653
  },
93654
+ {
93655
+ name: "hand_over_site",
93656
+ 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.",
93657
+ input_schema: {
93658
+ type: "object",
93659
+ properties: {
93660
+ id: { type: "number", description: "Lead id to hand over." },
93661
+ domain: {
93662
+ type: "string",
93663
+ 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."
93664
+ }
93665
+ },
93666
+ required: ["id"]
93667
+ },
93668
+ // Commercial + hard to walk back: it unbrands a live client site and marks
93669
+ // the lead won. Always ask first.
93670
+ requiresConfirmation: true
93671
+ },
93320
93672
  {
93321
93673
  name: "booking_site_for_business",
93322
93674
  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.",
@@ -93928,7 +94280,11 @@ Interested?`;
93928
94280
  website: biz.website,
93929
94281
  currentBooking: check2.verdict === "messaging_only" ? check2.detail : check2.verdict,
93930
94282
  standalone: true,
93931
- ...result
94283
+ ...result,
94284
+ // Surfaced flat as well as nested so these always reach the operator —
94285
+ // they are the only copy of the client's password.
94286
+ adminUrl: result.admin.url,
94287
+ adminPassword: result.admin.password
93932
94288
  };
93933
94289
  }
93934
94290
  case "add_booking_site": {
@@ -93944,7 +94300,14 @@ Interested?`;
93944
94300
  archetypeOverride: input.archetype,
93945
94301
  ownerEmail: input.ownerEmail
93946
94302
  });
93947
- return { ok: true, id, standalone: true, ...result2 };
94303
+ return {
94304
+ ok: true,
94305
+ id,
94306
+ standalone: true,
94307
+ ...result2,
94308
+ adminUrl: result2.admin.url,
94309
+ adminPassword: result2.admin.password
94310
+ };
93948
94311
  }
93949
94312
  if (!lead.site_url) {
93950
94313
  return {
@@ -93960,6 +94323,25 @@ Interested?`;
93960
94323
  });
93961
94324
  return { ok: true, id, ...result };
93962
94325
  }
94326
+ case "hand_over_site": {
94327
+ const id = Number(input.id);
94328
+ const lead = db2.get("SELECT id, name, site_url, booking_url FROM leads WHERE id = ?", [id]);
94329
+ if (!lead)
94330
+ throw new Error(`Lead #${id} not found`);
94331
+ if (!lead.site_url && !lead.booking_url) {
94332
+ return {
94333
+ ok: false,
94334
+ error: `#${id} ${lead.name} has no site or booking page yet \u2014 build one before handing it over.`
94335
+ };
94336
+ }
94337
+ const result = await ctx.invokeIpc("site:handover", {
94338
+ leadId: id,
94339
+ domain: input.domain
94340
+ });
94341
+ db2.run("UPDATE leads SET status = 'won' WHERE id = ?", [id]);
94342
+ ctx.emitProgress?.({ channel: "leads:changed", payload: { reason: "handover" } });
94343
+ return { ok: true, id, business: lead.name, ...result };
94344
+ }
93963
94345
  case "provision_cms": {
93964
94346
  const id = Number(input.id);
93965
94347
  const result = await ctx.invokeIpc("cms:provision", id);
@@ -94974,7 +95356,7 @@ var require_package2 = __commonJS({
94974
95356
  "package.json"(exports2, module2) {
94975
95357
  module2.exports = {
94976
95358
  name: "sproutkit-code",
94977
- version: "1.2.2",
95359
+ version: "1.4.0",
94978
95360
  description: "Sproutkit in your terminal \u2014 find leads, build sites, run outreach, all via chat.",
94979
95361
  bin: {
94980
95362
  sproutkit: "./dist/index.js"
@@ -95034,6 +95416,7 @@ var provisionCmsForLead = cms.provisionCmsForLead;
95034
95416
  var generatePreset = bookingPreset.generatePreset;
95035
95417
  var deployBookingOnly = builder.deployBookingOnly;
95036
95418
  var deployStandaloneBooking = builder.deployStandaloneBooking;
95419
+ var handoverSite = builder.handoverSite;
95037
95420
  var createBlobStore = vercelStore.createBlobStore;
95038
95421
  var refreshModels = models.refreshModels;
95039
95422
  var refreshIfStale = models.refreshIfStale;
@@ -95352,6 +95735,43 @@ var StatusBoard = class {
95352
95735
  var import_fs = __toESM(require("fs"));
95353
95736
  var import_path = __toESM(require("path"));
95354
95737
  var import_os2 = __toESM(require("os"));
95738
+ function bundledCommandsDir() {
95739
+ const candidates = [
95740
+ import_path.default.join(__dirname, "commands"),
95741
+ import_path.default.join(__dirname, "..", "dist", "commands"),
95742
+ import_path.default.join(__dirname, "..", "commands")
95743
+ ];
95744
+ for (const c2 of candidates) {
95745
+ if (import_fs.default.existsSync(import_path.default.join(c2, "sproutkit.md"))) return c2;
95746
+ }
95747
+ return candidates[0];
95748
+ }
95749
+ function commandsTargetDir() {
95750
+ return import_path.default.join(import_os2.default.homedir(), ".claude", "commands");
95751
+ }
95752
+ function installCommands() {
95753
+ try {
95754
+ const src = bundledCommandsDir();
95755
+ if (!import_fs.default.existsSync(src)) {
95756
+ return { ok: false, error: `Command source not found (looked in ${src}).` };
95757
+ }
95758
+ const dest = commandsTargetDir();
95759
+ import_fs.default.mkdirSync(dest, { recursive: true });
95760
+ let count = 0;
95761
+ for (const f of import_fs.default.readdirSync(src)) {
95762
+ if (!f.endsWith(".md")) continue;
95763
+ if (f !== "sproutkit.md" && !f.startsWith("sproutkit-")) continue;
95764
+ import_fs.default.copyFileSync(import_path.default.join(src, f), import_path.default.join(dest, f));
95765
+ count++;
95766
+ }
95767
+ return { ok: true, count, path: dest };
95768
+ } catch (e) {
95769
+ return { ok: false, error: e.message };
95770
+ }
95771
+ }
95772
+ function commandsInstalled() {
95773
+ return import_fs.default.existsSync(import_path.default.join(commandsTargetDir(), "sproutkit.md"));
95774
+ }
95355
95775
  function bundledSkillDir() {
95356
95776
  const candidates = [
95357
95777
  import_path.default.join(__dirname, "skill"),
@@ -95461,10 +95881,16 @@ async function offerSkillInstall(force) {
95461
95881
  const res = installSkill();
95462
95882
  if (res.ok) {
95463
95883
  say(c.green(` \u2713 Skill installed to ${res.path}`));
95464
- say(c.dim(" Now run `claude` in any terminal and ask it to find or build leads."));
95465
95884
  } else {
95466
95885
  say(c.amber(` Couldn't install the skill: ${res.error}`));
95467
95886
  }
95887
+ const cmds = installCommands();
95888
+ if (cmds.ok) {
95889
+ say(c.green(` \u2713 ${cmds.count} slash commands installed \u2014 type / in Claude Code`));
95890
+ say(c.dim(" /sproutkit, /sproutkit-find, /sproutkit-build, /sproutkit-handover \u2026"));
95891
+ } else {
95892
+ say(c.amber(` Couldn't install the slash commands: ${cmds.error}`));
95893
+ }
95468
95894
  say();
95469
95895
  }
95470
95896
  async function onboardLicense(force) {
@@ -95895,21 +96321,42 @@ function makeInvokeIpc(emit) {
95895
96321
  resendKey: resendKey || "",
95896
96322
  ownerEmail: p.ownerEmail ?? "",
95897
96323
  bookingConfig: config,
96324
+ reuseAdminPassword: lead.booking_admin_password ?? null,
95898
96325
  onProgress: (msg) => emit("bookings:progress", { leadId: p.leadId, msg })
95899
96326
  });
95900
96327
  db2.run(
95901
- "UPDATE leads SET booking_url = ?, owner_email = ?, booking_archetype = ?, booking_config = ? WHERE id = ?",
96328
+ `UPDATE leads SET booking_url = ?, owner_email = ?, booking_archetype = ?, booking_config = ?,
96329
+ booking_admin_url = ?, booking_admin_password = ?,
96330
+ status = CASE WHEN status = 'discovered' THEN 'site_built' ELSE status END
96331
+ WHERE id = ?`,
95902
96332
  [
95903
96333
  result.bookingUrl,
95904
96334
  p.ownerEmail ?? "",
95905
96335
  config.archetype,
95906
96336
  JSON.stringify(config),
96337
+ result.admin.url,
96338
+ result.admin.password,
95907
96339
  p.leadId
95908
96340
  ]
95909
96341
  );
95910
96342
  emit("bookings:done", { leadId: p.leadId, ...result });
95911
96343
  return result;
95912
96344
  }
96345
+ if (channel === "site:handover") {
96346
+ const p = payload;
96347
+ const db2 = getDb();
96348
+ const lead = db2.get("SELECT * FROM leads WHERE id = ?", [p.leadId]);
96349
+ if (!lead) throw new Error("Lead not found");
96350
+ const vercelToken = getSetting("vercel_token");
96351
+ if (!vercelToken) throw new Error("Vercel token not set");
96352
+ return await handoverSite({
96353
+ lead,
96354
+ vercelToken,
96355
+ blobToken: getSetting("blob_token"),
96356
+ domain: p.domain ?? null,
96357
+ onProgress: (msg) => emit("sites:progress", { leadId: p.leadId, msg })
96358
+ });
96359
+ }
95913
96360
  throw new Error(`channel not supported in CLI: ${channel}`);
95914
96361
  };
95915
96362
  }
@@ -96017,6 +96464,24 @@ async function cmdList(argv) {
96017
96464
  emitJson(rows);
96018
96465
  return;
96019
96466
  }
96467
+ if (flags.compact) {
96468
+ for (const r of rows) {
96469
+ process.stdout.write(
96470
+ JSON.stringify({
96471
+ id: r.id,
96472
+ name: r.name,
96473
+ city: r.city,
96474
+ type: r.type,
96475
+ status: r.status,
96476
+ phone: r.phone || void 0,
96477
+ site_url: r.site_url || void 0,
96478
+ booking_url: r.booking_url || void 0,
96479
+ lead_kind: r.lead_kind || void 0
96480
+ }) + "\n"
96481
+ );
96482
+ }
96483
+ return;
96484
+ }
96020
96485
  if (rows.length === 0) {
96021
96486
  say(c.dim(' No leads yet. Try: sproutkit find "barbers in <area>"'));
96022
96487
  return;
@@ -96165,7 +96630,14 @@ async function cmdPipeline(argv) {
96165
96630
  setRow("building", "preparing outreach\u2026");
96166
96631
  await runTool("prepare_outreach", { ids: [t.id] }, makeCtx({ json: true }));
96167
96632
  setRow("built", booked.bookingUrl ?? "booking site live");
96168
- results.push({ id: t.id, name: t.name, ok: true, url: booked.bookingUrl });
96633
+ results.push({
96634
+ id: t.id,
96635
+ name: t.name,
96636
+ ok: true,
96637
+ url: booked.bookingUrl,
96638
+ adminUrl: booked.adminUrl,
96639
+ adminPassword: booked.adminPassword
96640
+ });
96169
96641
  } catch (e) {
96170
96642
  setRow("failed", e.message.slice(0, 60));
96171
96643
  results.push({ id: t.id, name: t.name, ok: false, error: e.message });
@@ -96232,8 +96704,98 @@ async function cmdPipeline(argv) {
96232
96704
  c.green(` Pipeline ${cancelled ? "stopped" : "done"} \u2014 ${ok} client${ok === 1 ? "" : "s"} built + outreach prepared`) + (failed ? c.red(`, ${failed} failed`) : "") + (cancelled ? c.amber(`, ${cancelled} not started`) : "") + c.dim(` (concurrency ${concurrency}).`)
96233
96705
  );
96234
96706
  say(c.dim(" Open the message links above, or check emailed leads in your Contacted column."));
96707
+ const withAdmin = results.filter((r) => r.ok && r.adminUrl && r.adminPassword);
96708
+ if (withAdmin.length) {
96709
+ say();
96710
+ say(c.bold(" Client admin panels"));
96711
+ for (const r of withAdmin) say(` ${r.name} \u2014 ${r.adminUrl} ${c.bold(String(r.adminPassword))}`);
96712
+ say(c.dim(" Also stored per lead: sproutkit list --json"));
96713
+ }
96235
96714
  say();
96236
96715
  }
96716
+ async function cmdHandover(argv) {
96717
+ const { flags, rest } = parseArgs(argv);
96718
+ const json = !!flags.json;
96719
+ const id = Number(flags.id ?? rest[0]);
96720
+ if (!id || Number.isNaN(id)) {
96721
+ const err = "Usage: sproutkit handover <leadId> [--domain thesalon.co.uk]";
96722
+ json ? emitJson({ ok: false, error: err }) : say(c.red(" " + err));
96723
+ process.exitCode = 1;
96724
+ return;
96725
+ }
96726
+ const domain = flags.domain ? String(flags.domain) : void 0;
96727
+ if (!flags.yes && !flags.y) {
96728
+ const lead = await runTool("get_lead", { id }, makeCtx({ json: true }));
96729
+ const label = lead?.name ? `${lead.name} (#${id})` : `lead #${id}`;
96730
+ 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`;
96731
+ json ? emitJson({ ok: false, error: msg, needsConfirmation: true }) : say(c.amber(" " + msg));
96732
+ process.exitCode = 1;
96733
+ return;
96734
+ }
96735
+ const status = json ? void 0 : new Status();
96736
+ if (status) status.start(`Handing over lead #${id}`);
96737
+ try {
96738
+ const r = await runTool("hand_over_site", { id, domain }, makeCtx({ json, status }));
96739
+ if (r && r.ok === false) {
96740
+ if (status) status.done(false, r.error ?? "Handover failed");
96741
+ if (json) emitJson(r);
96742
+ process.exitCode = 1;
96743
+ return;
96744
+ }
96745
+ if (status) status.done(true, r.bannerRemoved ? "Demo banner removed" : "Handed over");
96746
+ autoSync();
96747
+ if (json) {
96748
+ emitJson(r);
96749
+ return;
96750
+ }
96751
+ say();
96752
+ say(c.bold(` Handover \u2014 ${r.business ?? `lead #${id}`}`));
96753
+ if (r.alreadyHandedOver) say(c.dim(" (already handed over previously \u2014 re-running is safe)"));
96754
+ say();
96755
+ if (r.url) say(` Website: ${r.url}`);
96756
+ if (r.admin) {
96757
+ say(` Admin panel: ${r.admin.url}`);
96758
+ say(` Password: ${c.bold(r.admin.password)}`);
96759
+ }
96760
+ if (r.bookingUrl) say(` Booking page: ${r.bookingUrl}`);
96761
+ if (r.bookingAdmin) {
96762
+ say(` Booking admin: ${r.bookingAdmin.url}`);
96763
+ say(` Password: ${c.bold(r.bookingAdmin.password)}`);
96764
+ }
96765
+ if (r.domain) {
96766
+ const d = r.domain;
96767
+ say();
96768
+ say(c.bold(` Domain \u2014 ${d.domain}`));
96769
+ if (d.dnsConfigured) {
96770
+ say(c.green(" DNS is already pointing at us. Nothing to do."));
96771
+ } else {
96772
+ say(c.dim(" Ask the client to add these records at their domain registrar:"));
96773
+ if (d.records.length) {
96774
+ for (const rec of d.records) {
96775
+ say(` ${rec.type.padEnd(6)} ${rec.name.padEnd(6)} ${rec.value}${rec.note ? c.dim(` (${rec.note})`) : ""}`);
96776
+ }
96777
+ }
96778
+ if (d.ownershipChallenges.length) {
96779
+ say(c.dim(" Plus this record to prove ownership:"));
96780
+ for (const rec of d.ownershipChallenges) {
96781
+ say(` ${rec.type.padEnd(6)} ${rec.name} ${rec.value}`);
96782
+ }
96783
+ }
96784
+ if (d.warning) say(c.amber(" " + d.warning));
96785
+ say(c.dim(" DNS can take minutes to 48h. Re-check with: sproutkit handover " + id + " --domain " + d.domain));
96786
+ }
96787
+ }
96788
+ if (!r.bannerRemoved && r.bookingUrl && !r.url) {
96789
+ say();
96790
+ say(c.dim(" Booking-only client: the demo banner comes off next time the booking site deploys."));
96791
+ }
96792
+ say();
96793
+ } catch (e) {
96794
+ if (status) status.done(false, "Handover failed");
96795
+ json ? emitJson({ ok: false, error: e.message }) : say(c.red(" " + e.message));
96796
+ process.exitCode = 1;
96797
+ }
96798
+ }
96237
96799
  async function cmdRegenerate(argv) {
96238
96800
  const { flags, rest } = parseArgs(argv);
96239
96801
  const json = !!flags.json;
@@ -96291,12 +96853,21 @@ async function cmdCms(argv) {
96291
96853
  if (status) status.done(true, result.publicUrl ? `CMS live at ${result.publicUrl}` : `CMS set up for #${id}`);
96292
96854
  autoSync();
96293
96855
  if (json) emitJson(result);
96856
+ else sayAdminHandover(result.adminUrl, result.adminPassword);
96294
96857
  } catch (e) {
96295
96858
  if (status) status.done(false, "CMS setup failed");
96296
96859
  json ? emitJson({ ok: false, error: e.message }) : say(c.red(" " + e.message));
96297
96860
  process.exitCode = 1;
96298
96861
  }
96299
96862
  }
96863
+ function sayAdminHandover(adminUrl, adminPassword) {
96864
+ if (!adminUrl || !adminPassword) return;
96865
+ say("");
96866
+ say(c.bold(" Client handover"));
96867
+ say(` Admin panel: ${adminUrl}`);
96868
+ say(` Password: ${adminPassword}`);
96869
+ say(c.dim(" Send these to the client \u2014 they manage bookings and content there."));
96870
+ }
96300
96871
  async function cmdBook(argv) {
96301
96872
  const { flags, rest } = parseArgs(argv);
96302
96873
  const json = !!flags.json;
@@ -96326,7 +96897,10 @@ async function cmdBook(argv) {
96326
96897
  if (status2) status2.done(true, `${r.business} \u2014 ${r.bookingUrl}`);
96327
96898
  autoSync();
96328
96899
  if (json) emitJson(r);
96329
- else if (r.currentBooking) say(c.dim(` They currently take bookings via ${r.currentBooking}.`));
96900
+ else {
96901
+ if (r.currentBooking) say(c.dim(` They currently take bookings via ${r.currentBooking}.`));
96902
+ sayAdminHandover(r.adminUrl, r.adminPassword);
96903
+ }
96330
96904
  } catch (e) {
96331
96905
  if (status2) status2.done(false, "Failed");
96332
96906
  json ? emitJson({ ok: false, error: e.message }) : say(c.red(" " + e.message));
@@ -96366,6 +96940,7 @@ async function cmdBook(argv) {
96366
96940
  if (status) status.done(true, result.bookingUrl ? `Booking live at ${result.bookingUrl}` : `Booking added to lead #${id}`);
96367
96941
  autoSync();
96368
96942
  if (json) emitJson(result);
96943
+ else sayAdminHandover(result.adminUrl, result.adminPassword);
96369
96944
  } catch (e) {
96370
96945
  if (status) status.done(false, "Booking failed");
96371
96946
  json ? emitJson({ ok: false, error: e.message }) : say(c.red(" " + e.message));
@@ -96591,6 +97166,14 @@ async function runDoctor() {
96591
97166
  fix: "Run 'sproutkit setup' and say yes to installing the skill, so `claude` can drive Sproutkit."
96592
97167
  }
96593
97168
  );
97169
+ rows.push(
97170
+ commandsInstalled() ? { level: "ok", label: "Slash commands", detail: commandsTargetDir() } : {
97171
+ level: "warn",
97172
+ label: "Slash commands",
97173
+ detail: "not installed",
97174
+ fix: "Run 'sproutkit setup' so /sproutkit-find, /sproutkit-build etc. appear when you type / in Claude Code."
97175
+ }
97176
+ );
96594
97177
  say();
96595
97178
  say(c.greenBold(" Sproutkit doctor"));
96596
97179
  say(c.dim(` Backend: ${backend === "claude_code" ? "Claude subscription" : "Anthropic API key"}`));
@@ -96656,7 +97239,7 @@ async function main() {
96656
97239
  closeTty();
96657
97240
  return;
96658
97241
  }
96659
- if (arg === "find" || arg === "list" || arg === "build" || arg === "build-all" || arg === "regenerate" || arg === "cms" || arg === "book" || arg === "outreach" || arg === "pipeline") {
97242
+ if (arg === "find" || arg === "list" || arg === "build" || arg === "build-all" || arg === "regenerate" || arg === "cms" || arg === "book" || arg === "handover" || arg === "outreach" || arg === "pipeline") {
96660
97243
  const subArgs = process.argv.slice(3);
96661
97244
  if (subArgs.includes("--help") || subArgs.includes("-h")) {
96662
97245
  const USAGE = {
@@ -96668,7 +97251,7 @@ async function main() {
96668
97251
  " --json machine-readable output"
96669
97252
  ],
96670
97253
  list: [
96671
- "sproutkit list [--status <s>] [--city <c>] [--limit <n>] [--json]",
97254
+ "sproutkit list [--status <s>] [--city <c>] [--limit <n>] [--json|--compact]",
96672
97255
  " statuses: discovered, site_built, contacted, replied, won, dead"
96673
97256
  ],
96674
97257
  build: [
@@ -96696,6 +97279,13 @@ async function main() {
96696
97279
  ' --instructions "\u2026" e.g. "45-min slots, add beard trim \xA312"',
96697
97280
  " --email <addr> owner email for booking notifications"
96698
97281
  ],
97282
+ handover: [
97283
+ "sproutkit handover <leadId> [--domain thesalon.co.uk]",
97284
+ " the paid-deliverable step: removes the DEMO banner, attaches the",
97285
+ " client's own domain, and prints every credential in one place",
97286
+ " --domain <domain> client's domain; prints the DNS records to set",
97287
+ " --yes required \u2014 confirms the client has paid"
97288
+ ],
96699
97289
  outreach: [
96700
97290
  "sproutkit outreach [--ids 3,5] [--channel sms|whatsapp] [--json]",
96701
97291
  " Per lead: phone -> a messaging link you open; no phone -> emailed (needs SMTP).",
@@ -96730,6 +97320,7 @@ async function main() {
96730
97320
  else if (arg === "regenerate") await cmdRegenerate(subArgs);
96731
97321
  else if (arg === "cms") await cmdCms(subArgs);
96732
97322
  else if (arg === "book") await cmdBook(subArgs);
97323
+ else if (arg === "handover") await cmdHandover(subArgs);
96733
97324
  else if (arg === "pipeline") await cmdPipeline(subArgs);
96734
97325
  else await cmdOutreach(subArgs);
96735
97326
  closeTty();
@@ -96795,6 +97386,10 @@ async function main() {
96795
97386
  if (res.ok) say(c.dim(" Installed the Sproutkit skill for Claude Code."));
96796
97387
  else say(c.amber(` Couldn't install the Sproutkit skill: ${res.error}`));
96797
97388
  }
97389
+ if (!commandsInstalled()) {
97390
+ const res = installCommands();
97391
+ if (res.ok) say(c.dim(` Installed ${res.count} /sproutkit slash commands.`));
97392
+ }
96798
97393
  await notifyIfOutdated();
96799
97394
  say();
96800
97395
  say(c.greenBold(" Sproutkit") + c.dim(" \u2014 starting Claude Code"));
@@ -65,6 +65,28 @@ you can parse results reliably, then summarise them for the user in prose.
65
65
  `--archetype slots|reservation|enquiry`, `--email owner@x.com`.
66
66
  - Use `book --standalone` when the user says the client "already has a
67
67
  website" or "just needs a booking page".
68
+ - Booking sites include a client `/admin` panel where the owner sees incoming
69
+ bookings and edits their services and hours. The JSON result carries
70
+ `adminUrl` and `adminPassword` — ALWAYS relay both to the user, they are the
71
+ client handover and the password is shown only at build time. It is stored
72
+ on the lead afterwards (`sproutkit list --json`) if they lose it.
73
+ - You can also name a business that isn't a lead yet:
74
+ `sproutkit book "Kings Barbers, Leeds" --json` looks them up, reads their
75
+ site, and builds a booking page in one shot.
76
+
77
+ - **Hand a finished site to a paying client** — `sproutkit handover <leadId> --yes --json`
78
+ - THE PAID-DELIVERABLE STEP. Removes the DEMO banner, optionally attaches the
79
+ client's own domain, marks the lead won, and prints one sheet with the site
80
+ URL, /admin panel + password, booking page + password, and the DNS records
81
+ the client must set at their registrar.
82
+ - `--domain thesalon.co.uk` attaches their domain and returns the exact A or
83
+ CNAME record. DNS is out of our control, so re-run the same command later to
84
+ re-check whether it has propagated.
85
+ - `--yes` is REQUIRED. Without it the command refuses and explains why. Only
86
+ pass it when the user has clearly said the client has paid / signed off /
87
+ is going live — never infer it.
88
+ - The banner exists to stop clients using a free demo as their real site, so
89
+ do NOT run this just because a site looks finished.
68
90
 
69
91
  - **Run the full pipeline for many clients** — `sproutkit pipeline x3 --json` (optional `--book`, `--limit 10`, `--ids 3,5`)
70
92
  - Builds → (optionally adds booking) → prepares outreach for several clients
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sproutkit-code",
3
- "version": "1.2.2",
3
+ "version": "1.4.0",
4
4
  "description": "Sproutkit in your terminal — find leads, build sites, run outreach, all via chat.",
5
5
  "bin": {
6
6
  "sproutkit": "./dist/index.js"