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/commands/sproutkit-book.md +18 -0
- package/dist/commands/sproutkit-build.md +18 -0
- package/dist/commands/sproutkit-find.md +15 -0
- package/dist/commands/sproutkit-handover.md +22 -0
- package/dist/commands/sproutkit-leads.md +16 -0
- package/dist/commands/sproutkit-outreach.md +17 -0
- package/dist/commands/sproutkit-pipeline.md +20 -0
- package/dist/commands/sproutkit.md +15 -0
- package/dist/index.js +1039 -49
- package/dist/skill/SKILL.md +14 -0
- package/package.json +1 -1
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
|
-
|
|
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
|
-
|
|
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" },
|
|
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
|
|
34450
|
+
// Top nav lets the owner flip between site content, bookings and password.
|
|
33884
34451
|
return h("div", { className: "app" },
|
|
33885
|
-
h(
|
|
33886
|
-
[
|
|
34452
|
+
h(TabBar, {
|
|
34453
|
+
tabs: [
|
|
33887
34454
|
{ id: "content", label: "Site content" },
|
|
33888
|
-
{ id: "bookings", label: "Bookings" }
|
|
33889
|
-
|
|
33890
|
-
|
|
33891
|
-
|
|
33892
|
-
|
|
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
|
-
:
|
|
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
|
|
33960
|
-
|
|
33961
|
-
|
|
33962
|
-
|
|
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
|
|
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, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
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">${
|
|
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">${
|
|
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">${
|
|
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
|
-
{
|
|
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
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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"));
|