@lead-routing/cli 0.1.3 → 0.1.5

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.
Files changed (2) hide show
  1. package/dist/index.js +136 -41
  2. package/package.json +1 -1
package/dist/index.js CHANGED
@@ -4,7 +4,8 @@
4
4
  import { Command } from "commander";
5
5
 
6
6
  // src/commands/init.ts
7
- import { intro, outro, note as note4, log as log8 } from "@clack/prompts";
7
+ import { promises as dns } from "dns";
8
+ import { intro, outro, note as note4, log as log8, confirm as confirm3, cancel as cancel3, isCancel as isCancel4, password as promptPassword } from "@clack/prompts";
8
9
  import chalk2 from "chalk";
9
10
 
10
11
  // src/steps/prerequisites.ts
@@ -189,7 +190,8 @@ async function collectConfig() {
189
190
  validate: (v) => {
190
191
  if (!v) return "Required";
191
192
  try {
192
- new URL(v);
193
+ const u = new URL(v);
194
+ if (u.protocol !== "https:") return "Must be an HTTPS URL (required for Salesforce OAuth)";
193
195
  } catch {
194
196
  return "Must be a valid URL (e.g. https://routing.acme.com)";
195
197
  }
@@ -211,16 +213,16 @@ async function collectConfig() {
211
213
  }
212
214
  });
213
215
  if (isCancel2(engineUrl)) bail2(engineUrl);
214
- const callbackUrl = `${appUrl}/api/auth/callback`;
216
+ const callbackUrl = `${appUrl.trim().replace(/\/+$/, "")}/api/auth/sfdc/callback`;
215
217
  note2(
216
218
  `You need a Salesforce Connected App. If you haven't created one yet:
217
219
 
218
220
  1. Go to Salesforce Setup \u2192 App Manager \u2192 New Connected App
219
221
  2. Connected App Name: Lead Routing
220
222
  3. Check "Enable OAuth Settings"
221
- 4. Callback URL:
223
+ 4. Callback URL (copy exactly \u2014 must match):
222
224
  ${callbackUrl}
223
- 5. Selected Scopes: api \u2022 refresh_token, offline_access \u2022 openid
225
+ 5. Selected Scopes: api \u2022 refresh_token, offline_access
224
226
  6. Check "Require Secret for Web Server Flow"
225
227
  7. Save \u2014 wait ~2 min, then click "Manage Consumer Details"
226
228
  8. Copy the Consumer Key (Client ID) and Consumer Secret below`,
@@ -342,8 +344,8 @@ async function collectConfig() {
342
344
  return {
343
345
  appUrl: appUrl.trim().replace(/\/+$/, ""),
344
346
  engineUrl: engineUrl.trim().replace(/\/+$/, ""),
345
- sfdcClientId,
346
- sfdcClientSecret,
347
+ sfdcClientId: sfdcClientId.trim(),
348
+ sfdcClientSecret: sfdcClientSecret.trim(),
347
349
  sfdcLoginUrl,
348
350
  orgAlias,
349
351
  managedDb,
@@ -362,8 +364,9 @@ async function collectConfig() {
362
364
  }
363
365
 
364
366
  // src/steps/generate-files.ts
365
- import { mkdirSync, writeFileSync as writeFileSync2 } from "fs";
366
- import { join as join2 } from "path";
367
+ import { mkdirSync, writeFileSync as writeFileSync2, readFileSync as readFileSync2 } from "fs";
368
+ import { join as join2, dirname } from "path";
369
+ import { fileURLToPath } from "url";
367
370
  import { log as log2 } from "@clack/prompts";
368
371
 
369
372
  // src/templates/docker-compose.ts
@@ -618,6 +621,15 @@ function findInstallDir(startDir = process.cwd()) {
618
621
  }
619
622
 
620
623
  // src/steps/generate-files.ts
624
+ var __dirname = dirname(fileURLToPath(import.meta.url));
625
+ function getCliVersion() {
626
+ try {
627
+ const pkg = JSON.parse(readFileSync2(join2(__dirname, "../../package.json"), "utf8"));
628
+ return pkg.version ?? "0.1.0";
629
+ } catch {
630
+ return "0.1.0";
631
+ }
632
+ }
621
633
  function generateFiles(cfg, sshCfg) {
622
634
  const dir = join2(process.cwd(), "lead-routing");
623
635
  mkdirSync(dir, { recursive: true });
@@ -681,7 +693,7 @@ function generateFiles(cfg, sshCfg) {
681
693
  sfdcClientId: cfg.sfdcClientId,
682
694
  sfdcLoginUrl: cfg.sfdcLoginUrl,
683
695
  installedAt: (/* @__PURE__ */ new Date()).toISOString(),
684
- version: "0.1.0"
696
+ version: getCliVersion()
685
697
  });
686
698
  log2.success("Generated lead-routing.json");
687
699
  return { dir, composeFile, envWeb, envEngine, adminSecret: cfg.adminSecret };
@@ -905,11 +917,11 @@ function sleep(ms) {
905
917
  import * as fs from "fs";
906
918
  import * as path from "path";
907
919
  import * as crypto from "crypto";
908
- import { fileURLToPath } from "url";
920
+ import { fileURLToPath as fileURLToPath2 } from "url";
909
921
  import { execa as execa2 } from "execa";
910
922
  import { spinner as spinner4 } from "@clack/prompts";
911
- var __filename = fileURLToPath(import.meta.url);
912
- var __dirname = path.dirname(__filename);
923
+ var __filename = fileURLToPath2(import.meta.url);
924
+ var __dirname2 = path.dirname(__filename);
913
925
  function readEnvVar(envFile, key) {
914
926
  const content = fs.readFileSync(envFile, "utf8");
915
927
  const match = content.match(new RegExp(`^${key}=(.+)$`, "m"));
@@ -928,11 +940,11 @@ function findPrismaBin() {
928
940
  // npx / npm global install: @lead-routing/cli is nested under the scope dir,
929
941
  // so prisma lands 3 levels above dist/ in node_modules/.bin/
930
942
  // e.g. ~/.npm/_npx/HASH/node_modules/.bin/prisma
931
- path.join(__dirname, "../../../.bin/prisma"),
932
- path.join(__dirname, "../../../prisma/bin/prisma.js"),
943
+ path.join(__dirname2, "../../../.bin/prisma"),
944
+ path.join(__dirname2, "../../../prisma/bin/prisma.js"),
933
945
  // Fallback: prisma nested inside the package's own node_modules (hoisted install)
934
- path.join(__dirname, "../node_modules/.bin/prisma"),
935
- path.join(__dirname, "../node_modules/prisma/bin/prisma.js"),
946
+ path.join(__dirname2, "../node_modules/.bin/prisma"),
947
+ path.join(__dirname2, "../node_modules/prisma/bin/prisma.js"),
936
948
  // Monorepo dev paths
937
949
  path.resolve("packages/db/node_modules/.bin/prisma"),
938
950
  path.resolve("node_modules/.bin/prisma"),
@@ -951,7 +963,9 @@ async function runMigrations(ssh, localDir, adminEmail, adminPassword) {
951
963
  tunnelClose = close;
952
964
  s.stop(`Database tunnel open (local port ${localPort})`);
953
965
  await applyMigrations(localDir, localPort);
954
- await seedAdminUser(localDir, localPort, adminEmail, adminPassword);
966
+ if (adminEmail && adminPassword) {
967
+ await seedAdminUser(localDir, localPort, adminEmail, adminPassword);
968
+ }
955
969
  } finally {
956
970
  tunnelClose?.();
957
971
  }
@@ -962,7 +976,7 @@ async function applyMigrations(localDir, localPort) {
962
976
  try {
963
977
  const DATABASE_URL = getTunneledDbUrl(localDir, localPort);
964
978
  const prismaBin = findPrismaBin();
965
- const bundledSchema = path.join(__dirname, "prisma/schema.prisma");
979
+ const bundledSchema = path.join(__dirname2, "prisma/schema.prisma");
966
980
  const monoSchema = path.resolve("packages/db/prisma/schema.prisma");
967
981
  const schemaPath = fs.existsSync(bundledSchema) ? bundledSchema : monoSchema;
968
982
  await execa2(prismaBin, ["migrate", "deploy", "--schema", schemaPath], {
@@ -1086,13 +1100,13 @@ function sleep2(ms) {
1086
1100
  }
1087
1101
 
1088
1102
  // src/steps/sfdc-deploy-inline.ts
1089
- import { readFileSync as readFileSync3, writeFileSync as writeFileSync3, existsSync as existsSync4, cpSync, rmSync } from "fs";
1090
- import { join as join5, dirname as dirname2 } from "path";
1103
+ import { readFileSync as readFileSync4, writeFileSync as writeFileSync3, existsSync as existsSync4, cpSync, rmSync } from "fs";
1104
+ import { join as join5, dirname as dirname3 } from "path";
1091
1105
  import { tmpdir } from "os";
1092
- import { fileURLToPath as fileURLToPath2 } from "url";
1106
+ import { fileURLToPath as fileURLToPath3 } from "url";
1093
1107
  import { spinner as spinner6, log as log6 } from "@clack/prompts";
1094
1108
  import { execa as execa3 } from "execa";
1095
- var __dirname2 = dirname2(fileURLToPath2(import.meta.url));
1109
+ var __dirname3 = dirname3(fileURLToPath3(import.meta.url));
1096
1110
  function patchXml(content, tag, value) {
1097
1111
  const re = new RegExp(`(<${tag}>)[^<]*(</\\s*${tag}>)`, "g");
1098
1112
  return content.replace(re, `$1${value}$2`);
@@ -1118,8 +1132,8 @@ async function sfdcDeployInline(params) {
1118
1132
  }
1119
1133
  }
1120
1134
  s.start("Copying Salesforce package\u2026");
1121
- const inDist = join5(__dirname2, "sfdc-package");
1122
- const nextToDist = join5(__dirname2, "..", "sfdc-package");
1135
+ const inDist = join5(__dirname3, "sfdc-package");
1136
+ const nextToDist = join5(__dirname3, "..", "sfdc-package");
1123
1137
  const bundledPkg = existsSync4(inDist) ? inDist : nextToDist;
1124
1138
  const destPkg = join5(installDir ?? tmpdir(), "lead-routing-sfdc-package");
1125
1139
  if (!existsSync4(bundledPkg)) {
@@ -1141,7 +1155,7 @@ The CLI may need to be reinstalled: npm i -g @lead-routing/cli`
1141
1155
  "RoutingEngine.namedCredential-meta.xml"
1142
1156
  );
1143
1157
  if (existsSync4(ncPath)) {
1144
- const nc = patchXml(readFileSync3(ncPath, "utf8"), "endpoint", engineUrl);
1158
+ const nc = patchXml(readFileSync4(ncPath, "utf8"), "endpoint", engineUrl);
1145
1159
  writeFileSync3(ncPath, nc, "utf8");
1146
1160
  }
1147
1161
  const rssEnginePath = join5(
@@ -1153,7 +1167,7 @@ The CLI may need to be reinstalled: npm i -g @lead-routing/cli`
1153
1167
  "LeadRouterEngine.remoteSite-meta.xml"
1154
1168
  );
1155
1169
  if (existsSync4(rssEnginePath)) {
1156
- let rss = patchXml(readFileSync3(rssEnginePath, "utf8"), "url", engineUrl);
1170
+ let rss = patchXml(readFileSync4(rssEnginePath, "utf8"), "url", engineUrl);
1157
1171
  rss = patchXml(rss, "description", "Lead Router Engine endpoint");
1158
1172
  writeFileSync3(rssEnginePath, rss, "utf8");
1159
1173
  }
@@ -1166,7 +1180,7 @@ The CLI may need to be reinstalled: npm i -g @lead-routing/cli`
1166
1180
  "LeadRouterApp.remoteSite-meta.xml"
1167
1181
  );
1168
1182
  if (existsSync4(rssAppPath)) {
1169
- let rss = patchXml(readFileSync3(rssAppPath, "utf8"), "url", appUrl);
1183
+ let rss = patchXml(readFileSync4(rssAppPath, "utf8"), "url", appUrl);
1170
1184
  rss = patchXml(rss, "description", "Lead Router App URL");
1171
1185
  writeFileSync3(rssAppPath, rss, "utf8");
1172
1186
  }
@@ -1494,13 +1508,97 @@ ${result.stderr || result.stdout}`
1494
1508
  };
1495
1509
 
1496
1510
  // src/commands/init.ts
1511
+ async function checkDnsResolvable(appUrl, engineUrl) {
1512
+ let hosts;
1513
+ try {
1514
+ hosts = [.../* @__PURE__ */ new Set([new URL(appUrl).hostname, new URL(engineUrl).hostname])];
1515
+ } catch {
1516
+ return;
1517
+ }
1518
+ for (const host of hosts) {
1519
+ try {
1520
+ await dns.lookup(host);
1521
+ } catch {
1522
+ log8.warn(
1523
+ `${chalk2.yellow(host)} does not resolve in DNS yet.
1524
+ Check for typos \u2014 a bad domain will cause a 2-minute timeout at step 8.`
1525
+ );
1526
+ const go = await confirm3({ message: "Continue anyway?", initialValue: true });
1527
+ if (isCancel4(go) || !go) {
1528
+ cancel3("Setup cancelled.");
1529
+ process.exit(0);
1530
+ }
1531
+ }
1532
+ }
1533
+ }
1497
1534
  async function runInit(options = {}) {
1498
1535
  const dryRun = options.dryRun ?? false;
1536
+ const resume = options.resume ?? false;
1499
1537
  console.log();
1500
1538
  intro(
1501
- chalk2.bold.cyan("Lead Routing \u2014 Self-Hosted Setup") + (dryRun ? chalk2.yellow(" [dry run]") : "")
1539
+ chalk2.bold.cyan("Lead Routing \u2014 Self-Hosted Setup") + (dryRun ? chalk2.yellow(" [dry run]") : "") + (resume ? chalk2.yellow(" [resume]") : "")
1502
1540
  );
1503
1541
  const ssh = new SshConnection();
1542
+ if (resume) {
1543
+ try {
1544
+ const dir = findInstallDir();
1545
+ if (!dir) {
1546
+ log8.error("No lead-routing.json found \u2014 run `lead-routing init` first.");
1547
+ process.exit(1);
1548
+ }
1549
+ const saved = readConfig(dir);
1550
+ let sshPassword;
1551
+ if (!saved.ssh.privateKeyPath) {
1552
+ const pw = await promptPassword({
1553
+ message: `SSH password for ${saved.ssh.username}@${saved.ssh.host}`
1554
+ });
1555
+ if (typeof pw === "symbol") process.exit(0);
1556
+ sshPassword = pw;
1557
+ }
1558
+ log8.step("Connecting to server");
1559
+ await ssh.connect({
1560
+ host: saved.ssh.host,
1561
+ port: saved.ssh.port,
1562
+ username: saved.ssh.username,
1563
+ privateKeyPath: saved.ssh.privateKeyPath,
1564
+ password: sshPassword,
1565
+ remoteDir: saved.remoteDir
1566
+ });
1567
+ log8.success(`Connected to ${saved.ssh.host}`);
1568
+ const remoteDir = await ssh.resolveHome(saved.remoteDir);
1569
+ log8.step("Step 8/9 Verifying health");
1570
+ await verifyHealth(saved.appUrl, saved.engineUrl, ssh, remoteDir);
1571
+ log8.step("Step 9/9 Deploying Salesforce package");
1572
+ await sfdcDeployInline({
1573
+ appUrl: saved.appUrl,
1574
+ engineUrl: saved.engineUrl,
1575
+ orgAlias: "lead-routing",
1576
+ sfdcClientId: saved.sfdcClientId ?? "",
1577
+ sfdcLoginUrl: saved.sfdcLoginUrl ?? "https://login.salesforce.com",
1578
+ installDir: dir
1579
+ });
1580
+ await guideAppLauncherSetup(saved.appUrl);
1581
+ outro(
1582
+ chalk2.green("\u2714 You're live!") + `
1583
+
1584
+ Dashboard: ${chalk2.cyan(saved.appUrl)}
1585
+ Routing engine: ${chalk2.cyan(saved.engineUrl)}
1586
+
1587
+ ` + chalk2.bold(" Next steps:\n") + ` ${chalk2.cyan("1.")} Open ${chalk2.cyan(saved.appUrl)} and log in
1588
+ ${chalk2.cyan("2.")} Create your first routing rule to start routing leads
1589
+
1590
+ Run ${chalk2.cyan("lead-routing doctor")} to check service health at any time.
1591
+ Run ${chalk2.cyan("lead-routing deploy")} to update to a new version.`
1592
+ );
1593
+ } catch (err) {
1594
+ const message = err instanceof Error ? err.message : String(err);
1595
+ log8.error(`Resume failed: ${message}`);
1596
+ process.exit(1);
1597
+ } finally {
1598
+ await ssh.disconnect();
1599
+ }
1600
+ return;
1601
+ }
1504
1602
  try {
1505
1603
  log8.step("Step 1/9 Checking local prerequisites");
1506
1604
  await checkPrerequisites();
@@ -1508,6 +1606,7 @@ async function runInit(options = {}) {
1508
1606
  const sshCfg = await collectSshConfig();
1509
1607
  log8.step("Step 3/9 Configuration");
1510
1608
  const cfg = await collectConfig();
1609
+ await checkDnsResolvable(cfg.appUrl, cfg.engineUrl);
1511
1610
  log8.step("Step 4/9 Generating config files");
1512
1611
  const { dir, adminSecret } = generateFiles(cfg, sshCfg);
1513
1612
  note4(
@@ -1575,7 +1674,7 @@ Files created: docker-compose.yml, Caddyfile, .env.web, .env.engine, lead-routin
1575
1674
  import { writeFileSync as writeFileSync4, unlinkSync } from "fs";
1576
1675
  import { join as join6 } from "path";
1577
1676
  import { tmpdir as tmpdir2 } from "os";
1578
- import { intro as intro2, outro as outro2, log as log9, password as promptPassword } from "@clack/prompts";
1677
+ import { intro as intro2, outro as outro2, log as log9, password as promptPassword2 } from "@clack/prompts";
1579
1678
  import chalk3 from "chalk";
1580
1679
  async function runDeploy() {
1581
1680
  console.log();
@@ -1591,7 +1690,7 @@ async function runDeploy() {
1591
1690
  const ssh = new SshConnection();
1592
1691
  let sshPassword;
1593
1692
  if (!cfg.ssh.privateKeyPath) {
1594
- const pw = await promptPassword({
1693
+ const pw = await promptPassword2({
1595
1694
  message: `SSH password for ${cfg.ssh.username}@${cfg.ssh.host}`
1596
1695
  });
1597
1696
  if (typeof pw === "symbol") process.exit(0);
@@ -1628,7 +1727,7 @@ async function runDeploy() {
1628
1727
  await ssh.exec("docker compose up -d --remove-orphans", remoteDir);
1629
1728
  log9.success("Services restarted");
1630
1729
  log9.step("Running database migrations");
1631
- await runMigrations(ssh, dir, "", "");
1730
+ await runMigrations(ssh, dir);
1632
1731
  outro2(
1633
1732
  chalk3.green("\u2714 Deployment complete!") + `
1634
1733
 
@@ -1780,7 +1879,7 @@ async function runStatus() {
1780
1879
  }
1781
1880
 
1782
1881
  // src/commands/config.ts
1783
- import { readFileSync as readFileSync4, writeFileSync as writeFileSync5, existsSync as existsSync5 } from "fs";
1882
+ import { readFileSync as readFileSync5, writeFileSync as writeFileSync5, existsSync as existsSync5 } from "fs";
1784
1883
  import { join as join7 } from "path";
1785
1884
  import { intro as intro4, outro as outro4, text as text3, password as password3, spinner as spinner7, log as log13 } from "@clack/prompts";
1786
1885
  import chalk5 from "chalk";
@@ -1788,7 +1887,7 @@ import { execa as execa7 } from "execa";
1788
1887
  function parseEnv(filePath) {
1789
1888
  const map = /* @__PURE__ */ new Map();
1790
1889
  if (!existsSync5(filePath)) return map;
1791
- for (const line of readFileSync4(filePath, "utf8").split("\n")) {
1890
+ for (const line of readFileSync5(filePath, "utf8").split("\n")) {
1792
1891
  const trimmed = line.trim();
1793
1892
  if (!trimmed || trimmed.startsWith("#")) continue;
1794
1893
  const eq = trimmed.indexOf("=");
@@ -1798,7 +1897,7 @@ function parseEnv(filePath) {
1798
1897
  return map;
1799
1898
  }
1800
1899
  function writeEnv(filePath, updates) {
1801
- const lines = existsSync5(filePath) ? readFileSync4(filePath, "utf8").split("\n") : [];
1900
+ const lines = existsSync5(filePath) ? readFileSync5(filePath, "utf8").split("\n") : [];
1802
1901
  const updated = /* @__PURE__ */ new Set();
1803
1902
  const result = lines.map((line) => {
1804
1903
  const trimmed = line.trim();
@@ -1956,14 +2055,10 @@ async function runSfdcDeploy() {
1956
2055
  log14.error(err instanceof Error ? err.message : String(err));
1957
2056
  process.exit(1);
1958
2057
  }
2058
+ await guideAppLauncherSetup(appUrl);
1959
2059
  outro5(
1960
2060
  chalk6.green("\u2714 Salesforce package deployed!") + `
1961
2061
 
1962
- Next steps:
1963
- 1. In Salesforce, open App Launcher \u2192 search "Lead Router Setup"
1964
- 2. Click "Connect to Lead Router" to authorise the OAuth connection
1965
- 3. Follow the 4-step wizard to activate triggers and sync field schema
1966
-
1967
2062
  Your Lead Router dashboard: ${chalk6.cyan(appUrl)}`
1968
2063
  );
1969
2064
  }
@@ -1971,7 +2066,7 @@ async function runSfdcDeploy() {
1971
2066
  // src/index.ts
1972
2067
  var program = new Command();
1973
2068
  program.name("lead-routing").description("Self-hosted Lead Routing \u2014 scaffold, deploy, and manage your installation").version("0.1.0");
1974
- program.command("init").description("Interactive setup wizard \u2014 configure and deploy the full Lead Routing stack").option("--dry-run", "Run the wizard and generate config files without starting Docker services").action((opts) => runInit({ dryRun: opts.dryRun }));
2069
+ program.command("init").description("Interactive setup wizard \u2014 configure and deploy the full Lead Routing stack").option("--dry-run", "Run the wizard and generate config files without starting Docker services").option("--resume", "Skip steps 1-7 and resume from health check using existing lead-routing.json").action((opts) => runInit({ dryRun: opts.dryRun, resume: opts.resume }));
1975
2070
  program.command("deploy").description("Pull latest images, restart services, and run any pending migrations").action(runDeploy);
1976
2071
  program.command("doctor").description("Check the health of all services in your installation").action(runDoctor);
1977
2072
  program.command("logs [service]").description("Stream logs from a service (web, engine, postgres, redis). Defaults to engine.").action((service) => runLogs(service));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lead-routing/cli",
3
- "version": "0.1.3",
3
+ "version": "0.1.5",
4
4
  "description": "Self-hosted deployment CLI for Lead Routing",
5
5
  "homepage": "https://github.com/lead-routing/lead-routing",
6
6
  "keywords": ["salesforce", "lead-routing", "self-hosted", "deployment", "cli"],