@lead-routing/cli 0.5.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -5,12 +5,12 @@ import { Command } from "commander";
5
5
 
6
6
  // src/commands/init.ts
7
7
  import { promises as dns } from "dns";
8
- import { readFileSync as readFileSync3, writeFileSync as writeFileSync3 } from "fs";
8
+ import { readFileSync as readFileSync4, writeFileSync as writeFileSync4 } from "fs";
9
9
  import { exec } from "child_process";
10
10
  import { platform } from "os";
11
- import { join as join4 } from "path";
11
+ import { join as join5 } from "path";
12
12
  import { intro, outro, note as note3, log as log7, confirm, cancel as cancel3, isCancel as isCancel3, password as promptPassword } from "@clack/prompts";
13
- import chalk from "chalk";
13
+ import chalk2 from "chalk";
14
14
 
15
15
  // src/steps/prerequisites.ts
16
16
  import { log } from "@clack/prompts";
@@ -77,12 +77,12 @@ async function collectSshConfig(opts = {}) {
77
77
  privateKeyPath = resolved;
78
78
  log2.info(`Using SSH key: ${opts.sshKey}`);
79
79
  } else {
80
- const p = await password({
80
+ const p3 = await password({
81
81
  message: `SSH password for ${opts.sshUser ?? "root"}@${host}`,
82
82
  validate: (v) => !v ? "Required" : void 0
83
83
  });
84
- if (isCancel(p)) bail(p);
85
- pwd = p;
84
+ if (isCancel(p3)) bail(p3);
85
+ pwd = p3;
86
86
  }
87
87
  return {
88
88
  host,
@@ -170,7 +170,6 @@ async function collectConfig(opts = {}) {
170
170
  if (isCancel2(adminPassword)) bail2(adminPassword);
171
171
  const sessionSecret = generateSecret(32);
172
172
  const engineWebhookSecret = generateSecret(32);
173
- const adminSecret = generateSecret(16);
174
173
  const internalApiKey = generateSecret(32);
175
174
  return {
176
175
  appUrl: appUrl.trim().replace(/\/+$/, ""),
@@ -187,7 +186,6 @@ async function collectConfig(opts = {}) {
187
186
  feedbackToEmail: "",
188
187
  sessionSecret,
189
188
  engineWebhookSecret,
190
- adminSecret,
191
189
  internalApiKey
192
190
  };
193
191
  }
@@ -336,13 +334,17 @@ function renderEnvWeb(c) {
336
334
  `SESSION_SECRET=${c.sessionSecret}`,
337
335
  ``,
338
336
  `# Admin`,
339
- `ADMIN_SECRET=${c.adminSecret}`,
340
337
  `ADMIN_EMAIL=${c.adminEmail}`,
341
338
  `ADMIN_PASSWORD=${c.adminPassword}`,
342
339
  ``,
343
340
  `# Internal API key (shared with engine for analytics)`,
344
341
  `INTERNAL_API_KEY=${c.internalApiKey}`,
345
342
  ``,
343
+ `# License`,
344
+ `LICENSE_KEY=${c.licenseKey ?? ""}`,
345
+ `LICENSE_TIER=${c.licenseTier}`,
346
+ `LICENSE_API_URL=https://lead-routing-license.artyagi2011.workers.dev`,
347
+ ``,
346
348
  `# Email (optional)`,
347
349
  `RESEND_API_KEY=${c.resendApiKey ?? ""}`,
348
350
  `FEEDBACK_TO_EMAIL=${c.feedbackToEmail ?? ""}`
@@ -370,7 +372,12 @@ function renderEnvEngine(c) {
370
372
  `ENGINE_WEBHOOK_SECRET=${c.engineWebhookSecret}`,
371
373
  ``,
372
374
  `# Internal API key (Bearer token for analytics endpoints)`,
373
- `INTERNAL_API_KEY=${c.internalApiKey}`
375
+ `INTERNAL_API_KEY=${c.internalApiKey}`,
376
+ ``,
377
+ `# License`,
378
+ `LICENSE_KEY=${c.licenseKey ?? ""}`,
379
+ `LICENSE_TIER=${c.licenseTier}`,
380
+ `LICENSE_API_URL=https://lead-routing-license.artyagi2011.workers.dev`
374
381
  ].join("\n");
375
382
  }
376
383
 
@@ -411,6 +418,14 @@ function renderCaddyfile(appUrl, engineUrl) {
411
418
  ` health_uri /health`,
412
419
  ` health_interval 15s`,
413
420
  ` }`,
421
+ `}`,
422
+ ``,
423
+ `# Marketing site \u2014 served independently`,
424
+ `openedgeai.tech {`,
425
+ ` import security_headers`,
426
+ ` root * /srv/marketing-site`,
427
+ ` file_server`,
428
+ ` try_files {path} /index.html`,
414
429
  `}`
415
430
  ].join("\n");
416
431
  }
@@ -443,6 +458,14 @@ function renderCaddyfile(appUrl, engineUrl) {
443
458
  ` health_uri /health`,
444
459
  ` health_interval 15s`,
445
460
  ` }`,
461
+ `}`,
462
+ ``,
463
+ `# Marketing site \u2014 served independently`,
464
+ `openedgeai.tech {`,
465
+ ` import security_headers`,
466
+ ` root * /srv/marketing-site`,
467
+ ` file_server`,
468
+ ` try_files {path} /index.html`,
446
469
  `}`
447
470
  ].join("\n");
448
471
  }
@@ -483,7 +506,7 @@ function getCliVersion() {
483
506
  return "0.1.0";
484
507
  }
485
508
  }
486
- function generateFiles(cfg, sshCfg) {
509
+ function generateFiles(cfg, sshCfg, license = { licenseTier: "free" }) {
487
510
  const dir = join2(process.cwd(), "lead-routing");
488
511
  mkdirSync(dir, { recursive: true });
489
512
  const dockerEngineUrl = `http://engine:3001`;
@@ -507,12 +530,13 @@ function generateFiles(cfg, sshCfg) {
507
530
  redisUrl: cfg.redisUrl,
508
531
  sessionSecret: cfg.sessionSecret,
509
532
  engineWebhookSecret: cfg.engineWebhookSecret,
510
- adminSecret: cfg.adminSecret,
511
533
  adminEmail: cfg.adminEmail,
512
534
  adminPassword: cfg.adminPassword,
513
535
  internalApiKey: cfg.internalApiKey,
514
536
  resendApiKey: cfg.resendApiKey || void 0,
515
- feedbackToEmail: cfg.feedbackToEmail || void 0
537
+ feedbackToEmail: cfg.feedbackToEmail || void 0,
538
+ licenseKey: license.licenseKey,
539
+ licenseTier: license.licenseTier
516
540
  });
517
541
  const envWeb = join2(dir, ".env.web");
518
542
  writeFileSync2(envWeb, envWebContent, "utf8");
@@ -521,7 +545,9 @@ function generateFiles(cfg, sshCfg) {
521
545
  databaseUrl: cfg.databaseUrl,
522
546
  redisUrl: cfg.redisUrl,
523
547
  engineWebhookSecret: cfg.engineWebhookSecret,
524
- internalApiKey: cfg.internalApiKey
548
+ internalApiKey: cfg.internalApiKey,
549
+ licenseKey: license.licenseKey,
550
+ licenseTier: license.licenseTier
525
551
  });
526
552
  const envEngine = join2(dir, ".env.engine");
527
553
  writeFileSync2(envEngine, envEngineContent, "utf8");
@@ -543,11 +569,13 @@ function generateFiles(cfg, sshCfg) {
543
569
  redis: cfg.managedRedis
544
570
  },
545
571
  engineWebhookSecret: cfg.engineWebhookSecret,
572
+ licenseKey: license.licenseKey,
573
+ licenseTier: license.licenseTier,
546
574
  installedAt: (/* @__PURE__ */ new Date()).toISOString(),
547
575
  version: getCliVersion()
548
576
  });
549
577
  log3.success("Generated lead-routing.json");
550
- return { dir, composeFile, envWeb, envEngine, adminSecret: cfg.adminSecret };
578
+ return { dir, composeFile, envWeb, envEngine };
551
579
  }
552
580
 
553
581
  // src/steps/check-remote-prerequisites.ts
@@ -1001,6 +1029,104 @@ ${result.stderr || result.stdout}`
1001
1029
  }
1002
1030
  };
1003
1031
 
1032
+ // src/utils/auth.ts
1033
+ import { readFileSync as readFileSync3, writeFileSync as writeFileSync3, mkdirSync as mkdirSync2 } from "fs";
1034
+ import { homedir as homedir2 } from "os";
1035
+ import { join as join4 } from "path";
1036
+
1037
+ // src/utils/license.ts
1038
+ import chalk from "chalk";
1039
+ var LICENSE_API_URL = process.env.LICENSE_API_URL || "https://lead-routing-license.artyagi2011.workers.dev";
1040
+ function formatTierBadge(tier) {
1041
+ if (tier === "pro") return chalk.bgGreen.black(" PRO ");
1042
+ return chalk.bgGray.white(" FREE ");
1043
+ }
1044
+
1045
+ // src/utils/auth.ts
1046
+ var CRED_DIR = join4(homedir2(), ".lead-routing");
1047
+ var CRED_FILE = join4(CRED_DIR, "credentials.json");
1048
+ function loadCredentials() {
1049
+ try {
1050
+ const data = readFileSync3(CRED_FILE, "utf-8");
1051
+ return JSON.parse(data);
1052
+ } catch {
1053
+ return null;
1054
+ }
1055
+ }
1056
+ function saveCredentials(creds) {
1057
+ mkdirSync2(CRED_DIR, { recursive: true });
1058
+ writeFileSync3(CRED_FILE, JSON.stringify(creds, null, 2));
1059
+ }
1060
+ function clearCredentials() {
1061
+ try {
1062
+ writeFileSync3(CRED_FILE, "");
1063
+ } catch {
1064
+ }
1065
+ }
1066
+ async function requireAuth() {
1067
+ const creds = loadCredentials();
1068
+ if (!creds?.token) {
1069
+ throw new Error("Not logged in. Run `lead-routing login` first.");
1070
+ }
1071
+ const res = await fetch(`${LICENSE_API_URL}/v1/auth/me`, {
1072
+ headers: { "Authorization": `Bearer ${creds.token}` },
1073
+ signal: AbortSignal.timeout(1e4)
1074
+ });
1075
+ if (!res.ok) {
1076
+ clearCredentials();
1077
+ throw new Error("Session expired. Run `lead-routing login` to sign in again.");
1078
+ }
1079
+ const data = await res.json();
1080
+ if (!data.customer.emailVerified) {
1081
+ throw new Error("Email not verified. Check your inbox for the verification link.");
1082
+ }
1083
+ const updated = {
1084
+ token: creds.token,
1085
+ customer: data.customer,
1086
+ storedAt: creds.storedAt
1087
+ };
1088
+ saveCredentials(updated);
1089
+ return updated;
1090
+ }
1091
+ async function apiLogin(email, password5) {
1092
+ const res = await fetch(`${LICENSE_API_URL}/v1/auth/login`, {
1093
+ method: "POST",
1094
+ headers: { "Content-Type": "application/json" },
1095
+ body: JSON.stringify({ email, password: password5 }),
1096
+ signal: AbortSignal.timeout(1e4)
1097
+ });
1098
+ if (!res.ok) {
1099
+ const body = await res.json().catch(() => ({ error: `HTTP ${res.status}` }));
1100
+ throw new Error(body.error || "Login failed");
1101
+ }
1102
+ return await res.json();
1103
+ }
1104
+ async function apiSignup(data) {
1105
+ const res = await fetch(`${LICENSE_API_URL}/v1/auth/signup`, {
1106
+ method: "POST",
1107
+ headers: { "Content-Type": "application/json" },
1108
+ body: JSON.stringify(data),
1109
+ signal: AbortSignal.timeout(1e4)
1110
+ });
1111
+ if (!res.ok) {
1112
+ const body = await res.json().catch(() => ({ error: `HTTP ${res.status}` }));
1113
+ throw new Error(body.error || "Signup failed");
1114
+ }
1115
+ const result = await res.json();
1116
+ return result.message;
1117
+ }
1118
+ async function apiResendVerification(token) {
1119
+ const res = await fetch(`${LICENSE_API_URL}/v1/auth/resend-verification`, {
1120
+ method: "POST",
1121
+ headers: { "Authorization": `Bearer ${token}` },
1122
+ signal: AbortSignal.timeout(1e4)
1123
+ });
1124
+ if (!res.ok) {
1125
+ const body = await res.json().catch(() => ({ error: "Failed" }));
1126
+ throw new Error(body.error || "Failed to resend");
1127
+ }
1128
+ }
1129
+
1004
1130
  // src/commands/init.ts
1005
1131
  var MANAGED_PACKAGE_INSTALL_URL = "https://login.salesforce.com/packaging/installPackage.apexp?p0=04tgL000000CTnp";
1006
1132
  function openBrowser(url) {
@@ -1019,7 +1145,7 @@ async function checkDnsResolvable(appUrl, engineUrl) {
1019
1145
  await dns.lookup(host);
1020
1146
  } catch {
1021
1147
  log7.warn(
1022
- `${chalk.yellow(host)} does not resolve in DNS yet.
1148
+ `${chalk2.yellow(host)} does not resolve in DNS yet.
1023
1149
  Check for typos \u2014 a bad domain will cause a 2-minute timeout at step 7.`
1024
1150
  );
1025
1151
  const go = await confirm({ message: "Continue anyway?", initialValue: true });
@@ -1035,7 +1161,7 @@ async function runInit(options = {}) {
1035
1161
  const resume = options.resume ?? false;
1036
1162
  console.log();
1037
1163
  intro(
1038
- chalk.bold.cyan("Lead Routing \u2014 Self-Hosted Setup") + (dryRun ? chalk.yellow(" [dry run]") : "") + (resume ? chalk.yellow(" [resume]") : "")
1164
+ chalk2.bold.cyan("Lead Routing \u2014 Self-Hosted Setup") + (dryRun ? chalk2.yellow(" [dry run]") : "") + (resume ? chalk2.yellow(" [resume]") : "")
1039
1165
  );
1040
1166
  const ssh = new SshConnection();
1041
1167
  if (resume) {
@@ -1067,7 +1193,7 @@ async function runInit(options = {}) {
1067
1193
  const remoteDir = await ssh.resolveHome(saved.remoteDir);
1068
1194
  log7.step("Verifying health");
1069
1195
  await verifyHealth(saved.appUrl, saved.engineUrl, ssh, remoteDir);
1070
- outro(chalk.green("\u2714 Services are healthy!"));
1196
+ outro(chalk2.green("\u2714 Services are healthy!"));
1071
1197
  } catch (err) {
1072
1198
  const message = err instanceof Error ? err.message : String(err);
1073
1199
  log7.error(`Resume failed: ${message}`);
@@ -1077,18 +1203,35 @@ async function runInit(options = {}) {
1077
1203
  }
1078
1204
  return;
1079
1205
  }
1206
+ let auth;
1207
+ try {
1208
+ auth = await requireAuth();
1209
+ } catch (err) {
1210
+ log7.error(err instanceof Error ? err.message : "Authentication required");
1211
+ note3(
1212
+ `Run one of the following:
1213
+
1214
+ ${chalk2.cyan("lead-routing signup")} Create a new account
1215
+ ${chalk2.cyan("lead-routing login")} Log in to existing account`,
1216
+ "Account Required"
1217
+ );
1218
+ process.exit(1);
1219
+ }
1220
+ log7.success(`Logged in as ${auth.customer.firstName} ${auth.customer.lastName} \u2014 ${formatTierBadge(auth.customer.tier)}`);
1080
1221
  try {
1081
- log7.step("Step 1/8 Install Salesforce Package");
1222
+ log7.step("Step 1/9 License validation");
1223
+ const licenseResult = { tier: auth.customer.tier, key: void 0 };
1224
+ log7.step("Step 2/9 Install Salesforce Package");
1082
1225
  note3(
1083
1226
  `The Lead Router managed package installs the required Connected App,
1084
1227
  triggers, and custom objects in your Salesforce org.
1085
1228
 
1086
- Install URL: ${chalk.cyan(MANAGED_PACKAGE_INSTALL_URL)}`,
1229
+ Install URL: ${chalk2.cyan(MANAGED_PACKAGE_INSTALL_URL)}`,
1087
1230
  "Salesforce Package"
1088
1231
  );
1089
1232
  log7.info("Opening install URL in your browser...");
1090
1233
  openBrowser(MANAGED_PACKAGE_INSTALL_URL);
1091
- log7.info(`${chalk.dim("If the browser didn't open, visit the URL above manually.")}`);
1234
+ log7.info(`${chalk2.dim("If the browser didn't open, visit the URL above manually.")}`);
1092
1235
  const installed = await confirm({
1093
1236
  message: 'Have you installed the package? (Click "Install for All Users" in Salesforce)',
1094
1237
  initialValue: false
@@ -1102,9 +1245,9 @@ Install URL: ${chalk.cyan(MANAGED_PACKAGE_INSTALL_URL)}`,
1102
1245
  } else {
1103
1246
  log7.success("Salesforce package installed");
1104
1247
  }
1105
- log7.step("Step 2/8 Checking local prerequisites");
1248
+ log7.step("Step 3/9 Checking local prerequisites");
1106
1249
  await checkPrerequisites();
1107
- log7.step("Step 3/8 SSH connection");
1250
+ log7.step("Step 4/9 SSH connection");
1108
1251
  const sshCfg = await collectSshConfig({
1109
1252
  sshPort: options.sshPort,
1110
1253
  sshUser: options.sshUser,
@@ -1121,42 +1264,45 @@ Install URL: ${chalk.cyan(MANAGED_PACKAGE_INSTALL_URL)}`,
1121
1264
  process.exit(1);
1122
1265
  }
1123
1266
  }
1124
- log7.step("Step 4/8 Configuration");
1267
+ log7.step("Step 5/9 Configuration");
1125
1268
  const cfg = await collectConfig({
1126
1269
  externalDb: options.externalDb,
1127
1270
  externalRedis: options.externalRedis
1128
1271
  });
1129
1272
  await checkDnsResolvable(cfg.appUrl, cfg.engineUrl);
1130
- log7.step("Step 5/8 Generating config files");
1131
- const { dir, adminSecret } = generateFiles(cfg, sshCfg);
1273
+ log7.step("Step 6/9 Generating config files");
1274
+ const { dir } = generateFiles(cfg, sshCfg, {
1275
+ licenseKey: licenseResult.key,
1276
+ licenseTier: licenseResult.tier
1277
+ });
1132
1278
  note3(
1133
- `Local config directory: ${chalk.cyan(dir)}
1279
+ `Local config directory: ${chalk2.cyan(dir)}
1134
1280
  Files created: docker-compose.yml, Caddyfile, .env.web, .env.engine, lead-routing.json`,
1135
1281
  "Files"
1136
1282
  );
1137
1283
  if (dryRun) {
1138
1284
  outro(
1139
- chalk.yellow("Dry run complete \u2014 no connection made, no services started.") + `
1285
+ chalk2.yellow("Dry run complete \u2014 no connection made, no services started.") + `
1140
1286
 
1141
- Config files written to: ${chalk.cyan(dir)}
1287
+ Config files written to: ${chalk2.cyan(dir)}
1142
1288
 
1143
- When ready, run ${chalk.cyan("lead-routing init")} (without --dry-run) to deploy.`
1289
+ When ready, run ${chalk2.cyan("lead-routing init")} (without --dry-run) to deploy.`
1144
1290
  );
1145
1291
  return;
1146
1292
  }
1147
- log7.step("Step 6/8 Remote setup");
1293
+ log7.step("Step 7/9 Remote setup");
1148
1294
  const remoteDir = await ssh.resolveHome(sshCfg.remoteDir);
1149
1295
  await checkRemotePrerequisites(ssh);
1150
1296
  await uploadFiles(ssh, dir, remoteDir);
1151
- log7.step("Step 7/8 Starting services");
1297
+ log7.step("Step 8/9 Starting services");
1152
1298
  await startServices(ssh, remoteDir);
1153
- log7.step("Step 8/8 Verifying health");
1299
+ log7.step("Step 9/9 Verifying health");
1154
1300
  await verifyHealth(cfg.appUrl, cfg.engineUrl, ssh, remoteDir);
1155
1301
  try {
1156
- const envWebPath = join4(dir, ".env.web");
1157
- const envContent = readFileSync3(envWebPath, "utf-8");
1302
+ const envWebPath = join5(dir, ".env.web");
1303
+ const envContent = readFileSync4(envWebPath, "utf-8");
1158
1304
  const cleaned = envContent.split("\n").filter((line) => !line.startsWith("ADMIN_PASSWORD=")).join("\n");
1159
- writeFileSync3(envWebPath, cleaned, "utf-8");
1305
+ writeFileSync4(envWebPath, cleaned, "utf-8");
1160
1306
  log7.success("Removed ADMIN_PASSWORD from .env.web (no longer needed after seed)");
1161
1307
  } catch {
1162
1308
  }
@@ -1166,22 +1312,20 @@ The managed package is already installed \u2014 just click "Connect Salesforce"
1166
1312
  "Next: Connect Salesforce"
1167
1313
  );
1168
1314
  outro(
1169
- chalk.green("\u2714 You're live!") + `
1315
+ chalk2.green("\u2714 You're live!") + `
1170
1316
 
1171
- Dashboard: ${chalk.cyan(cfg.appUrl)}
1172
- Routing engine: ${chalk.cyan(cfg.engineUrl)}
1317
+ Dashboard: ${chalk2.cyan(cfg.appUrl)}
1318
+ Routing engine: ${chalk2.cyan(cfg.engineUrl)}
1173
1319
 
1174
- Admin email: ${chalk.white(cfg.adminEmail)}
1175
- Admin secret: ${chalk.yellow(adminSecret)}
1176
- ${chalk.dim("run `lead-routing config show` to retrieve later")}
1320
+ Admin email: ${chalk2.white(cfg.adminEmail)}
1177
1321
 
1178
- ` + chalk.bold(" Next steps:\n") + ` ${chalk.cyan("1.")} Open ${chalk.cyan(cfg.appUrl)} and log in
1179
- ${chalk.cyan("2.")} Go to Integrations \u2192 Salesforce \u2192 Connect
1180
- ${chalk.cyan("3.")} Complete the onboarding wizard in Salesforce
1181
- ${chalk.cyan("4.")} Create your first routing rule
1322
+ ` + chalk2.bold(" Next steps:\n") + ` ${chalk2.cyan("1.")} Open ${chalk2.cyan(cfg.appUrl)} and log in
1323
+ ${chalk2.cyan("2.")} Go to Integrations \u2192 Salesforce \u2192 Connect
1324
+ ${chalk2.cyan("3.")} Complete the onboarding wizard in Salesforce
1325
+ ${chalk2.cyan("4.")} Create your first routing rule
1182
1326
 
1183
- Run ${chalk.cyan("lead-routing doctor")} to check service health at any time.
1184
- Run ${chalk.cyan("lead-routing deploy")} to update to a new version.`
1327
+ Run ${chalk2.cyan("lead-routing doctor")} to check service health at any time.
1328
+ Run ${chalk2.cyan("lead-routing deploy")} to update to a new version.`
1185
1329
  );
1186
1330
  } catch (err) {
1187
1331
  const message = err instanceof Error ? err.message : String(err);
@@ -1193,14 +1337,14 @@ The managed package is already installed \u2014 just click "Connect Salesforce"
1193
1337
  }
1194
1338
 
1195
1339
  // src/commands/deploy.ts
1196
- import { writeFileSync as writeFileSync4, unlinkSync } from "fs";
1197
- import { join as join5 } from "path";
1340
+ import { writeFileSync as writeFileSync5, unlinkSync } from "fs";
1341
+ import { join as join6 } from "path";
1198
1342
  import { tmpdir } from "os";
1199
1343
  import { intro as intro2, outro as outro2, log as log8, password as promptPassword2 } from "@clack/prompts";
1200
- import chalk2 from "chalk";
1344
+ import chalk3 from "chalk";
1201
1345
  async function runDeploy() {
1202
1346
  console.log();
1203
- intro2(chalk2.bold.cyan("Lead Routing \u2014 Deploy"));
1347
+ intro2(chalk3.bold.cyan("Lead Routing \u2014 Deploy"));
1204
1348
  const dir = findInstallDir();
1205
1349
  if (!dir) {
1206
1350
  log8.error(
@@ -1236,8 +1380,8 @@ async function runDeploy() {
1236
1380
  const remoteDir = await ssh.resolveHome(cfg.remoteDir);
1237
1381
  log8.step("Syncing Caddyfile");
1238
1382
  const caddyContent = renderCaddyfile(cfg.appUrl, cfg.engineUrl);
1239
- const tmpCaddy = join5(tmpdir(), "lead-routing-Caddyfile");
1240
- writeFileSync4(tmpCaddy, caddyContent, "utf8");
1383
+ const tmpCaddy = join6(tmpdir(), "lead-routing-Caddyfile");
1384
+ writeFileSync5(tmpCaddy, caddyContent, "utf8");
1241
1385
  await ssh.upload([{ local: tmpCaddy, remote: `${remoteDir}/Caddyfile` }]);
1242
1386
  unlinkSync(tmpCaddy);
1243
1387
  await ssh.exec("docker compose restart caddy", remoteDir);
@@ -1249,9 +1393,9 @@ async function runDeploy() {
1249
1393
  await ssh.exec("docker compose up -d --remove-orphans", remoteDir);
1250
1394
  log8.success("Services restarted");
1251
1395
  outro2(
1252
- chalk2.green("\u2714 Deployment complete!") + `
1396
+ chalk3.green("\u2714 Deployment complete!") + `
1253
1397
 
1254
- ${chalk2.cyan(cfg.appUrl)}`
1398
+ ${chalk3.cyan(cfg.appUrl)}`
1255
1399
  );
1256
1400
  } catch (err) {
1257
1401
  const message = err instanceof Error ? err.message : String(err);
@@ -1264,11 +1408,11 @@ async function runDeploy() {
1264
1408
 
1265
1409
  // src/commands/doctor.ts
1266
1410
  import { intro as intro3, outro as outro3, log as log9 } from "@clack/prompts";
1267
- import chalk3 from "chalk";
1411
+ import chalk4 from "chalk";
1268
1412
  import { execa } from "execa";
1269
1413
  async function runDoctor() {
1270
1414
  console.log();
1271
- intro3(chalk3.bold.cyan("Lead Routing \u2014 Health Check"));
1415
+ intro3(chalk4.bold.cyan("Lead Routing \u2014 Health Check"));
1272
1416
  const dir = findInstallDir();
1273
1417
  if (!dir) {
1274
1418
  log9.error("No lead-routing.json found. Run `lead-routing init` first.");
@@ -1287,17 +1431,17 @@ async function runDoctor() {
1287
1431
  checks.push(await checkEndpoint("Routing engine", `${cfg.engineUrl}/health`));
1288
1432
  console.log();
1289
1433
  for (const c of checks) {
1290
- const icon = c.pass ? chalk3.green("\u2714") : chalk3.red("\u2717");
1291
- const label = c.pass ? chalk3.white(c.label) : chalk3.red(c.label);
1292
- const detail = c.detail ? chalk3.dim(` \u2014 ${c.detail}`) : "";
1434
+ const icon = c.pass ? chalk4.green("\u2714") : chalk4.red("\u2717");
1435
+ const label = c.pass ? chalk4.white(c.label) : chalk4.red(c.label);
1436
+ const detail = c.detail ? chalk4.dim(` \u2014 ${c.detail}`) : "";
1293
1437
  console.log(` ${icon} ${label}${detail}`);
1294
1438
  }
1295
1439
  console.log();
1296
1440
  const failed = checks.filter((c) => !c.pass);
1297
1441
  if (failed.length === 0) {
1298
- outro3(chalk3.green("All checks passed"));
1442
+ outro3(chalk4.green("All checks passed"));
1299
1443
  } else {
1300
- outro3(chalk3.yellow(`${failed.length} check(s) failed`));
1444
+ outro3(chalk4.yellow(`${failed.length} check(s) failed`));
1301
1445
  process.exit(1);
1302
1446
  }
1303
1447
  }
@@ -1399,14 +1543,14 @@ async function runStatus() {
1399
1543
  }
1400
1544
 
1401
1545
  // src/commands/config.ts
1402
- import { readFileSync as readFileSync4, writeFileSync as writeFileSync5, existsSync as existsSync3 } from "fs";
1403
- import { join as join6 } from "path";
1546
+ import { readFileSync as readFileSync5, writeFileSync as writeFileSync6, existsSync as existsSync3 } from "fs";
1547
+ import { join as join7 } from "path";
1404
1548
  import { intro as intro4, outro as outro4, log as log12 } from "@clack/prompts";
1405
- import chalk4 from "chalk";
1549
+ import chalk5 from "chalk";
1406
1550
  function parseEnv(filePath) {
1407
1551
  const map = /* @__PURE__ */ new Map();
1408
1552
  if (!existsSync3(filePath)) return map;
1409
- for (const line of readFileSync4(filePath, "utf8").split("\n")) {
1553
+ for (const line of readFileSync5(filePath, "utf8").split("\n")) {
1410
1554
  const trimmed = line.trim();
1411
1555
  if (!trimmed || trimmed.startsWith("#")) continue;
1412
1556
  const eq = trimmed.indexOf("=");
@@ -1434,25 +1578,23 @@ function runConfigShow() {
1434
1578
  console.error("No lead-routing installation found in the current directory.");
1435
1579
  process.exit(1);
1436
1580
  }
1437
- const envWeb = join6(dir, ".env.web");
1581
+ const envWeb = join7(dir, ".env.web");
1438
1582
  const cfg = parseEnv(envWeb);
1439
- const adminSecret = cfg.get("ADMIN_SECRET") ?? "(not found)";
1440
1583
  const appUrl = cfg.get("APP_URL") ?? "(not found)";
1441
1584
  console.log();
1442
- console.log(chalk4.bold("Lead Routing \u2014 Installation Config"));
1585
+ console.log(chalk5.bold("Lead Routing \u2014 Installation Config"));
1443
1586
  console.log();
1444
- console.log(` Admin panel: ${chalk4.cyan(appUrl + "/admin")}`);
1445
- console.log(` Admin secret: ${chalk4.yellow(adminSecret)}`);
1587
+ console.log(` Dashboard: ${chalk5.cyan(appUrl)}`);
1446
1588
  console.log();
1447
1589
  }
1448
1590
 
1449
1591
  // src/commands/sfdc.ts
1450
1592
  import { intro as intro5, outro as outro5, text as text3, log as log15 } from "@clack/prompts";
1451
- import chalk6 from "chalk";
1593
+ import chalk7 from "chalk";
1452
1594
 
1453
1595
  // src/steps/sfdc-deploy-inline.ts
1454
- import { readFileSync as readFileSync6, writeFileSync as writeFileSync6, existsSync as existsSync5, cpSync, rmSync } from "fs";
1455
- import { join as join8, dirname as dirname2 } from "path";
1596
+ import { readFileSync as readFileSync7, writeFileSync as writeFileSync7, existsSync as existsSync5, cpSync, rmSync } from "fs";
1597
+ import { join as join9, dirname as dirname2 } from "path";
1456
1598
  import { tmpdir as tmpdir2 } from "os";
1457
1599
  import { fileURLToPath as fileURLToPath2 } from "url";
1458
1600
  import { execSync } from "child_process";
@@ -1577,9 +1719,9 @@ Content-Type: application/zip\r
1577
1719
  body
1578
1720
  });
1579
1721
  if (!res.ok) {
1580
- const text4 = await res.text();
1722
+ const text6 = await res.text();
1581
1723
  throw new Error(
1582
- `Metadata deploy request failed (${res.status}): ${text4}`
1724
+ `Metadata deploy request failed (${res.status}): ${text6}`
1583
1725
  );
1584
1726
  }
1585
1727
  const result = await res.json();
@@ -1596,9 +1738,9 @@ Content-Type: application/zip\r
1596
1738
  const url = `${this.baseUrl}/metadata/deployRequest/${deployId}?includeDetails=true`;
1597
1739
  const res = await fetch(url, { headers: this.headers() });
1598
1740
  if (!res.ok) {
1599
- const text4 = await res.text();
1741
+ const text6 = await res.text();
1600
1742
  throw new Error(
1601
- `Deploy status check failed (${res.status}): ${text4}`
1743
+ `Deploy status check failed (${res.status}): ${text6}`
1602
1744
  );
1603
1745
  }
1604
1746
  const data = await res.json();
@@ -1619,8 +1761,8 @@ var DuplicateError = class extends Error {
1619
1761
  };
1620
1762
 
1621
1763
  // src/utils/zip-source.ts
1622
- import { join as join7 } from "path";
1623
- import { readdirSync, readFileSync as readFileSync5, existsSync as existsSync4 } from "fs";
1764
+ import { join as join8 } from "path";
1765
+ import { readdirSync, readFileSync as readFileSync6, existsSync as existsSync4 } from "fs";
1624
1766
  import archiver from "archiver";
1625
1767
  var META_TYPE_MAP = {
1626
1768
  applications: "CustomApplication",
@@ -1633,10 +1775,10 @@ var META_TYPE_MAP = {
1633
1775
  tabs: "CustomTab"
1634
1776
  };
1635
1777
  async function zipSourcePackage(packageDir) {
1636
- const forceAppDefault = join7(packageDir, "force-app", "main", "default");
1778
+ const forceAppDefault = join8(packageDir, "force-app", "main", "default");
1637
1779
  let apiVersion = "59.0";
1638
1780
  try {
1639
- const proj = JSON.parse(readFileSync5(join7(packageDir, "sfdx-project.json"), "utf8"));
1781
+ const proj = JSON.parse(readFileSync6(join8(packageDir, "sfdx-project.json"), "utf8"));
1640
1782
  if (proj.sourceApiVersion) apiVersion = proj.sourceApiVersion;
1641
1783
  } catch {
1642
1784
  }
@@ -1652,15 +1794,15 @@ async function zipSourcePackage(packageDir) {
1652
1794
  archive.on("end", () => resolve(Buffer.concat(chunks)));
1653
1795
  archive.on("error", reject);
1654
1796
  for (const [dirName, metaType] of Object.entries(META_TYPE_MAP)) {
1655
- const srcDir = join7(forceAppDefault, dirName);
1797
+ const srcDir = join8(forceAppDefault, dirName);
1656
1798
  if (!existsSync4(srcDir)) continue;
1657
1799
  const entries = readdirSync(srcDir, { withFileTypes: true });
1658
1800
  for (const entry of entries) {
1659
1801
  if (dirName === "lwc" && entry.isDirectory()) {
1660
1802
  addMember(metaType, entry.name);
1661
- archive.directory(join7(srcDir, entry.name), `${dirName}/${entry.name}`);
1803
+ archive.directory(join8(srcDir, entry.name), `${dirName}/${entry.name}`);
1662
1804
  } else if (entry.isFile()) {
1663
- archive.file(join7(srcDir, entry.name), { name: `${dirName}/${entry.name}` });
1805
+ archive.file(join8(srcDir, entry.name), { name: `${dirName}/${entry.name}` });
1664
1806
  if (!entry.name.endsWith("-meta.xml")) {
1665
1807
  const memberName = entry.name.replace(/\.[^.]+$/, "");
1666
1808
  addMember(metaType, memberName);
@@ -1668,13 +1810,13 @@ async function zipSourcePackage(packageDir) {
1668
1810
  }
1669
1811
  }
1670
1812
  }
1671
- const objectsDir = join7(forceAppDefault, "objects");
1813
+ const objectsDir = join8(forceAppDefault, "objects");
1672
1814
  if (existsSync4(objectsDir)) {
1673
1815
  for (const objEntry of readdirSync(objectsDir, { withFileTypes: true })) {
1674
1816
  if (!objEntry.isDirectory()) continue;
1675
1817
  const objName = objEntry.name;
1676
1818
  addMember("CustomObject", objName);
1677
- const objDir = join7(objectsDir, objName);
1819
+ const objDir = join8(objectsDir, objName);
1678
1820
  const objectXml = mergeObjectXml(objDir, objName, apiVersion);
1679
1821
  archive.append(Buffer.from(objectXml, "utf8"), {
1680
1822
  name: `objects/${objName}.object`
@@ -1691,17 +1833,17 @@ function mergeObjectXml(objDir, objName, apiVersion) {
1691
1833
  '<?xml version="1.0" encoding="UTF-8"?>',
1692
1834
  '<CustomObject xmlns="http://soap.sforce.com/2006/04/metadata">'
1693
1835
  ];
1694
- const objMetaPath = join7(objDir, `${objName}.object-meta.xml`);
1836
+ const objMetaPath = join8(objDir, `${objName}.object-meta.xml`);
1695
1837
  if (existsSync4(objMetaPath)) {
1696
- const content = readFileSync5(objMetaPath, "utf8");
1838
+ const content = readFileSync6(objMetaPath, "utf8");
1697
1839
  const inner = content.replace(/<\?xml[^?]*\?>\s*/g, "").replace(/<CustomObject[^>]*>/g, "").replace(/<\/CustomObject>/g, "").trim();
1698
1840
  if (inner) lines.push(inner);
1699
1841
  }
1700
- const fieldsDir = join7(objDir, "fields");
1842
+ const fieldsDir = join8(objDir, "fields");
1701
1843
  if (existsSync4(fieldsDir)) {
1702
1844
  for (const fieldFile of readdirSync(fieldsDir).sort()) {
1703
1845
  if (!fieldFile.endsWith(".field-meta.xml")) continue;
1704
- const content = readFileSync5(join7(fieldsDir, fieldFile), "utf8");
1846
+ const content = readFileSync6(join8(fieldsDir, fieldFile), "utf8");
1705
1847
  const inner = content.replace(/<\?xml[^?]*\?>\s*/g, "").replace(/<CustomField[^>]*>/g, "").replace(/<\/CustomField>/g, "").trim();
1706
1848
  if (inner) {
1707
1849
  lines.push(" <fields>");
@@ -1743,10 +1885,10 @@ async function sfdcDeployInline(params) {
1743
1885
  const { accessToken, instanceUrl } = await loginViaAppBridge(appUrl);
1744
1886
  const sf = new SalesforceApi(instanceUrl, accessToken);
1745
1887
  s.start("Copying Salesforce package\u2026");
1746
- const inDist = join8(__dirname2, "sfdc-package");
1747
- const nextToDist = join8(__dirname2, "..", "sfdc-package");
1888
+ const inDist = join9(__dirname2, "sfdc-package");
1889
+ const nextToDist = join9(__dirname2, "..", "sfdc-package");
1748
1890
  const bundledPkg = existsSync5(inDist) ? inDist : nextToDist;
1749
- const destPkg = join8(installDir ?? tmpdir2(), "lead-routing-sfdc-package");
1891
+ const destPkg = join9(installDir ?? tmpdir2(), "lead-routing-sfdc-package");
1750
1892
  if (!existsSync5(bundledPkg)) {
1751
1893
  s.stop("sfdc-package not found in CLI bundle");
1752
1894
  throw new Error(
@@ -1757,7 +1899,7 @@ The CLI may need to be reinstalled: npm i -g @lead-routing/cli`
1757
1899
  if (existsSync5(destPkg)) rmSync(destPkg, { recursive: true, force: true });
1758
1900
  cpSync(bundledPkg, destPkg, { recursive: true });
1759
1901
  s.stop("Package copied");
1760
- const ncPath = join8(
1902
+ const ncPath = join9(
1761
1903
  destPkg,
1762
1904
  "force-app",
1763
1905
  "main",
@@ -1766,10 +1908,10 @@ The CLI may need to be reinstalled: npm i -g @lead-routing/cli`
1766
1908
  "RoutingEngine.namedCredential-meta.xml"
1767
1909
  );
1768
1910
  if (existsSync5(ncPath)) {
1769
- const nc = patchXml(readFileSync6(ncPath, "utf8"), "endpoint", engineUrl);
1770
- writeFileSync6(ncPath, nc, "utf8");
1911
+ const nc = patchXml(readFileSync7(ncPath, "utf8"), "endpoint", engineUrl);
1912
+ writeFileSync7(ncPath, nc, "utf8");
1771
1913
  }
1772
- const rssEnginePath = join8(
1914
+ const rssEnginePath = join9(
1773
1915
  destPkg,
1774
1916
  "force-app",
1775
1917
  "main",
@@ -1778,11 +1920,11 @@ The CLI may need to be reinstalled: npm i -g @lead-routing/cli`
1778
1920
  "LeadRouterEngine.remoteSite-meta.xml"
1779
1921
  );
1780
1922
  if (existsSync5(rssEnginePath)) {
1781
- let rss = patchXml(readFileSync6(rssEnginePath, "utf8"), "url", engineUrl);
1923
+ let rss = patchXml(readFileSync7(rssEnginePath, "utf8"), "url", engineUrl);
1782
1924
  rss = patchXml(rss, "description", "Lead Router Engine endpoint");
1783
- writeFileSync6(rssEnginePath, rss, "utf8");
1925
+ writeFileSync7(rssEnginePath, rss, "utf8");
1784
1926
  }
1785
- const rssAppPath = join8(
1927
+ const rssAppPath = join9(
1786
1928
  destPkg,
1787
1929
  "force-app",
1788
1930
  "main",
@@ -1791,9 +1933,9 @@ The CLI may need to be reinstalled: npm i -g @lead-routing/cli`
1791
1933
  "LeadRouterApp.remoteSite-meta.xml"
1792
1934
  );
1793
1935
  if (existsSync5(rssAppPath)) {
1794
- let rss = patchXml(readFileSync6(rssAppPath, "utf8"), "url", appUrl);
1936
+ let rss = patchXml(readFileSync7(rssAppPath, "utf8"), "url", appUrl);
1795
1937
  rss = patchXml(rss, "description", "Lead Router App URL");
1796
- writeFileSync6(rssAppPath, rss, "utf8");
1938
+ writeFileSync7(rssAppPath, rss, "utf8");
1797
1939
  }
1798
1940
  log13.success("Remote Site Settings patched");
1799
1941
  s.start("Deploying Salesforce package (this may take ~2 min)\u2026");
@@ -1950,24 +2092,24 @@ Ensure the app is running and the URL is correct.`
1950
2092
 
1951
2093
  // src/steps/app-launcher-guide.ts
1952
2094
  import { note as note4, confirm as confirm2, isCancel as isCancel4, log as log14 } from "@clack/prompts";
1953
- import chalk5 from "chalk";
2095
+ import chalk6 from "chalk";
1954
2096
  async function guideAppLauncherSetup(appUrl) {
1955
2097
  note4(
1956
2098
  `Complete the following steps in Salesforce now:
1957
2099
 
1958
- ${chalk5.cyan("1.")} Open ${chalk5.bold("App Launcher")} (grid icon, top-left in Salesforce)
1959
- ${chalk5.cyan("2.")} Search for ${chalk5.white('"Lead Router Setup"')} and click it
1960
- ${chalk5.cyan("3.")} Click ${chalk5.white('"Connect to Lead Router"')}
1961
- \u2192 You will be redirected to ${chalk5.dim(appUrl)} and back
2100
+ ${chalk6.cyan("1.")} Open ${chalk6.bold("App Launcher")} (grid icon, top-left in Salesforce)
2101
+ ${chalk6.cyan("2.")} Search for ${chalk6.white('"Lead Router Setup"')} and click it
2102
+ ${chalk6.cyan("3.")} Click ${chalk6.white('"Connect to Lead Router"')}
2103
+ \u2192 You will be redirected to ${chalk6.dim(appUrl)} and back
1962
2104
  \u2192 Authorize the OAuth connection when prompted
1963
2105
 
1964
- ${chalk5.cyan("4.")} ${chalk5.bold("Step 1")} \u2014 wait for the ${chalk5.green('"Connected"')} checkmark (~5 sec)
1965
- ${chalk5.cyan("5.")} ${chalk5.bold("Step 2")} \u2014 click ${chalk5.white("Activate")} to enable Lead triggers
1966
- ${chalk5.cyan("6.")} ${chalk5.bold("Step 3")} \u2014 click ${chalk5.white("Sync Fields")} to index your Lead field schema
1967
- ${chalk5.cyan("7.")} ${chalk5.bold("Step 4")} \u2014 click ${chalk5.white("Send Test")} to fire a test routing event
1968
- \u2192 ${chalk5.dim('"Test successful"')} or ${chalk5.dim('"No matching rule"')} are both valid
2106
+ ${chalk6.cyan("4.")} ${chalk6.bold("Step 1")} \u2014 wait for the ${chalk6.green('"Connected"')} checkmark (~5 sec)
2107
+ ${chalk6.cyan("5.")} ${chalk6.bold("Step 2")} \u2014 click ${chalk6.white("Activate")} to enable Lead triggers
2108
+ ${chalk6.cyan("6.")} ${chalk6.bold("Step 3")} \u2014 click ${chalk6.white("Sync Fields")} to index your Lead field schema
2109
+ ${chalk6.cyan("7.")} ${chalk6.bold("Step 4")} \u2014 click ${chalk6.white("Send Test")} to fire a test routing event
2110
+ \u2192 ${chalk6.dim('"Test successful"')} or ${chalk6.dim('"No matching rule"')} are both valid
1969
2111
 
1970
- ` + chalk5.dim("Keep this terminal open while you complete the wizard."),
2112
+ ` + chalk6.dim("Keep this terminal open while you complete the wizard."),
1971
2113
  "Complete Salesforce setup"
1972
2114
  );
1973
2115
  const done = await confirm2({
@@ -2038,19 +2180,19 @@ async function runSfdcDeploy() {
2038
2180
  }
2039
2181
  await guideAppLauncherSetup(appUrl);
2040
2182
  outro5(
2041
- chalk6.green("\u2714 Salesforce package deployed!") + `
2183
+ chalk7.green("\u2714 Salesforce package deployed!") + `
2042
2184
 
2043
- Your Lead Router dashboard: ${chalk6.cyan(appUrl)}`
2185
+ Your Lead Router dashboard: ${chalk7.cyan(appUrl)}`
2044
2186
  );
2045
2187
  }
2046
2188
 
2047
2189
  // src/commands/uninstall.ts
2048
2190
  import { rmSync as rmSync2, existsSync as existsSync6 } from "fs";
2049
2191
  import { intro as intro6, outro as outro6, log as log16, confirm as confirm3, password as promptPassword3, isCancel as isCancel5 } from "@clack/prompts";
2050
- import chalk7 from "chalk";
2192
+ import chalk8 from "chalk";
2051
2193
  async function runUninstall() {
2052
2194
  console.log();
2053
- intro6(chalk7.bold.red("Lead Routing \u2014 Uninstall"));
2195
+ intro6(chalk8.bold.red("Lead Routing \u2014 Uninstall"));
2054
2196
  const dir = findInstallDir();
2055
2197
  if (!dir) {
2056
2198
  log16.error(
@@ -2065,13 +2207,13 @@ async function runUninstall() {
2065
2207
  );
2066
2208
  process.exit(1);
2067
2209
  }
2068
- log16.warn(chalk7.red("This will permanently destroy:"));
2210
+ log16.warn(chalk8.red("This will permanently destroy:"));
2069
2211
  log16.warn(` \u2022 Remote: ${cfg.ssh.username}@${cfg.ssh.host}:${cfg.remoteDir}`);
2070
2212
  log16.warn(" \u2514\u2500 All containers, Postgres data, Redis data, config files");
2071
2213
  log16.warn(` \u2022 Local: ${dir}/`);
2072
2214
  log16.warn(" \u2514\u2500 docker-compose.yml, .env.web, .env.engine, Caddyfile, lead-routing.json");
2073
2215
  const confirmed = await confirm3({
2074
- message: chalk7.bold("Are you sure you want to uninstall? This cannot be undone."),
2216
+ message: chalk8.bold("Are you sure you want to uninstall? This cannot be undone."),
2075
2217
  initialValue: false
2076
2218
  });
2077
2219
  if (isCancel5(confirmed) || !confirmed) {
@@ -2126,12 +2268,96 @@ async function runUninstall() {
2126
2268
  log16.success(`Removed ${dir}`);
2127
2269
  }
2128
2270
  outro6(
2129
- chalk7.green("\u2714 Uninstall complete.") + `
2271
+ chalk8.green("\u2714 Uninstall complete.") + `
2130
2272
 
2131
- Run ${chalk7.cyan("npx @lead-routing/cli init")} to start fresh.`
2273
+ Run ${chalk8.cyan("npx @lead-routing/cli init")} to start fresh.`
2132
2274
  );
2133
2275
  }
2134
2276
 
2277
+ // src/commands/signup.ts
2278
+ import * as p from "@clack/prompts";
2279
+ import chalk9 from "chalk";
2280
+ async function runSignup() {
2281
+ p.intro(chalk9.bgBlue.white(" lead-routing signup "));
2282
+ const firstName = await p.text({ message: "First name", placeholder: "John", validate: (v) => v.trim() ? void 0 : "Required" });
2283
+ if (p.isCancel(firstName)) process.exit(0);
2284
+ const lastName = await p.text({ message: "Last name", placeholder: "Smith", validate: (v) => v.trim() ? void 0 : "Required" });
2285
+ if (p.isCancel(lastName)) process.exit(0);
2286
+ const email = await p.text({ message: "Email", placeholder: "john@acme.com", validate: (v) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(v.trim()) ? void 0 : "Invalid email" });
2287
+ if (p.isCancel(email)) process.exit(0);
2288
+ const password5 = await p.password({ message: "Password (min 8 characters)", validate: (v) => v.length >= 8 ? void 0 : "Must be at least 8 characters" });
2289
+ if (p.isCancel(password5)) process.exit(0);
2290
+ const confirm5 = await p.password({ message: "Confirm password", validate: (v) => v === password5 ? void 0 : "Passwords do not match" });
2291
+ if (p.isCancel(confirm5)) process.exit(0);
2292
+ const spinner8 = p.spinner();
2293
+ spinner8.start("Creating account...");
2294
+ try {
2295
+ const message = await apiSignup({
2296
+ firstName: firstName.trim(),
2297
+ lastName: lastName.trim(),
2298
+ email: email.trim(),
2299
+ password: password5
2300
+ });
2301
+ spinner8.stop("Account created!");
2302
+ p.note(
2303
+ `Check your email (${email.trim()}) for a verification link.
2304
+
2305
+ After verifying, run:
2306
+
2307
+ ${chalk9.cyan("lead-routing login")}`,
2308
+ "Next Steps"
2309
+ );
2310
+ } catch (err) {
2311
+ const msg = err instanceof Error ? err.message : "Signup failed";
2312
+ spinner8.stop(msg);
2313
+ if (msg.includes("already")) {
2314
+ p.log.info(`Try ${chalk9.cyan("lead-routing login")} instead.`);
2315
+ }
2316
+ process.exit(1);
2317
+ }
2318
+ p.outro("Done!");
2319
+ }
2320
+
2321
+ // src/commands/login.ts
2322
+ import * as p2 from "@clack/prompts";
2323
+ import chalk10 from "chalk";
2324
+ async function runLogin() {
2325
+ p2.intro(chalk10.bgBlue.white(" lead-routing login "));
2326
+ const email = await p2.text({ message: "Email", placeholder: "john@acme.com" });
2327
+ if (p2.isCancel(email)) process.exit(0);
2328
+ const password5 = await p2.password({ message: "Password" });
2329
+ if (p2.isCancel(password5)) process.exit(0);
2330
+ const spinner8 = p2.spinner();
2331
+ spinner8.start("Authenticating...");
2332
+ try {
2333
+ const { token, customer } = await apiLogin(email.trim(), password5);
2334
+ if (!customer.emailVerified) {
2335
+ spinner8.stop("Email not verified");
2336
+ p2.log.warn(`Your email (${customer.email}) hasn't been verified yet.`);
2337
+ const resend = await p2.confirm({ message: "Resend verification email?" });
2338
+ if (resend && !p2.isCancel(resend)) {
2339
+ try {
2340
+ await apiResendVerification(token);
2341
+ p2.log.success("Verification email sent! Check your inbox.");
2342
+ } catch {
2343
+ p2.log.error("Failed to resend. Try again later.");
2344
+ }
2345
+ }
2346
+ p2.note(`Verify your email, then run:
2347
+
2348
+ ${chalk10.cyan("lead-routing login")}`, "Next Steps");
2349
+ process.exit(1);
2350
+ }
2351
+ saveCredentials({ token, customer, storedAt: (/* @__PURE__ */ new Date()).toISOString() });
2352
+ spinner8.stop(`Logged in as ${customer.firstName} ${customer.lastName} \u2014 ${formatTierBadge(customer.tier)}`);
2353
+ p2.note(`Credentials saved to ~/.lead-routing/credentials.json`, "Saved");
2354
+ } catch (err) {
2355
+ spinner8.stop(err instanceof Error ? err.message : "Login failed");
2356
+ process.exit(1);
2357
+ }
2358
+ p2.outro("Done!");
2359
+ }
2360
+
2135
2361
  // src/index.ts
2136
2362
  var program = new Command();
2137
2363
  program.name("lead-routing").description("Self-hosted Lead Routing \u2014 scaffold, deploy, and manage your installation").version("0.1.13");
@@ -2150,11 +2376,13 @@ program.command("doctor").description("Check the health of all services in your
2150
2376
  program.command("logs [service]").description("Stream logs from a service (web, engine, postgres, redis). Defaults to engine.").action((service) => runLogs(service));
2151
2377
  program.command("status").description("Show the running state of all Docker containers").action(runStatus);
2152
2378
  var config = program.command("config").description("Update configuration values in a live installation");
2153
- config.command("show").description("Print key config values for this installation (admin secret, app URL, SFDC client ID)").action(runConfigShow);
2379
+ config.command("show").description("Print key config values for this installation (app URL)").action(runConfigShow);
2154
2380
  config.command("sfdc").description("Update Salesforce Connected App credentials (Consumer Key + Secret)").action(runConfigSfdc);
2155
2381
  var sfdc = program.command("sfdc").description("Manage the Salesforce package for this installation");
2156
2382
  sfdc.command("deploy").description("Deploy (or redeploy) the Lead Router Salesforce package to your Salesforce org").action(runSfdcDeploy);
2157
2383
  program.command("uninstall").description("Stop all containers, remove all data, and delete the remote installation").action(runUninstall);
2384
+ program.command("signup").description("Create a new Lead Routing account").action(runSignup);
2385
+ program.command("login").description("Log in to your Lead Routing account").action(runLogin);
2158
2386
  program.parseAsync(process.argv).catch((err) => {
2159
2387
  console.error(err instanceof Error ? err.message : String(err));
2160
2388
  process.exit(1);
@@ -0,0 +1,36 @@
1
+ -- Fix: the existing @@unique constraint does not prevent duplicate rows
2
+ -- because PostgreSQL treats NULLs as distinct in unique indexes.
3
+
4
+ -- Step 1: Clean up any existing duplicate rows (keep the one with highest totalCount)
5
+ DELETE FROM "routing_daily_aggregates" a
6
+ USING "routing_daily_aggregates" b
7
+ WHERE a."orgId" = b."orgId"
8
+ AND a."date" = b."date"
9
+ AND a."objectType" IS NOT DISTINCT FROM b."objectType"
10
+ AND a."ruleId" IS NOT DISTINCT FROM b."ruleId"
11
+ AND a."pathLabel" IS NOT DISTINCT FROM b."pathLabel"
12
+ AND a."branchId" IS NOT DISTINCT FROM b."branchId"
13
+ AND a."teamId" IS NOT DISTINCT FROM b."teamId"
14
+ AND a."assigneeId" IS NOT DISTINCT FROM b."assigneeId"
15
+ AND a."id" < b."id";
16
+
17
+ -- Step 2: Create an immutable cast helper for the enum column.
18
+ -- PostgreSQL's built-in enum::text cast is only STABLE, not IMMUTABLE,
19
+ -- so it cannot be used directly in an index expression.
20
+ CREATE OR REPLACE FUNCTION immutable_object_type_text(val "SfdcObjectType")
21
+ RETURNS text LANGUAGE sql IMMUTABLE PARALLEL SAFE AS $$
22
+ SELECT val::text;
23
+ $$;
24
+
25
+ -- Step 3: Create a functional unique index using COALESCE to handle NULLs
26
+ CREATE UNIQUE INDEX "routing_daily_aggregates_dimension_key"
27
+ ON "routing_daily_aggregates" (
28
+ "orgId",
29
+ "date",
30
+ COALESCE("ruleId", ''),
31
+ COALESCE("pathLabel", ''),
32
+ COALESCE("branchId", ''),
33
+ COALESCE("teamId", ''),
34
+ COALESCE("assigneeId", ''),
35
+ COALESCE(immutable_object_type_text("objectType"), '')
36
+ );
@@ -0,0 +1,5 @@
1
+ -- AlterTable
2
+ ALTER TABLE "organizations" ADD COLUMN IF NOT EXISTS "licenseKey" TEXT;
3
+ ALTER TABLE "organizations" ADD COLUMN IF NOT EXISTS "licenseTier" TEXT;
4
+ ALTER TABLE "organizations" ADD COLUMN IF NOT EXISTS "licenseValidUntil" TIMESTAMP(3);
5
+ ALTER TABLE "organizations" ADD COLUMN IF NOT EXISTS "licenseActivatedAt" TIMESTAMP(3);
@@ -107,6 +107,11 @@ model Organization {
107
107
  aiBaseUrl String? // custom endpoint URL (null = default)
108
108
  aiCustomHeaders Json? // custom headers for self-hosted proxies
109
109
  aiChatCount Int @default(0)
110
+ // License activation (from web UI)
111
+ licenseKey String?
112
+ licenseTier String? // "free" | "pro"
113
+ licenseValidUntil DateTime?
114
+ licenseActivatedAt DateTime?
110
115
  createdAt DateTime @default(now())
111
116
  updatedAt DateTime @updatedAt
112
117
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lead-routing/cli",
3
- "version": "0.5.0",
3
+ "version": "0.6.0",
4
4
  "description": "Self-hosted deployment CLI for Lead Routing",
5
5
  "homepage": "https://github.com/lead-routing/lead-routing",
6
6
  "keywords": [