@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.
- package/dist/index.js +279 -276
- 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 {
|
|
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 {
|
|
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
|
|
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 (
|
|
118
|
-
const
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
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
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
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:
|
|
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
|
|
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
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
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
|
|
261
|
+
import { join as join3, dirname } from "path";
|
|
368
262
|
import { fileURLToPath } from "url";
|
|
369
|
-
import { log as
|
|
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
|
|
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 =
|
|
509
|
+
const candidate = join2(startDir, "lead-routing.json");
|
|
616
510
|
if (existsSync2(candidate)) return startDir;
|
|
617
|
-
const nested =
|
|
618
|
-
if (existsSync2(nested)) return
|
|
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(
|
|
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 =
|
|
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 =
|
|
535
|
+
const composeFile = join3(dir, "docker-compose.yml");
|
|
642
536
|
writeFileSync2(composeFile, composeContent, "utf8");
|
|
643
|
-
|
|
537
|
+
log3.success("Generated docker-compose.yml");
|
|
644
538
|
const caddyfileContent = renderCaddyfile(cfg.appUrl, cfg.engineUrl);
|
|
645
|
-
writeFileSync2(
|
|
646
|
-
|
|
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 =
|
|
555
|
+
const envWeb = join3(dir, ".env.web");
|
|
662
556
|
writeFileSync2(envWeb, envWebContent, "utf8");
|
|
663
|
-
|
|
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 =
|
|
566
|
+
const envEngine = join3(dir, ".env.engine");
|
|
673
567
|
writeFileSync2(envEngine, envEngineContent, "utf8");
|
|
674
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
608
|
+
log4.success(r.label);
|
|
715
609
|
} else if (r.warn) {
|
|
716
|
-
|
|
610
|
+
log4.warn(r.label);
|
|
717
611
|
} else {
|
|
718
|
-
|
|
612
|
+
log4.error(r.label);
|
|
719
613
|
}
|
|
720
614
|
}
|
|
721
615
|
if (warnings.length > 0) {
|
|
722
|
-
|
|
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
|
|
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:
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
927
|
+
log6.success(`${r.service} \u2014 ${r.url}`);
|
|
1034
928
|
} else {
|
|
1035
|
-
|
|
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
|
-
|
|
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())
|
|
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())
|
|
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
|
|
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
|
|
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
|
-
|
|
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 =
|
|
1135
|
-
const nextToDist =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
1220
|
-
|
|
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
|
-
|
|
1270
|
-
|
|
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
|
-
|
|
1193
|
+
log7.info(`Open this URL in your browser to authenticate with Salesforce:
|
|
1300
1194
|
|
|
1301
1195
|
${authUrl}
|
|
1302
1196
|
`);
|
|
1303
|
-
|
|
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
|
-
|
|
1237
|
+
log7.success(`Salesforce org saved as "${orgAlias}"`);
|
|
1344
1238
|
aliasStored = true;
|
|
1345
1239
|
} catch (err) {
|
|
1346
|
-
|
|
1347
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1496
|
+
log9.step("Step 1/9 Checking local prerequisites");
|
|
1519
1497
|
await checkPrerequisites();
|
|
1520
|
-
|
|
1521
|
-
const sshCfg = await collectSshConfig(
|
|
1522
|
-
|
|
1523
|
-
|
|
1524
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1543
|
+
log9.step("Step 6/9 Starting services");
|
|
1547
1544
|
await startServices(ssh, remoteDir);
|
|
1548
|
-
|
|
1545
|
+
log9.step("Step 7/9 Database migrations");
|
|
1549
1546
|
await runMigrations(ssh, dir, cfg.adminEmail, cfg.adminPassword);
|
|
1550
|
-
|
|
1547
|
+
log9.step("Step 8/9 Verifying health");
|
|
1551
1548
|
await verifyHealth(cfg.appUrl, cfg.engineUrl, ssh, remoteDir);
|
|
1552
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
1619
|
+
log10.success(`Connected to ${cfg.ssh.host}`);
|
|
1623
1620
|
} catch (err) {
|
|
1624
|
-
|
|
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
|
-
|
|
1626
|
+
log10.step("Syncing Caddyfile");
|
|
1630
1627
|
const caddyContent = renderCaddyfile(cfg.appUrl, cfg.engineUrl);
|
|
1631
|
-
const tmpCaddy =
|
|
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
|
-
|
|
1637
|
-
|
|
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
|
-
|
|
1640
|
-
|
|
1636
|
+
log10.success("Images pulled");
|
|
1637
|
+
log10.step("Restarting services");
|
|
1641
1638
|
await ssh.exec("docker compose up -d --remove-orphans", remoteDir);
|
|
1642
|
-
|
|
1643
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
1798
|
-
import { intro as intro4, outro as outro4, text as text3, password as password3, spinner as spinner7, log as
|
|
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
|
-
|
|
1838
|
-
|
|
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 =
|
|
1842
|
-
const envEngine =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
|
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
|
-
|
|
1920
|
+
log15.info(`Using config from ${dir}/lead-routing.json`);
|
|
1924
1921
|
} else {
|
|
1925
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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", "
|
|
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.
|
|
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"],
|