@lead-routing/cli 0.1.4 → 0.1.6

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 +279 -276
  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 log9, confirm as confirm2, 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
@@ -68,7 +69,16 @@ async function checkSalesforceCLI() {
68
69
  // src/steps/collect-ssh-config.ts
69
70
  import { existsSync } from "fs";
70
71
  import { homedir } from "os";
71
- import { text, password, select, note, cancel, isCancel } from "@clack/prompts";
72
+ import { join } from "path";
73
+ import { text, password, note, log as log2, cancel, isCancel } from "@clack/prompts";
74
+ var DEFAULT_KEYS = [
75
+ join(homedir(), ".ssh", "id_ed25519"),
76
+ join(homedir(), ".ssh", "id_rsa"),
77
+ join(homedir(), ".ssh", "id_ecdsa")
78
+ ];
79
+ function detectDefaultKey() {
80
+ return DEFAULT_KEYS.find(existsSync);
81
+ }
72
82
  function bail(value) {
73
83
  if (isCancel(value)) {
74
84
  cancel("Setup cancelled.");
@@ -76,9 +86,9 @@ function bail(value) {
76
86
  }
77
87
  throw new Error("Unexpected cancel");
78
88
  }
79
- async function collectSshConfig() {
89
+ async function collectSshConfig(opts = {}) {
80
90
  note(
81
- "The CLI will SSH into your server to deploy the full stack.\nYou will need:\n \u2022 Server hostname or IP address\n \u2022 SSH access (key file recommended, password supported)\n \u2022 Docker 24+ already installed on the server",
91
+ "The CLI will SSH into your server to deploy the full stack.\nYou will need:\n \u2022 Server hostname or IP address\n \u2022 SSH access (key auto-detected, or password)\n \u2022 Docker 24+ already installed on the server",
82
92
  "Server connection"
83
93
  );
84
94
  const host = await text({
@@ -87,82 +97,43 @@ async function collectSshConfig() {
87
97
  validate: (v) => !v ? "Required" : void 0
88
98
  });
89
99
  if (isCancel(host)) bail(host);
90
- const portRaw = await text({
91
- message: "SSH port",
92
- placeholder: "22",
93
- initialValue: "22",
94
- validate: (v) => {
95
- const n = parseInt(v, 10);
96
- if (isNaN(n) || n < 1 || n > 65535) return "Must be a valid port (1\u201365535)";
97
- }
98
- });
99
- if (isCancel(portRaw)) bail(portRaw);
100
- const username = await text({
101
- message: "SSH username",
102
- placeholder: "root",
103
- initialValue: "root",
104
- validate: (v) => !v ? "Required" : void 0
105
- });
106
- if (isCancel(username)) bail(username);
107
- const authMethod = await select({
108
- message: "SSH authentication method",
109
- options: [
110
- { value: "key", label: "SSH key file (recommended)" },
111
- { value: "password", label: "Password" }
112
- ]
113
- });
114
- if (isCancel(authMethod)) bail(authMethod);
115
100
  let privateKeyPath;
116
101
  let pwd;
117
- if (authMethod === "key") {
118
- const defaultKey = `${homedir()}/.ssh/id_rsa`;
119
- const keyPath = await text({
120
- message: "Path to SSH private key",
121
- placeholder: defaultKey,
122
- initialValue: `~/.ssh/id_rsa`,
123
- validate: (v) => {
124
- const resolved = v.startsWith("~") ? homedir() + v.slice(1) : v;
125
- if (!existsSync(resolved)) return `Key file not found: ${resolved}`;
126
- }
127
- });
128
- if (isCancel(keyPath)) bail(keyPath);
129
- const raw = keyPath;
130
- privateKeyPath = raw.startsWith("~") ? homedir() + raw.slice(1) : raw;
102
+ if (opts.sshKey) {
103
+ const resolved = opts.sshKey.startsWith("~") ? homedir() + opts.sshKey.slice(1) : opts.sshKey;
104
+ if (!existsSync(resolved)) {
105
+ log2.error(`SSH key not found: ${resolved}`);
106
+ process.exit(1);
107
+ }
108
+ privateKeyPath = resolved;
109
+ log2.info(`Using SSH key: ${opts.sshKey}`);
131
110
  } else {
132
- const p = await password({
133
- message: "SSH password",
134
- validate: (v) => !v ? "Required" : void 0
135
- });
136
- if (isCancel(p)) bail(p);
137
- pwd = p;
111
+ const detected = detectDefaultKey();
112
+ if (detected) {
113
+ privateKeyPath = detected;
114
+ log2.info(`Using SSH key: ${detected.replace(homedir(), "~")}`);
115
+ } else {
116
+ log2.warn("No SSH key found at ~/.ssh/id_ed25519, ~/.ssh/id_rsa, or ~/.ssh/id_ecdsa");
117
+ const p = await password({
118
+ message: `SSH password for ${opts.sshUser ?? "root"}@${host}`,
119
+ validate: (v) => !v ? "Required" : void 0
120
+ });
121
+ if (isCancel(p)) bail(p);
122
+ pwd = p;
123
+ }
138
124
  }
139
- const remoteDir = await text({
140
- message: "Remote install directory on server",
141
- placeholder: "~/lead-routing",
142
- initialValue: "~/lead-routing",
143
- validate: (v) => !v ? "Required" : void 0
144
- });
145
- if (isCancel(remoteDir)) bail(remoteDir);
146
125
  return {
147
126
  host,
148
- port: parseInt(portRaw, 10),
149
- username,
127
+ port: opts.sshPort ?? 22,
128
+ username: opts.sshUser ?? "root",
150
129
  privateKeyPath,
151
130
  password: pwd,
152
- remoteDir
131
+ remoteDir: opts.remoteDir ?? "~/lead-routing"
153
132
  };
154
133
  }
155
134
 
156
135
  // src/steps/collect-config.ts
157
- import {
158
- text as text2,
159
- password as password2,
160
- select as select2,
161
- confirm,
162
- note as note2,
163
- cancel as cancel2,
164
- isCancel as isCancel2
165
- } from "@clack/prompts";
136
+ import { text as text2, password as password2, note as note2, cancel as cancel2, isCancel as isCancel2 } from "@clack/prompts";
166
137
 
167
138
  // src/utils/crypto.ts
168
139
  import { randomBytes } from "crypto";
@@ -178,9 +149,9 @@ function bail2(value) {
178
149
  }
179
150
  throw new Error("Unexpected cancel");
180
151
  }
181
- async function collectConfig() {
152
+ async function collectConfig(opts = {}) {
182
153
  note2(
183
- "You will need:\n \u2022 A Salesforce Connected App (Client ID + Secret) \u2014 instructions below\n \u2022 A public URL or localhost for the app\n \u2022 PostgreSQL + Redis (or let Docker manage them)",
154
+ "You will need:\n \u2022 A Salesforce Connected App (Client ID + Secret) \u2014 instructions below\n \u2022 Public HTTPS URLs for the web app and routing engine",
184
155
  "Before you begin"
185
156
  );
186
157
  const appUrl = await text2({
@@ -199,8 +170,7 @@ async function collectConfig() {
199
170
  if (isCancel2(appUrl)) bail2(appUrl);
200
171
  const engineUrl = await text2({
201
172
  message: "Engine URL (public URL Salesforce will use to route leads)",
202
- placeholder: "https://engine.acme.com",
203
- hint: "Subdomain: https://engine.acme.com \u2022 Same domain + port: https://acme.com:3001",
173
+ placeholder: "https://engine.acme.com or https://acme.com:3001",
204
174
  validate: (v) => {
205
175
  if (!v) return "Required";
206
176
  try {
@@ -238,66 +208,12 @@ async function collectConfig() {
238
208
  validate: (v) => !v ? "Required" : void 0
239
209
  });
240
210
  if (isCancel2(sfdcClientSecret)) bail2(sfdcClientSecret);
241
- const sfdcLoginUrlChoice = await select2({
242
- message: "Salesforce environment",
243
- options: [
244
- { value: "https://login.salesforce.com", label: "Production / Developer org" },
245
- { value: "https://test.salesforce.com", label: "Sandbox" }
246
- ]
247
- });
248
- if (isCancel2(sfdcLoginUrlChoice)) bail2(sfdcLoginUrlChoice);
249
- const sfdcLoginUrl = sfdcLoginUrlChoice;
250
- const orgAlias = await text2({
251
- message: "Salesforce org alias (used by the sf CLI to identify this org)",
252
- placeholder: "lead-routing",
253
- initialValue: "lead-routing",
254
- validate: (v) => !v ? "Required" : void 0
255
- });
256
- if (isCancel2(orgAlias)) bail2(orgAlias);
257
- const managedDb = await confirm({
258
- message: "Manage PostgreSQL with Docker? (recommended \u2014 choose No to provide your own URL)",
259
- initialValue: true
260
- });
261
- if (isCancel2(managedDb)) bail2(managedDb);
262
- let databaseUrl = "";
263
- let dbPassword = generateSecret(16);
264
- if (managedDb) {
265
- databaseUrl = "postgresql://leadrouting:" + dbPassword + "@postgres:5432/leadrouting";
266
- } else {
267
- const url = await text2({
268
- message: "PostgreSQL connection URL",
269
- placeholder: "postgresql://user:pass@host:5432/dbname",
270
- validate: (v) => {
271
- if (!v) return "Required";
272
- if (!v.startsWith("postgresql://") && !v.startsWith("postgres://"))
273
- return "Must start with postgresql:// or postgres://";
274
- }
275
- });
276
- if (isCancel2(url)) bail2(url);
277
- databaseUrl = url;
278
- dbPassword = "";
279
- }
280
- const managedRedis = await confirm({
281
- message: "Manage Redis with Docker? (recommended \u2014 choose No to provide your own URL)",
282
- initialValue: true
283
- });
284
- if (isCancel2(managedRedis)) bail2(managedRedis);
285
- let redisUrl = "";
286
- if (managedRedis) {
287
- redisUrl = "redis://redis:6379";
288
- } else {
289
- const url = await text2({
290
- message: "Redis connection URL",
291
- placeholder: "redis://user:pass@host:6379",
292
- validate: (v) => {
293
- if (!v) return "Required";
294
- if (!v.startsWith("redis://") && !v.startsWith("rediss://"))
295
- return "Must start with redis:// or rediss://";
296
- }
297
- });
298
- if (isCancel2(url)) bail2(url);
299
- redisUrl = url;
300
- }
211
+ const sfdcLoginUrl = opts.sandbox ? "https://test.salesforce.com" : "https://login.salesforce.com";
212
+ const dbPassword = generateSecret(16);
213
+ const managedDb = !opts.externalDb;
214
+ const databaseUrl = opts.externalDb ?? `postgresql://leadrouting:${dbPassword}@postgres:5432/leadrouting`;
215
+ const managedRedis = !opts.externalRedis;
216
+ const redisUrl = opts.externalRedis ?? "redis://redis:6379";
301
217
  note2("This creates the first admin user for the web app.", "Admin Account");
302
218
  const adminEmail = await text2({
303
219
  message: "Admin email address",
@@ -316,27 +232,6 @@ async function collectConfig() {
316
232
  }
317
233
  });
318
234
  if (isCancel2(adminPassword)) bail2(adminPassword);
319
- const wantResend = await confirm({
320
- message: "Configure Resend for email invites? (optional)",
321
- initialValue: false
322
- });
323
- if (isCancel2(wantResend)) bail2(wantResend);
324
- let resendApiKey = "";
325
- let feedbackToEmail = "";
326
- if (wantResend) {
327
- const key = await text2({
328
- message: "Resend API key",
329
- placeholder: "re_..."
330
- });
331
- if (isCancel2(key)) bail2(key);
332
- resendApiKey = key ?? "";
333
- const email = await text2({
334
- message: "Email address to receive feedback",
335
- placeholder: "feedback@acme.com"
336
- });
337
- if (isCancel2(email)) bail2(email);
338
- feedbackToEmail = email ?? "";
339
- }
340
235
  const sessionSecret = generateSecret(32);
341
236
  const engineWebhookSecret = generateSecret(32);
342
237
  const adminSecret = generateSecret(16);
@@ -346,16 +241,15 @@ async function collectConfig() {
346
241
  sfdcClientId: sfdcClientId.trim(),
347
242
  sfdcClientSecret: sfdcClientSecret.trim(),
348
243
  sfdcLoginUrl,
349
- orgAlias,
350
244
  managedDb,
351
245
  databaseUrl,
352
- dbPassword,
246
+ dbPassword: managedDb ? dbPassword : "",
353
247
  managedRedis,
354
248
  redisUrl,
355
249
  adminEmail,
356
250
  adminPassword,
357
- resendApiKey,
358
- feedbackToEmail,
251
+ resendApiKey: "",
252
+ feedbackToEmail: "",
359
253
  sessionSecret,
360
254
  engineWebhookSecret,
361
255
  adminSecret
@@ -364,9 +258,9 @@ async function collectConfig() {
364
258
 
365
259
  // src/steps/generate-files.ts
366
260
  import { mkdirSync, writeFileSync as writeFileSync2, readFileSync as readFileSync2 } from "fs";
367
- import { join as join2, dirname } from "path";
261
+ import { join as join3, dirname } from "path";
368
262
  import { fileURLToPath } from "url";
369
- import { log as log2 } from "@clack/prompts";
263
+ import { log as log3 } from "@clack/prompts";
370
264
 
371
265
  // src/templates/docker-compose.ts
372
266
  function renderDockerCompose(c) {
@@ -595,9 +489,9 @@ function renderCaddyfile(appUrl, engineUrl) {
595
489
 
596
490
  // src/utils/config.ts
597
491
  import { readFileSync, writeFileSync, existsSync as existsSync2 } from "fs";
598
- import { join } from "path";
492
+ import { join as join2 } from "path";
599
493
  function getConfigPath(dir) {
600
- return join(dir, "lead-routing.json");
494
+ return join2(dir, "lead-routing.json");
601
495
  }
602
496
  function readConfig(dir) {
603
497
  const path2 = getConfigPath(dir);
@@ -612,10 +506,10 @@ function writeConfig(dir, config2) {
612
506
  writeFileSync(getConfigPath(dir), JSON.stringify(config2, null, 2), "utf8");
613
507
  }
614
508
  function findInstallDir(startDir = process.cwd()) {
615
- const candidate = join(startDir, "lead-routing.json");
509
+ const candidate = join2(startDir, "lead-routing.json");
616
510
  if (existsSync2(candidate)) return startDir;
617
- const nested = join(startDir, "lead-routing", "lead-routing.json");
618
- if (existsSync2(nested)) return join(startDir, "lead-routing");
511
+ const nested = join2(startDir, "lead-routing", "lead-routing.json");
512
+ if (existsSync2(nested)) return join2(startDir, "lead-routing");
619
513
  return null;
620
514
  }
621
515
 
@@ -623,14 +517,14 @@ function findInstallDir(startDir = process.cwd()) {
623
517
  var __dirname = dirname(fileURLToPath(import.meta.url));
624
518
  function getCliVersion() {
625
519
  try {
626
- const pkg = JSON.parse(readFileSync2(join2(__dirname, "../../package.json"), "utf8"));
520
+ const pkg = JSON.parse(readFileSync2(join3(__dirname, "../../package.json"), "utf8"));
627
521
  return pkg.version ?? "0.1.0";
628
522
  } catch {
629
523
  return "0.1.0";
630
524
  }
631
525
  }
632
526
  function generateFiles(cfg, sshCfg) {
633
- const dir = join2(process.cwd(), "lead-routing");
527
+ const dir = join3(process.cwd(), "lead-routing");
634
528
  mkdirSync(dir, { recursive: true });
635
529
  const dockerEngineUrl = `http://engine:3001`;
636
530
  const composeContent = renderDockerCompose({
@@ -638,12 +532,12 @@ function generateFiles(cfg, sshCfg) {
638
532
  managedRedis: cfg.managedRedis,
639
533
  dbPassword: cfg.dbPassword
640
534
  });
641
- const composeFile = join2(dir, "docker-compose.yml");
535
+ const composeFile = join3(dir, "docker-compose.yml");
642
536
  writeFileSync2(composeFile, composeContent, "utf8");
643
- log2.success("Generated docker-compose.yml");
537
+ log3.success("Generated docker-compose.yml");
644
538
  const caddyfileContent = renderCaddyfile(cfg.appUrl, cfg.engineUrl);
645
- writeFileSync2(join2(dir, "Caddyfile"), caddyfileContent, "utf8");
646
- log2.success("Generated Caddyfile");
539
+ writeFileSync2(join3(dir, "Caddyfile"), caddyfileContent, "utf8");
540
+ log3.success("Generated Caddyfile");
647
541
  const envWebContent = renderEnvWeb({
648
542
  appUrl: cfg.appUrl,
649
543
  engineUrl: dockerEngineUrl,
@@ -658,9 +552,9 @@ function generateFiles(cfg, sshCfg) {
658
552
  resendApiKey: cfg.resendApiKey || void 0,
659
553
  feedbackToEmail: cfg.feedbackToEmail || void 0
660
554
  });
661
- const envWeb = join2(dir, ".env.web");
555
+ const envWeb = join3(dir, ".env.web");
662
556
  writeFileSync2(envWeb, envWebContent, "utf8");
663
- log2.success("Generated .env.web");
557
+ log3.success("Generated .env.web");
664
558
  const envEngineContent = renderEnvEngine({
665
559
  databaseUrl: cfg.databaseUrl,
666
560
  redisUrl: cfg.redisUrl,
@@ -669,9 +563,9 @@ function generateFiles(cfg, sshCfg) {
669
563
  sfdcLoginUrl: cfg.sfdcLoginUrl,
670
564
  engineWebhookSecret: cfg.engineWebhookSecret
671
565
  });
672
- const envEngine = join2(dir, ".env.engine");
566
+ const envEngine = join3(dir, ".env.engine");
673
567
  writeFileSync2(envEngine, envEngineContent, "utf8");
674
- log2.success("Generated .env.engine");
568
+ log3.success("Generated .env.engine");
675
569
  writeConfig(dir, {
676
570
  appUrl: cfg.appUrl,
677
571
  engineUrl: cfg.engineUrl,
@@ -694,12 +588,12 @@ function generateFiles(cfg, sshCfg) {
694
588
  installedAt: (/* @__PURE__ */ new Date()).toISOString(),
695
589
  version: getCliVersion()
696
590
  });
697
- log2.success("Generated lead-routing.json");
591
+ log3.success("Generated lead-routing.json");
698
592
  return { dir, composeFile, envWeb, envEngine, adminSecret: cfg.adminSecret };
699
593
  }
700
594
 
701
595
  // src/steps/check-remote-prerequisites.ts
702
- import { log as log3 } from "@clack/prompts";
596
+ import { log as log4 } from "@clack/prompts";
703
597
  async function checkRemotePrerequisites(ssh) {
704
598
  const results = await Promise.all([
705
599
  checkRemoteDocker(ssh),
@@ -711,15 +605,15 @@ async function checkRemotePrerequisites(ssh) {
711
605
  const warnings = results.filter((r) => !r.ok && r.warn);
712
606
  for (const r of results) {
713
607
  if (r.ok) {
714
- log3.success(r.label);
608
+ log4.success(r.label);
715
609
  } else if (r.warn) {
716
- log3.warn(r.label);
610
+ log4.warn(r.label);
717
611
  } else {
718
- log3.error(r.label);
612
+ log4.error(r.label);
719
613
  }
720
614
  }
721
615
  if (warnings.length > 0) {
722
- log3.warn("Non-blocking warnings above \u2014 setup will continue.");
616
+ log4.warn("Non-blocking warnings above \u2014 setup will continue.");
723
617
  }
724
618
  if (failed.length > 0) {
725
619
  throw new Error(
@@ -793,7 +687,7 @@ async function checkRemotePort(ssh, port) {
793
687
  }
794
688
 
795
689
  // src/steps/upload-files.ts
796
- import { join as join3 } from "path";
690
+ import { join as join4 } from "path";
797
691
  import { spinner as spinner2 } from "@clack/prompts";
798
692
  async function uploadFiles(ssh, localDir, remoteDir) {
799
693
  const s = spinner2();
@@ -809,7 +703,7 @@ async function uploadFiles(ssh, localDir, remoteDir) {
809
703
  ];
810
704
  await ssh.upload(
811
705
  filenames.map((f) => ({
812
- local: join3(localDir, f),
706
+ local: join4(localDir, f),
813
707
  remote: `${remoteDir}/${f}`
814
708
  }))
815
709
  );
@@ -821,7 +715,7 @@ async function uploadFiles(ssh, localDir, remoteDir) {
821
715
  }
822
716
 
823
717
  // src/steps/start-services.ts
824
- import { spinner as spinner3, log as log4 } from "@clack/prompts";
718
+ import { spinner as spinner3, log as log5 } from "@clack/prompts";
825
719
  async function startServices(ssh, remoteDir) {
826
720
  await wipeStalePostgresVolume(ssh, remoteDir);
827
721
  await pullImages(ssh, remoteDir);
@@ -842,7 +736,7 @@ async function wipeStalePostgresVolume(ssh, remoteDir) {
842
736
  s.stop("Old volumes removed \u2014 database will be initialised fresh");
843
737
  } catch {
844
738
  s.stop("Could not remove old volumes \u2014 proceeding anyway");
845
- log4.warn(
739
+ log5.warn(
846
740
  `If migrations fail with "authentication error", remove the postgres_data volume manually:
847
741
  ssh into your server and run: docker volume rm ${volumeName}`
848
742
  );
@@ -856,7 +750,7 @@ async function pullImages(ssh, remoteDir) {
856
750
  s.stop("Images pulled successfully");
857
751
  } catch {
858
752
  s.stop("Could not pull images from registry \u2014 using local images if available");
859
- log4.warn(
753
+ log5.warn(
860
754
  "Registry pull failed. If images are available on the server locally, setup will continue."
861
755
  );
862
756
  }
@@ -891,7 +785,7 @@ async function waitForPostgres(ssh, remoteDir) {
891
785
  }
892
786
  if (!containerReady) {
893
787
  s.stop("PostgreSQL readiness check timed out \u2014 continuing anyway");
894
- log4.warn("PostgreSQL may not be fully ready. If migrations fail, try `lead-routing deploy`.");
788
+ log5.warn("PostgreSQL may not be fully ready. If migrations fail, try `lead-routing deploy`.");
895
789
  return;
896
790
  }
897
791
  for (let j = 0; j < 8; j++) {
@@ -906,7 +800,7 @@ async function waitForPostgres(ssh, remoteDir) {
906
800
  await sleep(1e3);
907
801
  }
908
802
  s.stop("PostgreSQL is ready");
909
- log4.warn("Host TCP port check timed out \u2014 tunnel may have issues. Proceeding anyway.");
803
+ log5.warn("Host TCP port check timed out \u2014 tunnel may have issues. Proceeding anyway.");
910
804
  }
911
805
  function sleep(ms) {
912
806
  return new Promise((resolve2) => setTimeout(resolve2, ms));
@@ -1021,7 +915,7 @@ ON CONFLICT ("orgId", email) DO NOTHING;
1021
915
  }
1022
916
 
1023
917
  // src/steps/verify-health.ts
1024
- import { spinner as spinner5, log as log5 } from "@clack/prompts";
918
+ import { spinner as spinner5, log as log6 } from "@clack/prompts";
1025
919
  async function verifyHealth(appUrl, engineUrl, ssh, remoteDir) {
1026
920
  const checks = [
1027
921
  { service: "Web app", url: `${appUrl}/api/health` },
@@ -1030,17 +924,17 @@ async function verifyHealth(appUrl, engineUrl, ssh, remoteDir) {
1030
924
  const results = await Promise.all(checks.map(({ service, url }) => pollHealth(service, url)));
1031
925
  for (const r of results) {
1032
926
  if (r.ok) {
1033
- log5.success(`${r.service} \u2014 ${r.url}`);
927
+ log6.success(`${r.service} \u2014 ${r.url}`);
1034
928
  } else {
1035
- log5.warn(`${r.service} \u2014 did not respond after ${r.detail}`);
929
+ log6.warn(`${r.service} \u2014 did not respond after ${r.detail}`);
1036
930
  }
1037
931
  }
1038
932
  const failed = results.filter((r) => !r.ok);
1039
933
  if (failed.length === 0) return;
1040
- log5.info("Fetching remote diagnostics\u2026");
934
+ log6.info("Fetching remote diagnostics\u2026");
1041
935
  try {
1042
936
  const { stdout: ps } = await ssh.execSilent("docker compose ps --format table", remoteDir);
1043
- if (ps.trim()) log5.info(`Container status:
937
+ if (ps.trim()) log6.info(`Container status:
1044
938
  ${ps.trim()}`);
1045
939
  } catch {
1046
940
  }
@@ -1049,7 +943,7 @@ ${ps.trim()}`);
1049
943
  "docker compose logs caddy --tail 30 --no-color 2>&1",
1050
944
  remoteDir
1051
945
  );
1052
- if (caddyLogs.trim()) log5.info(`Caddy logs (last 30 lines):
946
+ if (caddyLogs.trim()) log6.info(`Caddy logs (last 30 lines):
1053
947
  ${caddyLogs.trim()}`);
1054
948
  } catch {
1055
949
  }
@@ -1100,10 +994,10 @@ function sleep2(ms) {
1100
994
 
1101
995
  // src/steps/sfdc-deploy-inline.ts
1102
996
  import { readFileSync as readFileSync4, writeFileSync as writeFileSync3, existsSync as existsSync4, cpSync, rmSync } from "fs";
1103
- import { join as join5, dirname as dirname3 } from "path";
997
+ import { join as join6, dirname as dirname3 } from "path";
1104
998
  import { tmpdir } from "os";
1105
999
  import { fileURLToPath as fileURLToPath3 } from "url";
1106
- import { spinner as spinner6, log as log6 } from "@clack/prompts";
1000
+ import { spinner as spinner6, log as log7 } from "@clack/prompts";
1107
1001
  import { execa as execa3 } from "execa";
1108
1002
  var __dirname3 = dirname3(fileURLToPath3(import.meta.url));
1109
1003
  function patchXml(content, tag, value) {
@@ -1122,7 +1016,7 @@ async function sfdcDeployInline(params) {
1122
1016
  let sfCredEnv = {};
1123
1017
  let targetOrgArgs = ["--target-org", orgAlias];
1124
1018
  if (alreadyAuthed) {
1125
- log6.success("Using existing Salesforce authentication");
1019
+ log7.success("Using existing Salesforce authentication");
1126
1020
  } else {
1127
1021
  const { accessToken, instanceUrl, aliasStored } = await loginViaAppBridge(appUrl, orgAlias);
1128
1022
  sfCredEnv = { SF_ACCESS_TOKEN: accessToken, SF_ORG_INSTANCE_URL: instanceUrl };
@@ -1131,10 +1025,10 @@ async function sfdcDeployInline(params) {
1131
1025
  }
1132
1026
  }
1133
1027
  s.start("Copying Salesforce package\u2026");
1134
- const inDist = join5(__dirname3, "sfdc-package");
1135
- const nextToDist = join5(__dirname3, "..", "sfdc-package");
1028
+ const inDist = join6(__dirname3, "sfdc-package");
1029
+ const nextToDist = join6(__dirname3, "..", "sfdc-package");
1136
1030
  const bundledPkg = existsSync4(inDist) ? inDist : nextToDist;
1137
- const destPkg = join5(installDir ?? tmpdir(), "lead-routing-sfdc-package");
1031
+ const destPkg = join6(installDir ?? tmpdir(), "lead-routing-sfdc-package");
1138
1032
  if (!existsSync4(bundledPkg)) {
1139
1033
  s.stop("sfdc-package not found in CLI bundle");
1140
1034
  throw new Error(
@@ -1145,7 +1039,7 @@ The CLI may need to be reinstalled: npm i -g @lead-routing/cli`
1145
1039
  if (existsSync4(destPkg)) rmSync(destPkg, { recursive: true, force: true });
1146
1040
  cpSync(bundledPkg, destPkg, { recursive: true });
1147
1041
  s.stop("Package copied");
1148
- const ncPath = join5(
1042
+ const ncPath = join6(
1149
1043
  destPkg,
1150
1044
  "force-app",
1151
1045
  "main",
@@ -1157,7 +1051,7 @@ The CLI may need to be reinstalled: npm i -g @lead-routing/cli`
1157
1051
  const nc = patchXml(readFileSync4(ncPath, "utf8"), "endpoint", engineUrl);
1158
1052
  writeFileSync3(ncPath, nc, "utf8");
1159
1053
  }
1160
- const rssEnginePath = join5(
1054
+ const rssEnginePath = join6(
1161
1055
  destPkg,
1162
1056
  "force-app",
1163
1057
  "main",
@@ -1170,7 +1064,7 @@ The CLI may need to be reinstalled: npm i -g @lead-routing/cli`
1170
1064
  rss = patchXml(rss, "description", "Lead Router Engine endpoint");
1171
1065
  writeFileSync3(rssEnginePath, rss, "utf8");
1172
1066
  }
1173
- const rssAppPath = join5(
1067
+ const rssAppPath = join6(
1174
1068
  destPkg,
1175
1069
  "force-app",
1176
1070
  "main",
@@ -1183,7 +1077,7 @@ The CLI may need to be reinstalled: npm i -g @lead-routing/cli`
1183
1077
  rss = patchXml(rss, "description", "Lead Router App URL");
1184
1078
  writeFileSync3(rssAppPath, rss, "utf8");
1185
1079
  }
1186
- log6.success("Remote Site Settings patched");
1080
+ log7.success("Remote Site Settings patched");
1187
1081
  s.start("Deploying Salesforce package (this may take ~2 min)\u2026");
1188
1082
  try {
1189
1083
  await execa3(
@@ -1216,8 +1110,8 @@ The CLI may need to be reinstalled: npm i -g @lead-routing/cli`
1216
1110
  s.stop("Permission set already assigned");
1217
1111
  } else {
1218
1112
  s.stop("Permission set assignment failed (non-fatal)");
1219
- log6.warn(msg);
1220
- log6.info(
1113
+ log7.warn(msg);
1114
+ log7.info(
1221
1115
  "Grant access manually:\n Salesforce Setup \u2192 Users \u2192 Permission Sets \u2192 Lead Router Admin \u2192 Manage Assignments"
1222
1116
  );
1223
1117
  }
@@ -1266,8 +1160,8 @@ The CLI may need to be reinstalled: npm i -g @lead-routing/cli`
1266
1160
  s.stop("Org settings written");
1267
1161
  } catch (err) {
1268
1162
  s.stop("Org settings write failed (non-fatal)");
1269
- log6.warn(String(err));
1270
- log6.info("Set manually: Salesforce \u2192 Custom Settings \u2192 Routing Settings \u2192 Manage");
1163
+ log7.warn(String(err));
1164
+ log7.info("Set manually: Salesforce \u2192 Custom Settings \u2192 Routing Settings \u2192 Manage");
1271
1165
  }
1272
1166
  }
1273
1167
  async function loginViaAppBridge(rawAppUrl, orgAlias) {
@@ -1296,11 +1190,11 @@ Ensure the app is running and the URL is correct.`
1296
1190
  );
1297
1191
  }
1298
1192
  s.stop("Auth session started");
1299
- log6.info(`Open this URL in your browser to authenticate with Salesforce:
1193
+ log7.info(`Open this URL in your browser to authenticate with Salesforce:
1300
1194
 
1301
1195
  ${authUrl}
1302
1196
  `);
1303
- log6.info('If Chrome shows a "Dangerous site" warning with no proceed option, paste the URL into Safari or Firefox.');
1197
+ log7.info('If Chrome shows a "Dangerous site" warning with no proceed option, paste the URL into Safari or Firefox.');
1304
1198
  const opener = process.platform === "win32" ? "start" : process.platform === "darwin" ? "open" : "xdg-open";
1305
1199
  await execa3(opener, [authUrl], { reject: false }).catch(() => {
1306
1200
  });
@@ -1340,17 +1234,17 @@ Ensure the app is running and the URL is correct.`
1340
1234
  ["org", "login", "access-token", "--instance-url", instanceUrl, "--alias", orgAlias, "--no-prompt"],
1341
1235
  { env: { ...process.env, SFDX_ACCESS_TOKEN: accessToken } }
1342
1236
  );
1343
- log6.success(`Salesforce org saved as "${orgAlias}"`);
1237
+ log7.success(`Salesforce org saved as "${orgAlias}"`);
1344
1238
  aliasStored = true;
1345
1239
  } catch (err) {
1346
- log6.warn(`Could not persist sf CLI credentials: ${String(err)}`);
1347
- log6.info("Continuing with direct token auth for this session.");
1240
+ log7.warn(`Could not persist sf CLI credentials: ${String(err)}`);
1241
+ log7.info("Continuing with direct token auth for this session.");
1348
1242
  }
1349
1243
  return { accessToken, instanceUrl, aliasStored };
1350
1244
  }
1351
1245
 
1352
1246
  // src/steps/app-launcher-guide.ts
1353
- import { note as note3, confirm as confirm2, isCancel as isCancel3, log as log7 } from "@clack/prompts";
1247
+ import { note as note3, confirm, isCancel as isCancel3, log as log8 } from "@clack/prompts";
1354
1248
  import chalk from "chalk";
1355
1249
  async function guideAppLauncherSetup(appUrl) {
1356
1250
  note3(
@@ -1371,24 +1265,24 @@ async function guideAppLauncherSetup(appUrl) {
1371
1265
  ` + chalk.dim("Keep this terminal open while you complete the wizard."),
1372
1266
  "Complete Salesforce setup"
1373
1267
  );
1374
- const done = await confirm2({
1268
+ const done = await confirm({
1375
1269
  message: "Have you completed the App Launcher wizard?",
1376
1270
  initialValue: false
1377
1271
  });
1378
1272
  if (isCancel3(done)) {
1379
- log7.warn(
1273
+ log8.warn(
1380
1274
  "Wizard skipped. Run `lead-routing sfdc deploy` to retry the Salesforce setup."
1381
1275
  );
1382
1276
  return;
1383
1277
  }
1384
1278
  if (!done) {
1385
- log7.warn(
1279
+ log8.warn(
1386
1280
  `No problem \u2014 complete it at your own pace.
1387
1281
  Open App Launcher \u2192 Lead Router Setup \u2192 Connect to Lead Router
1388
1282
  Dashboard: ${appUrl}`
1389
1283
  );
1390
1284
  } else {
1391
- log7.success("Salesforce setup complete");
1285
+ log8.success("Salesforce setup complete");
1392
1286
  }
1393
1287
  }
1394
1288
 
@@ -1507,21 +1401,125 @@ ${result.stderr || result.stdout}`
1507
1401
  };
1508
1402
 
1509
1403
  // src/commands/init.ts
1404
+ async function checkDnsResolvable(appUrl, engineUrl) {
1405
+ let hosts;
1406
+ try {
1407
+ hosts = [.../* @__PURE__ */ new Set([new URL(appUrl).hostname, new URL(engineUrl).hostname])];
1408
+ } catch {
1409
+ return;
1410
+ }
1411
+ for (const host of hosts) {
1412
+ try {
1413
+ await dns.lookup(host);
1414
+ } catch {
1415
+ log9.warn(
1416
+ `${chalk2.yellow(host)} does not resolve in DNS yet.
1417
+ Check for typos \u2014 a bad domain will cause a 2-minute timeout at step 8.`
1418
+ );
1419
+ const go = await confirm2({ message: "Continue anyway?", initialValue: true });
1420
+ if (isCancel4(go) || !go) {
1421
+ cancel3("Setup cancelled.");
1422
+ process.exit(0);
1423
+ }
1424
+ }
1425
+ }
1426
+ }
1510
1427
  async function runInit(options = {}) {
1511
1428
  const dryRun = options.dryRun ?? false;
1429
+ const resume = options.resume ?? false;
1512
1430
  console.log();
1513
1431
  intro(
1514
- chalk2.bold.cyan("Lead Routing \u2014 Self-Hosted Setup") + (dryRun ? chalk2.yellow(" [dry run]") : "")
1432
+ chalk2.bold.cyan("Lead Routing \u2014 Self-Hosted Setup") + (dryRun ? chalk2.yellow(" [dry run]") : "") + (resume ? chalk2.yellow(" [resume]") : "")
1515
1433
  );
1516
1434
  const ssh = new SshConnection();
1435
+ if (resume) {
1436
+ try {
1437
+ const dir = findInstallDir();
1438
+ if (!dir) {
1439
+ log9.error("No lead-routing.json found \u2014 run `lead-routing init` first.");
1440
+ process.exit(1);
1441
+ }
1442
+ const saved = readConfig(dir);
1443
+ let sshPassword;
1444
+ if (!saved.ssh.privateKeyPath) {
1445
+ const pw = await promptPassword({
1446
+ message: `SSH password for ${saved.ssh.username}@${saved.ssh.host}`
1447
+ });
1448
+ if (typeof pw === "symbol") process.exit(0);
1449
+ sshPassword = pw;
1450
+ }
1451
+ log9.step("Connecting to server");
1452
+ await ssh.connect({
1453
+ host: saved.ssh.host,
1454
+ port: saved.ssh.port,
1455
+ username: saved.ssh.username,
1456
+ privateKeyPath: saved.ssh.privateKeyPath,
1457
+ password: sshPassword,
1458
+ remoteDir: saved.remoteDir
1459
+ });
1460
+ log9.success(`Connected to ${saved.ssh.host}`);
1461
+ const remoteDir = await ssh.resolveHome(saved.remoteDir);
1462
+ log9.step("Step 8/9 Verifying health");
1463
+ await verifyHealth(saved.appUrl, saved.engineUrl, ssh, remoteDir);
1464
+ log9.step("Step 9/9 Deploying Salesforce package");
1465
+ await sfdcDeployInline({
1466
+ appUrl: saved.appUrl,
1467
+ engineUrl: saved.engineUrl,
1468
+ orgAlias: "lead-routing",
1469
+ sfdcClientId: saved.sfdcClientId ?? "",
1470
+ sfdcLoginUrl: saved.sfdcLoginUrl ?? "https://login.salesforce.com",
1471
+ installDir: dir
1472
+ });
1473
+ await guideAppLauncherSetup(saved.appUrl);
1474
+ outro(
1475
+ chalk2.green("\u2714 You're live!") + `
1476
+
1477
+ Dashboard: ${chalk2.cyan(saved.appUrl)}
1478
+ Routing engine: ${chalk2.cyan(saved.engineUrl)}
1479
+
1480
+ ` + chalk2.bold(" Next steps:\n") + ` ${chalk2.cyan("1.")} Open ${chalk2.cyan(saved.appUrl)} and log in
1481
+ ${chalk2.cyan("2.")} Create your first routing rule to start routing leads
1482
+
1483
+ Run ${chalk2.cyan("lead-routing doctor")} to check service health at any time.
1484
+ Run ${chalk2.cyan("lead-routing deploy")} to update to a new version.`
1485
+ );
1486
+ } catch (err) {
1487
+ const message = err instanceof Error ? err.message : String(err);
1488
+ log9.error(`Resume failed: ${message}`);
1489
+ process.exit(1);
1490
+ } finally {
1491
+ await ssh.disconnect();
1492
+ }
1493
+ return;
1494
+ }
1517
1495
  try {
1518
- log8.step("Step 1/9 Checking local prerequisites");
1496
+ log9.step("Step 1/9 Checking local prerequisites");
1519
1497
  await checkPrerequisites();
1520
- log8.step("Step 2/9 Server connection");
1521
- const sshCfg = await collectSshConfig();
1522
- log8.step("Step 3/9 Configuration");
1523
- const cfg = await collectConfig();
1524
- log8.step("Step 4/9 Generating config files");
1498
+ log9.step("Step 2/9 SSH connection");
1499
+ const sshCfg = await collectSshConfig({
1500
+ sshPort: options.sshPort,
1501
+ sshUser: options.sshUser,
1502
+ sshKey: options.sshKey,
1503
+ remoteDir: options.remoteDir
1504
+ });
1505
+ if (!dryRun) {
1506
+ try {
1507
+ await ssh.connect(sshCfg);
1508
+ log9.success(`Connected to ${sshCfg.host}`);
1509
+ } catch (err) {
1510
+ log9.error(`SSH connection failed: ${String(err)}`);
1511
+ log9.info("Fix your SSH credentials and re-run `lead-routing init`.");
1512
+ process.exit(1);
1513
+ }
1514
+ }
1515
+ log9.step("Step 3/9 Configuration");
1516
+ const cfg = await collectConfig({
1517
+ sandbox: options.sandbox,
1518
+ externalDb: options.externalDb,
1519
+ externalRedis: options.externalRedis
1520
+ });
1521
+ await checkDnsResolvable(cfg.appUrl, cfg.engineUrl);
1522
+ log9.step("Step 4/9 Generating config files");
1525
1523
  const { dir, adminSecret } = generateFiles(cfg, sshCfg);
1526
1524
  note4(
1527
1525
  `Local config directory: ${chalk2.cyan(dir)}
@@ -1538,22 +1536,21 @@ Files created: docker-compose.yml, Caddyfile, .env.web, .env.engine, lead-routin
1538
1536
  );
1539
1537
  return;
1540
1538
  }
1541
- log8.step("Step 5/9 Connecting to server");
1542
- await ssh.connect(sshCfg);
1539
+ log9.step("Step 5/9 Remote setup");
1543
1540
  const remoteDir = await ssh.resolveHome(sshCfg.remoteDir);
1544
1541
  await checkRemotePrerequisites(ssh);
1545
1542
  await uploadFiles(ssh, dir, remoteDir);
1546
- log8.step("Step 6/9 Starting services");
1543
+ log9.step("Step 6/9 Starting services");
1547
1544
  await startServices(ssh, remoteDir);
1548
- log8.step("Step 7/9 Database migrations");
1545
+ log9.step("Step 7/9 Database migrations");
1549
1546
  await runMigrations(ssh, dir, cfg.adminEmail, cfg.adminPassword);
1550
- log8.step("Step 8/9 Verifying health");
1547
+ log9.step("Step 8/9 Verifying health");
1551
1548
  await verifyHealth(cfg.appUrl, cfg.engineUrl, ssh, remoteDir);
1552
- log8.step("Step 9/9 Deploying Salesforce package");
1549
+ log9.step("Step 9/9 Deploying Salesforce package");
1553
1550
  await sfdcDeployInline({
1554
1551
  appUrl: cfg.appUrl,
1555
1552
  engineUrl: cfg.engineUrl,
1556
- orgAlias: cfg.orgAlias,
1553
+ orgAlias: "lead-routing",
1557
1554
  sfdcClientId: cfg.sfdcClientId,
1558
1555
  sfdcLoginUrl: cfg.sfdcLoginUrl,
1559
1556
  installDir: dir
@@ -1577,7 +1574,7 @@ Files created: docker-compose.yml, Caddyfile, .env.web, .env.engine, lead-routin
1577
1574
  );
1578
1575
  } catch (err) {
1579
1576
  const message = err instanceof Error ? err.message : String(err);
1580
- log8.error(`Setup failed: ${message}`);
1577
+ log9.error(`Setup failed: ${message}`);
1581
1578
  process.exit(1);
1582
1579
  } finally {
1583
1580
  await ssh.disconnect();
@@ -1586,16 +1583,16 @@ Files created: docker-compose.yml, Caddyfile, .env.web, .env.engine, lead-routin
1586
1583
 
1587
1584
  // src/commands/deploy.ts
1588
1585
  import { writeFileSync as writeFileSync4, unlinkSync } from "fs";
1589
- import { join as join6 } from "path";
1586
+ import { join as join7 } from "path";
1590
1587
  import { tmpdir as tmpdir2 } from "os";
1591
- import { intro as intro2, outro as outro2, log as log9, password as promptPassword } from "@clack/prompts";
1588
+ import { intro as intro2, outro as outro2, log as log10, password as promptPassword2 } from "@clack/prompts";
1592
1589
  import chalk3 from "chalk";
1593
1590
  async function runDeploy() {
1594
1591
  console.log();
1595
1592
  intro2(chalk3.bold.cyan("Lead Routing \u2014 Deploy"));
1596
1593
  const dir = findInstallDir();
1597
1594
  if (!dir) {
1598
- log9.error(
1595
+ log10.error(
1599
1596
  "No lead-routing.json found. Run `lead-routing init` first, or run this command from your install directory."
1600
1597
  );
1601
1598
  process.exit(1);
@@ -1604,7 +1601,7 @@ async function runDeploy() {
1604
1601
  const ssh = new SshConnection();
1605
1602
  let sshPassword;
1606
1603
  if (!cfg.ssh.privateKeyPath) {
1607
- const pw = await promptPassword({
1604
+ const pw = await promptPassword2({
1608
1605
  message: `SSH password for ${cfg.ssh.username}@${cfg.ssh.host}`
1609
1606
  });
1610
1607
  if (typeof pw === "symbol") process.exit(0);
@@ -1619,28 +1616,28 @@ async function runDeploy() {
1619
1616
  password: sshPassword,
1620
1617
  remoteDir: cfg.remoteDir
1621
1618
  });
1622
- log9.success(`Connected to ${cfg.ssh.host}`);
1619
+ log10.success(`Connected to ${cfg.ssh.host}`);
1623
1620
  } catch (err) {
1624
- log9.error(`SSH connection failed: ${String(err)}`);
1621
+ log10.error(`SSH connection failed: ${String(err)}`);
1625
1622
  process.exit(1);
1626
1623
  }
1627
1624
  try {
1628
1625
  const remoteDir = await ssh.resolveHome(cfg.remoteDir);
1629
- log9.step("Syncing Caddyfile");
1626
+ log10.step("Syncing Caddyfile");
1630
1627
  const caddyContent = renderCaddyfile(cfg.appUrl, cfg.engineUrl);
1631
- const tmpCaddy = join6(tmpdir2(), "lead-routing-Caddyfile");
1628
+ const tmpCaddy = join7(tmpdir2(), "lead-routing-Caddyfile");
1632
1629
  writeFileSync4(tmpCaddy, caddyContent, "utf8");
1633
1630
  await ssh.upload([{ local: tmpCaddy, remote: `${remoteDir}/Caddyfile` }]);
1634
1631
  unlinkSync(tmpCaddy);
1635
1632
  await ssh.exec("docker compose restart caddy", remoteDir);
1636
- log9.success("Caddyfile synced \u2014 waiting for TLS cert (~30s)");
1637
- log9.step("Pulling latest Docker images");
1633
+ log10.success("Caddyfile synced \u2014 waiting for TLS cert (~30s)");
1634
+ log10.step("Pulling latest Docker images");
1638
1635
  await ssh.exec("docker compose pull", remoteDir);
1639
- log9.success("Images pulled");
1640
- log9.step("Restarting services");
1636
+ log10.success("Images pulled");
1637
+ log10.step("Restarting services");
1641
1638
  await ssh.exec("docker compose up -d --remove-orphans", remoteDir);
1642
- log9.success("Services restarted");
1643
- log9.step("Running database migrations");
1639
+ log10.success("Services restarted");
1640
+ log10.step("Running database migrations");
1644
1641
  await runMigrations(ssh, dir);
1645
1642
  outro2(
1646
1643
  chalk3.green("\u2714 Deployment complete!") + `
@@ -1649,7 +1646,7 @@ async function runDeploy() {
1649
1646
  );
1650
1647
  } catch (err) {
1651
1648
  const message = err instanceof Error ? err.message : String(err);
1652
- log9.error(`Deploy failed: ${message}`);
1649
+ log10.error(`Deploy failed: ${message}`);
1653
1650
  process.exit(1);
1654
1651
  } finally {
1655
1652
  await ssh.disconnect();
@@ -1657,7 +1654,7 @@ async function runDeploy() {
1657
1654
  }
1658
1655
 
1659
1656
  // src/commands/doctor.ts
1660
- import { intro as intro3, outro as outro3, log as log10 } from "@clack/prompts";
1657
+ import { intro as intro3, outro as outro3, log as log11 } from "@clack/prompts";
1661
1658
  import chalk4 from "chalk";
1662
1659
  import { execa as execa4 } from "execa";
1663
1660
  async function runDoctor() {
@@ -1665,7 +1662,7 @@ async function runDoctor() {
1665
1662
  intro3(chalk4.bold.cyan("Lead Routing \u2014 Health Check"));
1666
1663
  const dir = findInstallDir();
1667
1664
  if (!dir) {
1668
- log10.error("No lead-routing.json found. Run `lead-routing init` first.");
1665
+ log11.error("No lead-routing.json found. Run `lead-routing init` first.");
1669
1666
  process.exit(1);
1670
1667
  }
1671
1668
  const cfg = readConfig(dir);
@@ -1748,17 +1745,17 @@ async function checkEndpoint(label, url) {
1748
1745
  }
1749
1746
 
1750
1747
  // src/commands/logs.ts
1751
- import { log as log11 } from "@clack/prompts";
1748
+ import { log as log12 } from "@clack/prompts";
1752
1749
  import { execa as execa5 } from "execa";
1753
1750
  var VALID_SERVICES = ["web", "engine", "postgres", "redis"];
1754
1751
  async function runLogs(service = "engine") {
1755
1752
  if (!VALID_SERVICES.includes(service)) {
1756
- log11.error(`Unknown service "${service}". Valid options: ${VALID_SERVICES.join(", ")}`);
1753
+ log12.error(`Unknown service "${service}". Valid options: ${VALID_SERVICES.join(", ")}`);
1757
1754
  process.exit(1);
1758
1755
  }
1759
1756
  const dir = findInstallDir();
1760
1757
  if (!dir) {
1761
- log11.error("No lead-routing.json found. Run `lead-routing init` first.");
1758
+ log12.error("No lead-routing.json found. Run `lead-routing init` first.");
1762
1759
  process.exit(1);
1763
1760
  }
1764
1761
  console.log(`
@@ -1773,12 +1770,12 @@ Streaming logs for ${service} (Ctrl+C to stop)...
1773
1770
  }
1774
1771
 
1775
1772
  // src/commands/status.ts
1776
- import { log as log12 } from "@clack/prompts";
1773
+ import { log as log13 } from "@clack/prompts";
1777
1774
  import { execa as execa6 } from "execa";
1778
1775
  async function runStatus() {
1779
1776
  const dir = findInstallDir();
1780
1777
  if (!dir) {
1781
- log12.error("No lead-routing.json found. Run `lead-routing init` first.");
1778
+ log13.error("No lead-routing.json found. Run `lead-routing init` first.");
1782
1779
  process.exit(1);
1783
1780
  }
1784
1781
  const result = await execa6("docker", ["compose", "ps"], {
@@ -1787,15 +1784,15 @@ async function runStatus() {
1787
1784
  reject: false
1788
1785
  });
1789
1786
  if (result.exitCode !== 0) {
1790
- log12.error("Failed to get container status. Is Docker running?");
1787
+ log13.error("Failed to get container status. Is Docker running?");
1791
1788
  process.exit(1);
1792
1789
  }
1793
1790
  }
1794
1791
 
1795
1792
  // src/commands/config.ts
1796
1793
  import { readFileSync as readFileSync5, writeFileSync as writeFileSync5, existsSync as existsSync5 } from "fs";
1797
- import { join as join7 } from "path";
1798
- import { intro as intro4, outro as outro4, text as text3, password as password3, spinner as spinner7, log as log13 } from "@clack/prompts";
1794
+ import { join as join8 } from "path";
1795
+ import { intro as intro4, outro as outro4, text as text3, password as password3, spinner as spinner7, log as log14 } from "@clack/prompts";
1799
1796
  import chalk5 from "chalk";
1800
1797
  import { execa as execa7 } from "execa";
1801
1798
  function parseEnv(filePath) {
@@ -1834,18 +1831,18 @@ async function runConfigSfdc() {
1834
1831
  intro4("Lead Routing \u2014 Update Salesforce Credentials");
1835
1832
  const dir = findInstallDir();
1836
1833
  if (!dir) {
1837
- log13.error("No lead-routing installation found in the current directory.");
1838
- log13.info("Run `lead-routing init` first, or cd into your installation directory.");
1834
+ log14.error("No lead-routing installation found in the current directory.");
1835
+ log14.info("Run `lead-routing init` first, or cd into your installation directory.");
1839
1836
  process.exit(1);
1840
1837
  }
1841
- const envWeb = join7(dir, ".env.web");
1842
- const envEngine = join7(dir, ".env.engine");
1838
+ const envWeb = join8(dir, ".env.web");
1839
+ const envEngine = join8(dir, ".env.engine");
1843
1840
  const currentWeb = parseEnv(envWeb);
1844
1841
  const currentClientId = currentWeb.get("SFDC_CLIENT_ID") ?? "";
1845
1842
  const currentLoginUrl = currentWeb.get("SFDC_LOGIN_URL") ?? "https://login.salesforce.com";
1846
1843
  const currentAppUrl = currentWeb.get("APP_URL") ?? "";
1847
1844
  const callbackUrl = `${currentAppUrl}/api/auth/callback`;
1848
- log13.info(
1845
+ log14.info(
1849
1846
  `Paste the credentials from your Salesforce Connected App.
1850
1847
  Callback URL for your Connected App: ${callbackUrl}`
1851
1848
  );
@@ -1870,7 +1867,7 @@ Callback URL for your Connected App: ${callbackUrl}`
1870
1867
  };
1871
1868
  writeEnv(envWeb, updates);
1872
1869
  writeEnv(envEngine, updates);
1873
- log13.success("Updated .env.web and .env.engine");
1870
+ log14.success("Updated .env.web and .env.engine");
1874
1871
  const s = spinner7();
1875
1872
  s.start("Restarting web and engine containers\u2026");
1876
1873
  try {
@@ -1880,7 +1877,7 @@ Callback URL for your Connected App: ${callbackUrl}`
1880
1877
  s.stop("Containers restarted");
1881
1878
  } catch (err) {
1882
1879
  s.stop("Restart failed \u2014 run `docker compose up -d --force-recreate web engine` manually");
1883
- log13.warn(String(err));
1880
+ log14.warn(String(err));
1884
1881
  }
1885
1882
  outro4(
1886
1883
  "Salesforce credentials updated!\n\nNext: go to the web app \u2192 Settings \u2192 Connect Salesforce to refresh your OAuth tokens."
@@ -1892,7 +1889,7 @@ function runConfigShow() {
1892
1889
  console.error("No lead-routing installation found in the current directory.");
1893
1890
  process.exit(1);
1894
1891
  }
1895
- const envWeb = join7(dir, ".env.web");
1892
+ const envWeb = join8(dir, ".env.web");
1896
1893
  const cfg = parseEnv(envWeb);
1897
1894
  const adminSecret = cfg.get("ADMIN_SECRET") ?? "(not found)";
1898
1895
  const appUrl = cfg.get("APP_URL") ?? "(not found)";
@@ -1908,7 +1905,7 @@ function runConfigShow() {
1908
1905
  }
1909
1906
 
1910
1907
  // src/commands/sfdc.ts
1911
- import { intro as intro5, outro as outro5, text as text4, spinner as spinner8, log as log14 } from "@clack/prompts";
1908
+ import { intro as intro5, outro as outro5, text as text4, spinner as spinner8, log as log15 } from "@clack/prompts";
1912
1909
  import chalk6 from "chalk";
1913
1910
  import { execa as execa8 } from "execa";
1914
1911
  async function runSfdcDeploy() {
@@ -1920,9 +1917,9 @@ async function runSfdcDeploy() {
1920
1917
  if (config2?.appUrl && config2?.engineUrl) {
1921
1918
  appUrl = config2.appUrl;
1922
1919
  engineUrl = config2.engineUrl;
1923
- log14.info(`Using config from ${dir}/lead-routing.json`);
1920
+ log15.info(`Using config from ${dir}/lead-routing.json`);
1924
1921
  } else {
1925
- log14.warn("No lead-routing.json found \u2014 enter the URLs from your installation.");
1922
+ log15.warn("No lead-routing.json found \u2014 enter the URLs from your installation.");
1926
1923
  const rawApp = await text4({
1927
1924
  message: "App URL (e.g. https://leads.acme.com)",
1928
1925
  validate: (v) => !v ? "Required" : void 0
@@ -1943,7 +1940,7 @@ async function runSfdcDeploy() {
1943
1940
  s.stop("Salesforce CLI found");
1944
1941
  } catch {
1945
1942
  s.stop("Salesforce CLI (sf) not found");
1946
- log14.error(
1943
+ log15.error(
1947
1944
  "Install the Salesforce CLI and re-run this command:\n https://developer.salesforce.com/tools/salesforcecli"
1948
1945
  );
1949
1946
  process.exit(1);
@@ -1966,17 +1963,13 @@ async function runSfdcDeploy() {
1966
1963
  installDir: dir ?? void 0
1967
1964
  });
1968
1965
  } catch (err) {
1969
- log14.error(err instanceof Error ? err.message : String(err));
1966
+ log15.error(err instanceof Error ? err.message : String(err));
1970
1967
  process.exit(1);
1971
1968
  }
1969
+ await guideAppLauncherSetup(appUrl);
1972
1970
  outro5(
1973
1971
  chalk6.green("\u2714 Salesforce package deployed!") + `
1974
1972
 
1975
- Next steps:
1976
- 1. In Salesforce, open App Launcher \u2192 search "Lead Router Setup"
1977
- 2. Click "Connect to Lead Router" to authorise the OAuth connection
1978
- 3. Follow the 4-step wizard to activate triggers and sync field schema
1979
-
1980
1973
  Your Lead Router dashboard: ${chalk6.cyan(appUrl)}`
1981
1974
  );
1982
1975
  }
@@ -1984,7 +1977,17 @@ async function runSfdcDeploy() {
1984
1977
  // src/index.ts
1985
1978
  var program = new Command();
1986
1979
  program.name("lead-routing").description("Self-hosted Lead Routing \u2014 scaffold, deploy, and manage your installation").version("0.1.0");
1987
- 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 }));
1980
+ program.command("init").description("Interactive setup wizard \u2014 configure and deploy the full Lead Routing stack").option("--dry-run", "Generate config files without connecting or deploying").option("--resume", "Skip to health check using existing lead-routing.json (post-timeout recovery)").option("--sandbox", "Use Salesforce sandbox (test.salesforce.com) instead of production").option("--ssh-port <port>", "SSH port (default: 22)", parseInt).option("--ssh-user <user>", "SSH username (default: root)").option("--ssh-key <path>", "Path to SSH private key (overrides auto-detection)").option("--remote-dir <path>", "Remote install directory (default: ~/lead-routing)").option("--external-db <url>", "Use external PostgreSQL URL instead of managed Docker container").option("--external-redis <url>", "Use external Redis URL instead of managed Docker container").action((opts) => runInit({
1981
+ dryRun: opts.dryRun,
1982
+ resume: opts.resume,
1983
+ sandbox: opts.sandbox,
1984
+ sshPort: opts.sshPort,
1985
+ sshUser: opts.sshUser,
1986
+ sshKey: opts.sshKey,
1987
+ remoteDir: opts.remoteDir,
1988
+ externalDb: opts.externalDb,
1989
+ externalRedis: opts.externalRedis
1990
+ }));
1988
1991
  program.command("deploy").description("Pull latest images, restart services, and run any pending migrations").action(runDeploy);
1989
1992
  program.command("doctor").description("Check the health of all services in your installation").action(runDoctor);
1990
1993
  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.4",
3
+ "version": "0.1.6",
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"],