@lead-routing/cli 0.2.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.js +763 -623
- package/dist/prisma/migrations/20260310300000_make_branch_assignment_type_nullable/migration.sql +2 -0
- package/dist/prisma/migrations/20260311000000_add_integration_fields/migration.sql +6 -0
- package/dist/prisma/migrations/20260312000000_add_trigger_conditions/migration.sql +20 -0
- package/dist/prisma/migrations/20260312100000_add_ai_fields/migration.sql +7 -0
- package/dist/prisma/migrations/20260312120000_add_fuzzy_matching/migration.sql +26 -0
- package/dist/prisma/migrations/migration_lock.toml +3 -0
- package/dist/prisma/schema.prisma +76 -11
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -5,8 +5,10 @@ import { Command } from "commander";
|
|
|
5
5
|
|
|
6
6
|
// src/commands/init.ts
|
|
7
7
|
import { promises as dns } from "dns";
|
|
8
|
-
import {
|
|
9
|
-
import
|
|
8
|
+
import { readFileSync as readFileSync3, writeFileSync as writeFileSync3 } from "fs";
|
|
9
|
+
import { join as join4 } from "path";
|
|
10
|
+
import { intro, outro, note as note3, log as log7, confirm, cancel as cancel3, isCancel as isCancel3, password as promptPassword } from "@clack/prompts";
|
|
11
|
+
import chalk from "chalk";
|
|
10
12
|
|
|
11
13
|
// src/steps/prerequisites.ts
|
|
12
14
|
import { log } from "@clack/prompts";
|
|
@@ -170,8 +172,9 @@ async function collectConfig(opts = {}) {
|
|
|
170
172
|
const dbPassword = generateSecret(16);
|
|
171
173
|
const managedDb = !opts.externalDb;
|
|
172
174
|
const databaseUrl = opts.externalDb ?? `postgresql://leadrouting:${dbPassword}@postgres:5432/leadrouting`;
|
|
175
|
+
const redisPassword = generateSecret(16);
|
|
173
176
|
const managedRedis = !opts.externalRedis;
|
|
174
|
-
const redisUrl = opts.externalRedis ??
|
|
177
|
+
const redisUrl = opts.externalRedis ?? `redis://:${redisPassword}@redis:6379`;
|
|
175
178
|
note2("This creates the first admin user for the web app.", "Admin Account");
|
|
176
179
|
const adminEmail = await text2({
|
|
177
180
|
message: "Admin email address",
|
|
@@ -193,6 +196,7 @@ async function collectConfig(opts = {}) {
|
|
|
193
196
|
const sessionSecret = generateSecret(32);
|
|
194
197
|
const engineWebhookSecret = generateSecret(32);
|
|
195
198
|
const adminSecret = generateSecret(16);
|
|
199
|
+
const internalApiKey = generateSecret(32);
|
|
196
200
|
return {
|
|
197
201
|
appUrl: appUrl.trim().replace(/\/+$/, ""),
|
|
198
202
|
engineUrl: engineUrl.trim().replace(/\/+$/, ""),
|
|
@@ -204,13 +208,15 @@ async function collectConfig(opts = {}) {
|
|
|
204
208
|
dbPassword: managedDb ? dbPassword : "",
|
|
205
209
|
managedRedis,
|
|
206
210
|
redisUrl,
|
|
211
|
+
redisPassword: managedRedis ? redisPassword : "",
|
|
207
212
|
adminEmail,
|
|
208
213
|
adminPassword,
|
|
209
214
|
resendApiKey: "",
|
|
210
215
|
feedbackToEmail: "",
|
|
211
216
|
sessionSecret,
|
|
212
217
|
engineWebhookSecret,
|
|
213
|
-
adminSecret
|
|
218
|
+
adminSecret,
|
|
219
|
+
internalApiKey
|
|
214
220
|
};
|
|
215
221
|
}
|
|
216
222
|
|
|
@@ -243,14 +249,16 @@ function renderDockerCompose(c) {
|
|
|
243
249
|
timeout: 5s
|
|
244
250
|
retries: 10
|
|
245
251
|
` : "";
|
|
252
|
+
const redisPassword = c.redisPassword ?? "";
|
|
246
253
|
const redisService = c.managedRedis ? `
|
|
247
254
|
redis:
|
|
248
255
|
image: redis:7-alpine
|
|
249
|
-
restart: unless-stopped
|
|
256
|
+
restart: unless-stopped${redisPassword ? `
|
|
257
|
+
command: redis-server --requirepass ${redisPassword}` : ""}
|
|
250
258
|
volumes:
|
|
251
259
|
- redis_data:/data
|
|
252
260
|
healthcheck:
|
|
253
|
-
test: ["CMD", "redis-cli"
|
|
261
|
+
test: ["CMD-SHELL", "redis-cli${redisPassword ? ` -a ${redisPassword}` : ""} ping | grep PONG"]
|
|
254
262
|
interval: 5s
|
|
255
263
|
timeout: 3s
|
|
256
264
|
retries: 10
|
|
@@ -366,6 +374,9 @@ function renderEnvWeb(c) {
|
|
|
366
374
|
`ADMIN_EMAIL=${c.adminEmail}`,
|
|
367
375
|
`ADMIN_PASSWORD=${c.adminPassword}`,
|
|
368
376
|
``,
|
|
377
|
+
`# Internal API key (shared with engine for analytics)`,
|
|
378
|
+
`INTERNAL_API_KEY=${c.internalApiKey}`,
|
|
379
|
+
``,
|
|
369
380
|
`# Email (optional)`,
|
|
370
381
|
`RESEND_API_KEY=${c.resendApiKey ?? ""}`,
|
|
371
382
|
`FEEDBACK_TO_EMAIL=${c.feedbackToEmail ?? ""}`
|
|
@@ -395,7 +406,10 @@ function renderEnvEngine(c) {
|
|
|
395
406
|
`SFDC_LOGIN_URL=${c.sfdcLoginUrl}`,
|
|
396
407
|
``,
|
|
397
408
|
`# Webhook`,
|
|
398
|
-
`ENGINE_WEBHOOK_SECRET=${c.engineWebhookSecret}
|
|
409
|
+
`ENGINE_WEBHOOK_SECRET=${c.engineWebhookSecret}`,
|
|
410
|
+
``,
|
|
411
|
+
`# Internal API key (Bearer token for analytics endpoints)`,
|
|
412
|
+
`INTERNAL_API_KEY=${c.internalApiKey}`
|
|
399
413
|
].join("\n");
|
|
400
414
|
}
|
|
401
415
|
|
|
@@ -412,7 +426,18 @@ function renderCaddyfile(appUrl, engineUrl) {
|
|
|
412
426
|
`# Generated by lead-routing CLI`,
|
|
413
427
|
`# Caddy auto-provisions SSL certificates via Let's Encrypt`,
|
|
414
428
|
``,
|
|
429
|
+
`(security_headers) {`,
|
|
430
|
+
` header {`,
|
|
431
|
+
` X-Content-Type-Options nosniff`,
|
|
432
|
+
` X-Frame-Options DENY`,
|
|
433
|
+
` Referrer-Policy strict-origin-when-cross-origin`,
|
|
434
|
+
` Permissions-Policy interest-cohort=()`,
|
|
435
|
+
` Strict-Transport-Security "max-age=31536000; includeSubDomains"`,
|
|
436
|
+
` }`,
|
|
437
|
+
`}`,
|
|
438
|
+
``,
|
|
415
439
|
`${appHost} {`,
|
|
440
|
+
` import security_headers`,
|
|
416
441
|
` reverse_proxy web:3000 {`,
|
|
417
442
|
` health_uri /api/health`,
|
|
418
443
|
` health_interval 15s`,
|
|
@@ -420,6 +445,7 @@ function renderCaddyfile(appUrl, engineUrl) {
|
|
|
420
445
|
`}`,
|
|
421
446
|
``,
|
|
422
447
|
`${appHost}:${enginePort} {`,
|
|
448
|
+
` import security_headers`,
|
|
423
449
|
` reverse_proxy engine:3001 {`,
|
|
424
450
|
` health_uri /health`,
|
|
425
451
|
` health_interval 15s`,
|
|
@@ -432,7 +458,18 @@ function renderCaddyfile(appUrl, engineUrl) {
|
|
|
432
458
|
`# Generated by lead-routing CLI`,
|
|
433
459
|
`# Caddy auto-provisions SSL certificates via Let's Encrypt`,
|
|
434
460
|
``,
|
|
461
|
+
`(security_headers) {`,
|
|
462
|
+
` header {`,
|
|
463
|
+
` X-Content-Type-Options nosniff`,
|
|
464
|
+
` X-Frame-Options DENY`,
|
|
465
|
+
` Referrer-Policy strict-origin-when-cross-origin`,
|
|
466
|
+
` Permissions-Policy interest-cohort=()`,
|
|
467
|
+
` Strict-Transport-Security "max-age=31536000; includeSubDomains"`,
|
|
468
|
+
` }`,
|
|
469
|
+
`}`,
|
|
470
|
+
``,
|
|
435
471
|
`${appHost} {`,
|
|
472
|
+
` import security_headers`,
|
|
436
473
|
` reverse_proxy web:3000 {`,
|
|
437
474
|
` health_uri /api/health`,
|
|
438
475
|
` health_interval 15s`,
|
|
@@ -440,6 +477,7 @@ function renderCaddyfile(appUrl, engineUrl) {
|
|
|
440
477
|
`}`,
|
|
441
478
|
``,
|
|
442
479
|
`${engineHost} {`,
|
|
480
|
+
` import security_headers`,
|
|
443
481
|
` reverse_proxy engine:3001 {`,
|
|
444
482
|
` health_uri /health`,
|
|
445
483
|
` health_interval 15s`,
|
|
@@ -491,7 +529,8 @@ function generateFiles(cfg, sshCfg) {
|
|
|
491
529
|
const composeContent = renderDockerCompose({
|
|
492
530
|
managedDb: cfg.managedDb,
|
|
493
531
|
managedRedis: cfg.managedRedis,
|
|
494
|
-
dbPassword: cfg.dbPassword
|
|
532
|
+
dbPassword: cfg.dbPassword,
|
|
533
|
+
redisPassword: cfg.redisPassword
|
|
495
534
|
});
|
|
496
535
|
const composeFile = join2(dir, "docker-compose.yml");
|
|
497
536
|
writeFileSync2(composeFile, composeContent, "utf8");
|
|
@@ -513,6 +552,7 @@ function generateFiles(cfg, sshCfg) {
|
|
|
513
552
|
adminSecret: cfg.adminSecret,
|
|
514
553
|
adminEmail: cfg.adminEmail,
|
|
515
554
|
adminPassword: cfg.adminPassword,
|
|
555
|
+
internalApiKey: cfg.internalApiKey,
|
|
516
556
|
resendApiKey: cfg.resendApiKey || void 0,
|
|
517
557
|
feedbackToEmail: cfg.feedbackToEmail || void 0
|
|
518
558
|
});
|
|
@@ -525,7 +565,8 @@ function generateFiles(cfg, sshCfg) {
|
|
|
525
565
|
sfdcClientId: cfg.sfdcClientId,
|
|
526
566
|
sfdcClientSecret: cfg.sfdcClientSecret,
|
|
527
567
|
sfdcLoginUrl: cfg.sfdcLoginUrl,
|
|
528
|
-
engineWebhookSecret: cfg.engineWebhookSecret
|
|
568
|
+
engineWebhookSecret: cfg.engineWebhookSecret,
|
|
569
|
+
internalApiKey: cfg.internalApiKey
|
|
529
570
|
});
|
|
530
571
|
const envEngine = join2(dir, ".env.engine");
|
|
531
572
|
writeFileSync2(envEngine, envEngineContent, "utf8");
|
|
@@ -549,6 +590,7 @@ function generateFiles(cfg, sshCfg) {
|
|
|
549
590
|
// Stored so `lead-routing sfdc deploy` can re-authenticate without re-prompting
|
|
550
591
|
sfdcClientId: cfg.sfdcClientId,
|
|
551
592
|
sfdcLoginUrl: cfg.sfdcLoginUrl,
|
|
593
|
+
engineWebhookSecret: cfg.engineWebhookSecret,
|
|
552
594
|
installedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
553
595
|
version: getCliVersion()
|
|
554
596
|
});
|
|
@@ -893,516 +935,70 @@ function sleep2(ms) {
|
|
|
893
935
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
894
936
|
}
|
|
895
937
|
|
|
896
|
-
// src/
|
|
897
|
-
import
|
|
898
|
-
import {
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
this.accessToken = accessToken;
|
|
910
|
-
this.baseUrl = `${instanceUrl.replace(/\/+$/, "")}/services/data/${API_VERSION}`;
|
|
911
|
-
}
|
|
912
|
-
baseUrl;
|
|
913
|
-
headers(extra) {
|
|
914
|
-
return {
|
|
915
|
-
Authorization: `Bearer ${this.accessToken}`,
|
|
916
|
-
"Content-Type": "application/json",
|
|
917
|
-
...extra
|
|
938
|
+
// src/utils/ssh.ts
|
|
939
|
+
import net from "net";
|
|
940
|
+
import { NodeSSH } from "node-ssh";
|
|
941
|
+
var SshConnection = class {
|
|
942
|
+
ssh = new NodeSSH();
|
|
943
|
+
_connected = false;
|
|
944
|
+
async connect(config2) {
|
|
945
|
+
const opts = {
|
|
946
|
+
host: config2.host,
|
|
947
|
+
port: config2.port,
|
|
948
|
+
username: config2.username,
|
|
949
|
+
// Reduce connection timeout to fail fast on bad creds
|
|
950
|
+
readyTimeout: 15e3
|
|
918
951
|
};
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
const res = await fetch(url, { headers: this.headers() });
|
|
924
|
-
if (!res.ok) {
|
|
925
|
-
const body = await res.text();
|
|
926
|
-
throw new Error(`SOQL query failed (${res.status}): ${body}`);
|
|
927
|
-
}
|
|
928
|
-
const data = await res.json();
|
|
929
|
-
return data.records;
|
|
930
|
-
}
|
|
931
|
-
/** Create an sObject record, returns the new record ID */
|
|
932
|
-
async create(sobject, data) {
|
|
933
|
-
const url = `${this.baseUrl}/sobjects/${sobject}`;
|
|
934
|
-
const res = await fetch(url, {
|
|
935
|
-
method: "POST",
|
|
936
|
-
headers: this.headers(),
|
|
937
|
-
body: JSON.stringify(data)
|
|
938
|
-
});
|
|
939
|
-
if (!res.ok) {
|
|
940
|
-
const body = await res.text();
|
|
941
|
-
if (res.status === 400 && body.includes("Duplicate")) {
|
|
942
|
-
throw new DuplicateError(body);
|
|
943
|
-
}
|
|
944
|
-
throw new Error(`Create ${sobject} failed (${res.status}): ${body}`);
|
|
945
|
-
}
|
|
946
|
-
const result = await res.json();
|
|
947
|
-
return result.id;
|
|
948
|
-
}
|
|
949
|
-
/** Update an sObject record */
|
|
950
|
-
async update(sobject, id, data) {
|
|
951
|
-
const url = `${this.baseUrl}/sobjects/${sobject}/${id}`;
|
|
952
|
-
const res = await fetch(url, {
|
|
953
|
-
method: "PATCH",
|
|
954
|
-
headers: this.headers(),
|
|
955
|
-
body: JSON.stringify(data)
|
|
956
|
-
});
|
|
957
|
-
if (!res.ok) {
|
|
958
|
-
const body = await res.text();
|
|
959
|
-
throw new Error(`Update ${sobject}/${id} failed (${res.status}): ${body}`);
|
|
952
|
+
if (config2.privateKeyPath) {
|
|
953
|
+
opts.privateKeyPath = config2.privateKeyPath;
|
|
954
|
+
} else if (config2.password) {
|
|
955
|
+
opts.password = config2.password;
|
|
960
956
|
}
|
|
957
|
+
await this.ssh.connect(opts);
|
|
958
|
+
this._connected = true;
|
|
961
959
|
}
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
const url = `${this.instanceUrl.replace(/\/+$/, "")}/services/oauth2/userinfo`;
|
|
965
|
-
const res = await fetch(url, {
|
|
966
|
-
headers: { Authorization: `Bearer ${this.accessToken}` }
|
|
967
|
-
});
|
|
968
|
-
if (!res.ok) {
|
|
969
|
-
const body = await res.text();
|
|
970
|
-
throw new Error(`Get current user failed (${res.status}): ${body}`);
|
|
971
|
-
}
|
|
972
|
-
const data = await res.json();
|
|
973
|
-
return data.user_id;
|
|
960
|
+
get isConnected() {
|
|
961
|
+
return this._connected;
|
|
974
962
|
}
|
|
975
963
|
/**
|
|
976
|
-
*
|
|
977
|
-
* Accepts a ZIP buffer containing the source-format package.
|
|
978
|
-
* Returns the deploy request ID for polling.
|
|
964
|
+
* Run a command remotely. Throws if exit code is non-zero.
|
|
979
965
|
*/
|
|
980
|
-
async
|
|
981
|
-
const
|
|
982
|
-
|
|
983
|
-
const deployOptions = JSON.stringify({
|
|
984
|
-
deployOptions: {
|
|
985
|
-
rollbackOnError: true,
|
|
986
|
-
singlePackage: true,
|
|
987
|
-
rest: true
|
|
988
|
-
}
|
|
989
|
-
});
|
|
990
|
-
const parts = [];
|
|
991
|
-
parts.push(
|
|
992
|
-
Buffer.from(
|
|
993
|
-
`--${boundary}\r
|
|
994
|
-
Content-Disposition: form-data; name="json"\r
|
|
995
|
-
Content-Type: application/json\r
|
|
996
|
-
\r
|
|
997
|
-
${deployOptions}\r
|
|
998
|
-
`
|
|
999
|
-
)
|
|
1000
|
-
);
|
|
1001
|
-
parts.push(
|
|
1002
|
-
Buffer.from(
|
|
1003
|
-
`--${boundary}\r
|
|
1004
|
-
Content-Disposition: form-data; name="file"; filename="deploy.zip"\r
|
|
1005
|
-
Content-Type: application/zip\r
|
|
1006
|
-
\r
|
|
1007
|
-
`
|
|
1008
|
-
)
|
|
1009
|
-
);
|
|
1010
|
-
parts.push(zipBuffer);
|
|
1011
|
-
parts.push(Buffer.from(`\r
|
|
1012
|
-
--${boundary}--\r
|
|
1013
|
-
`));
|
|
1014
|
-
const body = Buffer.concat(parts);
|
|
1015
|
-
const res = await fetch(url, {
|
|
1016
|
-
method: "POST",
|
|
1017
|
-
headers: {
|
|
1018
|
-
Authorization: `Bearer ${this.accessToken}`,
|
|
1019
|
-
"Content-Type": `multipart/form-data; boundary=${boundary}`
|
|
1020
|
-
},
|
|
1021
|
-
body
|
|
1022
|
-
});
|
|
1023
|
-
if (!res.ok) {
|
|
1024
|
-
const text5 = await res.text();
|
|
966
|
+
async exec(cmd, cwd) {
|
|
967
|
+
const result = await this.ssh.execCommand(cmd, cwd ? { cwd } : {});
|
|
968
|
+
if (result.code !== 0) {
|
|
1025
969
|
throw new Error(
|
|
1026
|
-
`
|
|
970
|
+
`Remote command failed (exit ${result.code}): ${cmd}
|
|
971
|
+
${result.stderr || result.stdout}`
|
|
1027
972
|
);
|
|
1028
973
|
}
|
|
1029
|
-
|
|
1030
|
-
return result.id;
|
|
974
|
+
return { stdout: result.stdout, stderr: result.stderr };
|
|
1031
975
|
}
|
|
1032
976
|
/**
|
|
1033
|
-
*
|
|
1034
|
-
* Returns deploy result with success/failure info.
|
|
977
|
+
* Run a command remotely without throwing — returns code + output.
|
|
1035
978
|
*/
|
|
1036
|
-
async
|
|
1037
|
-
const
|
|
1038
|
-
|
|
1039
|
-
while (Date.now() - startTime < timeoutMs) {
|
|
1040
|
-
const url = `${this.baseUrl}/metadata/deployRequest/${deployId}?includeDetails=true`;
|
|
1041
|
-
const res = await fetch(url, { headers: this.headers() });
|
|
1042
|
-
if (!res.ok) {
|
|
1043
|
-
const text5 = await res.text();
|
|
1044
|
-
throw new Error(
|
|
1045
|
-
`Deploy status check failed (${res.status}): ${text5}`
|
|
1046
|
-
);
|
|
1047
|
-
}
|
|
1048
|
-
const data = await res.json();
|
|
1049
|
-
const result = data.deployResult;
|
|
1050
|
-
if (result.done) {
|
|
1051
|
-
return result;
|
|
1052
|
-
}
|
|
1053
|
-
await new Promise((resolve) => setTimeout(resolve, pollInterval));
|
|
1054
|
-
}
|
|
1055
|
-
throw new Error(`Deploy timed out after ${timeoutMs / 1e3}s`);
|
|
979
|
+
async execSilent(cmd, cwd) {
|
|
980
|
+
const result = await this.ssh.execCommand(cmd, cwd ? { cwd } : {});
|
|
981
|
+
return { stdout: result.stdout, stderr: result.stderr, code: result.code ?? 1 };
|
|
1056
982
|
}
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
this.
|
|
983
|
+
/**
|
|
984
|
+
* Create a remote directory (including parents).
|
|
985
|
+
*/
|
|
986
|
+
async mkdir(remotePath) {
|
|
987
|
+
await this.exec(`mkdir -p ${remotePath}`);
|
|
1062
988
|
}
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
async function zipSourcePackage(packageDir) {
|
|
1069
|
-
return new Promise((resolve, reject) => {
|
|
1070
|
-
const chunks = [];
|
|
1071
|
-
const archive = archiver("zip", { zlib: { level: 9 } });
|
|
1072
|
-
archive.on("data", (chunk) => chunks.push(chunk));
|
|
1073
|
-
archive.on("end", () => resolve(Buffer.concat(chunks)));
|
|
1074
|
-
archive.on("error", reject);
|
|
1075
|
-
archive.directory(join4(packageDir, "force-app"), "force-app");
|
|
1076
|
-
archive.file(join4(packageDir, "sfdx-project.json"), {
|
|
1077
|
-
name: "sfdx-project.json"
|
|
1078
|
-
});
|
|
1079
|
-
archive.finalize();
|
|
1080
|
-
});
|
|
1081
|
-
}
|
|
1082
|
-
|
|
1083
|
-
// src/steps/sfdc-deploy-inline.ts
|
|
1084
|
-
var __dirname2 = dirname2(fileURLToPath2(import.meta.url));
|
|
1085
|
-
function patchXml(content, tag, value) {
|
|
1086
|
-
const re = new RegExp(`(<${tag}>)[^<]*(</\\s*${tag}>)`, "g");
|
|
1087
|
-
return content.replace(re, `$1${value}$2`);
|
|
1088
|
-
}
|
|
1089
|
-
async function sfdcDeployInline(params) {
|
|
1090
|
-
const { appUrl, engineUrl, installDir } = params;
|
|
1091
|
-
const s = spinner5();
|
|
1092
|
-
const { accessToken, instanceUrl } = await loginViaAppBridge(appUrl);
|
|
1093
|
-
const sf = new SalesforceApi(instanceUrl, accessToken);
|
|
1094
|
-
s.start("Copying Salesforce package\u2026");
|
|
1095
|
-
const inDist = join5(__dirname2, "sfdc-package");
|
|
1096
|
-
const nextToDist = join5(__dirname2, "..", "sfdc-package");
|
|
1097
|
-
const bundledPkg = existsSync3(inDist) ? inDist : nextToDist;
|
|
1098
|
-
const destPkg = join5(installDir ?? tmpdir(), "lead-routing-sfdc-package");
|
|
1099
|
-
if (!existsSync3(bundledPkg)) {
|
|
1100
|
-
s.stop("sfdc-package not found in CLI bundle");
|
|
1101
|
-
throw new Error(
|
|
1102
|
-
`Expected bundle at: ${inDist}
|
|
1103
|
-
The CLI may need to be reinstalled: npm i -g @lead-routing/cli`
|
|
1104
|
-
);
|
|
989
|
+
/**
|
|
990
|
+
* Upload local files to the remote server via SFTP.
|
|
991
|
+
*/
|
|
992
|
+
async upload(files) {
|
|
993
|
+
await this.ssh.putFiles(files);
|
|
1105
994
|
}
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
"
|
|
1112
|
-
|
|
1113
|
-
"default",
|
|
1114
|
-
"namedCredentials",
|
|
1115
|
-
"RoutingEngine.namedCredential-meta.xml"
|
|
1116
|
-
);
|
|
1117
|
-
if (existsSync3(ncPath)) {
|
|
1118
|
-
const nc = patchXml(readFileSync3(ncPath, "utf8"), "endpoint", engineUrl);
|
|
1119
|
-
writeFileSync3(ncPath, nc, "utf8");
|
|
1120
|
-
}
|
|
1121
|
-
const rssEnginePath = join5(
|
|
1122
|
-
destPkg,
|
|
1123
|
-
"force-app",
|
|
1124
|
-
"main",
|
|
1125
|
-
"default",
|
|
1126
|
-
"remoteSiteSettings",
|
|
1127
|
-
"LeadRouterEngine.remoteSite-meta.xml"
|
|
1128
|
-
);
|
|
1129
|
-
if (existsSync3(rssEnginePath)) {
|
|
1130
|
-
let rss = patchXml(readFileSync3(rssEnginePath, "utf8"), "url", engineUrl);
|
|
1131
|
-
rss = patchXml(rss, "description", "Lead Router Engine endpoint");
|
|
1132
|
-
writeFileSync3(rssEnginePath, rss, "utf8");
|
|
1133
|
-
}
|
|
1134
|
-
const rssAppPath = join5(
|
|
1135
|
-
destPkg,
|
|
1136
|
-
"force-app",
|
|
1137
|
-
"main",
|
|
1138
|
-
"default",
|
|
1139
|
-
"remoteSiteSettings",
|
|
1140
|
-
"LeadRouterApp.remoteSite-meta.xml"
|
|
1141
|
-
);
|
|
1142
|
-
if (existsSync3(rssAppPath)) {
|
|
1143
|
-
let rss = patchXml(readFileSync3(rssAppPath, "utf8"), "url", appUrl);
|
|
1144
|
-
rss = patchXml(rss, "description", "Lead Router App URL");
|
|
1145
|
-
writeFileSync3(rssAppPath, rss, "utf8");
|
|
1146
|
-
}
|
|
1147
|
-
log7.success("Remote Site Settings patched");
|
|
1148
|
-
s.start("Deploying Salesforce package (this may take ~2 min)\u2026");
|
|
1149
|
-
try {
|
|
1150
|
-
const zipBuffer = await zipSourcePackage(destPkg);
|
|
1151
|
-
const deployId = await sf.deployMetadata(zipBuffer);
|
|
1152
|
-
const result = await sf.waitForDeploy(deployId);
|
|
1153
|
-
if (!result.success) {
|
|
1154
|
-
const failures = result.details?.componentFailures ?? [];
|
|
1155
|
-
const failureMsg = failures.map((f) => ` ${f.componentType}/${f.fullName}: ${f.problem}`).join("\n");
|
|
1156
|
-
s.stop("Deployment failed");
|
|
1157
|
-
throw new Error(
|
|
1158
|
-
`Metadata deploy failed (${result.numberComponentErrors} error(s)):
|
|
1159
|
-
${failureMsg || result.errorMessage || "Unknown error"}`
|
|
1160
|
-
);
|
|
1161
|
-
}
|
|
1162
|
-
s.stop(`Package deployed (${result.numberComponentsDeployed} components)`);
|
|
1163
|
-
} catch (err) {
|
|
1164
|
-
if (err instanceof Error && err.message.startsWith("Metadata deploy failed")) {
|
|
1165
|
-
throw err;
|
|
1166
|
-
}
|
|
1167
|
-
s.stop("Deployment failed");
|
|
1168
|
-
throw new Error(
|
|
1169
|
-
`Metadata deploy failed: ${String(err)}
|
|
1170
|
-
|
|
1171
|
-
The patched package is at: ${destPkg}
|
|
1172
|
-
You can retry with: sf project deploy start --source-dir force-app`
|
|
1173
|
-
);
|
|
1174
|
-
}
|
|
1175
|
-
s.start("Assigning LeadRouterAdmin permission set\u2026");
|
|
1176
|
-
try {
|
|
1177
|
-
const permSets = await sf.query(
|
|
1178
|
-
"SELECT Id FROM PermissionSet WHERE Name = 'LeadRouterAdmin' LIMIT 1"
|
|
1179
|
-
);
|
|
1180
|
-
if (permSets.length === 0) {
|
|
1181
|
-
s.stop("LeadRouterAdmin permission set not found (non-fatal)");
|
|
1182
|
-
log7.warn("The permission set may not have been included in the deploy.");
|
|
1183
|
-
} else {
|
|
1184
|
-
const userId = await sf.getCurrentUserId();
|
|
1185
|
-
try {
|
|
1186
|
-
await sf.create("PermissionSetAssignment", {
|
|
1187
|
-
AssigneeId: userId,
|
|
1188
|
-
PermissionSetId: permSets[0].Id
|
|
1189
|
-
});
|
|
1190
|
-
s.stop("Permission set assigned \u2014 Lead Router Setup will appear in the App Launcher");
|
|
1191
|
-
} catch (err) {
|
|
1192
|
-
if (err instanceof DuplicateError) {
|
|
1193
|
-
s.stop("Permission set already assigned");
|
|
1194
|
-
} else {
|
|
1195
|
-
throw err;
|
|
1196
|
-
}
|
|
1197
|
-
}
|
|
1198
|
-
}
|
|
1199
|
-
} catch (err) {
|
|
1200
|
-
if (!(err instanceof DuplicateError)) {
|
|
1201
|
-
s.stop("Permission set assignment failed (non-fatal)");
|
|
1202
|
-
log7.warn(String(err));
|
|
1203
|
-
log7.info(
|
|
1204
|
-
"Grant access manually:\n Salesforce Setup \u2192 Users \u2192 Permission Sets \u2192 Lead Router Admin \u2192 Manage Assignments"
|
|
1205
|
-
);
|
|
1206
|
-
}
|
|
1207
|
-
}
|
|
1208
|
-
s.start("Writing org settings to Routing_Settings__c\u2026");
|
|
1209
|
-
try {
|
|
1210
|
-
const existing = await sf.query(
|
|
1211
|
-
"SELECT Id FROM Routing_Settings__c LIMIT 1"
|
|
1212
|
-
);
|
|
1213
|
-
if (existing.length > 0) {
|
|
1214
|
-
await sf.update("Routing_Settings__c", existing[0].Id, {
|
|
1215
|
-
App_Url__c: appUrl,
|
|
1216
|
-
Engine_Endpoint__c: engineUrl
|
|
1217
|
-
});
|
|
1218
|
-
} else {
|
|
1219
|
-
await sf.create("Routing_Settings__c", {
|
|
1220
|
-
App_Url__c: appUrl,
|
|
1221
|
-
Engine_Endpoint__c: engineUrl
|
|
1222
|
-
});
|
|
1223
|
-
}
|
|
1224
|
-
s.stop("Org settings written");
|
|
1225
|
-
} catch (err) {
|
|
1226
|
-
s.stop("Org settings write failed (non-fatal)");
|
|
1227
|
-
log7.warn(String(err));
|
|
1228
|
-
log7.info("Set manually: Salesforce \u2192 Custom Settings \u2192 Routing Settings \u2192 Manage");
|
|
1229
|
-
}
|
|
1230
|
-
}
|
|
1231
|
-
async function loginViaAppBridge(rawAppUrl) {
|
|
1232
|
-
const appUrl = rawAppUrl.replace(/\/+$/, "");
|
|
1233
|
-
const s = spinner5();
|
|
1234
|
-
s.start("Starting Salesforce authentication via your Lead Router app\u2026");
|
|
1235
|
-
let sessionId;
|
|
1236
|
-
let authUrl;
|
|
1237
|
-
try {
|
|
1238
|
-
const res = await fetch(`${appUrl}/api/cli-auth/request`, { method: "POST" });
|
|
1239
|
-
if (!res.ok) {
|
|
1240
|
-
s.stop("Failed to start auth session");
|
|
1241
|
-
throw new Error(
|
|
1242
|
-
`Could not reach ${appUrl}/api/cli-auth/request (HTTP ${res.status}).
|
|
1243
|
-
Make sure the web app is running and accessible.`
|
|
1244
|
-
);
|
|
1245
|
-
}
|
|
1246
|
-
const data = await res.json();
|
|
1247
|
-
sessionId = data.sessionId;
|
|
1248
|
-
authUrl = data.authUrl;
|
|
1249
|
-
} catch (err) {
|
|
1250
|
-
s.stop("Could not reach Lead Router app");
|
|
1251
|
-
throw new Error(
|
|
1252
|
-
`Failed to connect to ${appUrl}: ${String(err)}
|
|
1253
|
-
Ensure the app is running and the URL is correct.`
|
|
1254
|
-
);
|
|
1255
|
-
}
|
|
1256
|
-
s.stop("Auth session started");
|
|
1257
|
-
log7.info(`Open this URL in your browser to authenticate with Salesforce:
|
|
1258
|
-
|
|
1259
|
-
${authUrl}
|
|
1260
|
-
`);
|
|
1261
|
-
log7.info('If Chrome shows a "Dangerous site" warning with no proceed option, paste the URL into Safari or Firefox.');
|
|
1262
|
-
const opener = process.platform === "win32" ? "start" : process.platform === "darwin" ? "open" : "xdg-open";
|
|
1263
|
-
try {
|
|
1264
|
-
execSync(`${opener} "${authUrl}"`, { stdio: "ignore" });
|
|
1265
|
-
} catch {
|
|
1266
|
-
}
|
|
1267
|
-
s.start("Waiting for Salesforce authentication in browser\u2026");
|
|
1268
|
-
const maxPolls = 150;
|
|
1269
|
-
let accessToken;
|
|
1270
|
-
let instanceUrl;
|
|
1271
|
-
for (let i = 0; i < maxPolls; i++) {
|
|
1272
|
-
await new Promise((r) => setTimeout(r, 2e3));
|
|
1273
|
-
try {
|
|
1274
|
-
const pollRes = await fetch(`${appUrl}/api/cli-auth/poll/${sessionId}`);
|
|
1275
|
-
if (pollRes.status === 410) {
|
|
1276
|
-
s.stop("Auth session expired");
|
|
1277
|
-
throw new Error("CLI auth session expired. Please re-run the command.");
|
|
1278
|
-
}
|
|
1279
|
-
const data = await pollRes.json();
|
|
1280
|
-
if (data.status === "ok") {
|
|
1281
|
-
accessToken = data.accessToken;
|
|
1282
|
-
instanceUrl = data.instanceUrl;
|
|
1283
|
-
break;
|
|
1284
|
-
}
|
|
1285
|
-
} catch (err) {
|
|
1286
|
-
if (String(err).includes("session expired")) throw err;
|
|
1287
|
-
}
|
|
1288
|
-
}
|
|
1289
|
-
if (!accessToken || !instanceUrl) {
|
|
1290
|
-
s.stop("Timed out");
|
|
1291
|
-
throw new Error(
|
|
1292
|
-
"Timed out waiting for Salesforce authentication (5 minutes).\nPlease re-run the command and complete login within 5 minutes."
|
|
1293
|
-
);
|
|
1294
|
-
}
|
|
1295
|
-
s.stop("Authenticated with Salesforce");
|
|
1296
|
-
return { accessToken, instanceUrl };
|
|
1297
|
-
}
|
|
1298
|
-
|
|
1299
|
-
// src/steps/app-launcher-guide.ts
|
|
1300
|
-
import { note as note3, confirm, isCancel as isCancel3, log as log8 } from "@clack/prompts";
|
|
1301
|
-
import chalk from "chalk";
|
|
1302
|
-
async function guideAppLauncherSetup(appUrl) {
|
|
1303
|
-
note3(
|
|
1304
|
-
`Complete the following steps in Salesforce now:
|
|
1305
|
-
|
|
1306
|
-
${chalk.cyan("1.")} Open ${chalk.bold("App Launcher")} (grid icon, top-left in Salesforce)
|
|
1307
|
-
${chalk.cyan("2.")} Search for ${chalk.white('"Lead Router Setup"')} and click it
|
|
1308
|
-
${chalk.cyan("3.")} Click ${chalk.white('"Connect to Lead Router"')}
|
|
1309
|
-
\u2192 You will be redirected to ${chalk.dim(appUrl)} and back
|
|
1310
|
-
\u2192 Authorize the OAuth connection when prompted
|
|
1311
|
-
|
|
1312
|
-
${chalk.cyan("4.")} ${chalk.bold("Step 1")} \u2014 wait for the ${chalk.green('"Connected"')} checkmark (~5 sec)
|
|
1313
|
-
${chalk.cyan("5.")} ${chalk.bold("Step 2")} \u2014 click ${chalk.white("Activate")} to enable Lead triggers
|
|
1314
|
-
${chalk.cyan("6.")} ${chalk.bold("Step 3")} \u2014 click ${chalk.white("Sync Fields")} to index your Lead field schema
|
|
1315
|
-
${chalk.cyan("7.")} ${chalk.bold("Step 4")} \u2014 click ${chalk.white("Send Test")} to fire a test routing event
|
|
1316
|
-
\u2192 ${chalk.dim('"Test successful"')} or ${chalk.dim('"No matching rule"')} are both valid
|
|
1317
|
-
|
|
1318
|
-
` + chalk.dim("Keep this terminal open while you complete the wizard."),
|
|
1319
|
-
"Complete Salesforce setup"
|
|
1320
|
-
);
|
|
1321
|
-
const done = await confirm({
|
|
1322
|
-
message: "Have you completed the App Launcher wizard?",
|
|
1323
|
-
initialValue: false
|
|
1324
|
-
});
|
|
1325
|
-
if (isCancel3(done)) {
|
|
1326
|
-
log8.warn(
|
|
1327
|
-
"Wizard skipped. Run `lead-routing sfdc deploy` to retry the Salesforce setup."
|
|
1328
|
-
);
|
|
1329
|
-
return;
|
|
1330
|
-
}
|
|
1331
|
-
if (!done) {
|
|
1332
|
-
log8.warn(
|
|
1333
|
-
`No problem \u2014 complete it at your own pace.
|
|
1334
|
-
Open App Launcher \u2192 Lead Router Setup \u2192 Connect to Lead Router
|
|
1335
|
-
Dashboard: ${appUrl}`
|
|
1336
|
-
);
|
|
1337
|
-
} else {
|
|
1338
|
-
log8.success("Salesforce setup complete");
|
|
1339
|
-
}
|
|
1340
|
-
}
|
|
1341
|
-
|
|
1342
|
-
// src/utils/ssh.ts
|
|
1343
|
-
import net from "net";
|
|
1344
|
-
import { NodeSSH } from "node-ssh";
|
|
1345
|
-
var SshConnection = class {
|
|
1346
|
-
ssh = new NodeSSH();
|
|
1347
|
-
_connected = false;
|
|
1348
|
-
async connect(config2) {
|
|
1349
|
-
const opts = {
|
|
1350
|
-
host: config2.host,
|
|
1351
|
-
port: config2.port,
|
|
1352
|
-
username: config2.username,
|
|
1353
|
-
// Reduce connection timeout to fail fast on bad creds
|
|
1354
|
-
readyTimeout: 15e3
|
|
1355
|
-
};
|
|
1356
|
-
if (config2.privateKeyPath) {
|
|
1357
|
-
opts.privateKeyPath = config2.privateKeyPath;
|
|
1358
|
-
} else if (config2.password) {
|
|
1359
|
-
opts.password = config2.password;
|
|
1360
|
-
}
|
|
1361
|
-
await this.ssh.connect(opts);
|
|
1362
|
-
this._connected = true;
|
|
1363
|
-
}
|
|
1364
|
-
get isConnected() {
|
|
1365
|
-
return this._connected;
|
|
1366
|
-
}
|
|
1367
|
-
/**
|
|
1368
|
-
* Run a command remotely. Throws if exit code is non-zero.
|
|
1369
|
-
*/
|
|
1370
|
-
async exec(cmd, cwd) {
|
|
1371
|
-
const result = await this.ssh.execCommand(cmd, cwd ? { cwd } : {});
|
|
1372
|
-
if (result.code !== 0) {
|
|
1373
|
-
throw new Error(
|
|
1374
|
-
`Remote command failed (exit ${result.code}): ${cmd}
|
|
1375
|
-
${result.stderr || result.stdout}`
|
|
1376
|
-
);
|
|
1377
|
-
}
|
|
1378
|
-
return { stdout: result.stdout, stderr: result.stderr };
|
|
1379
|
-
}
|
|
1380
|
-
/**
|
|
1381
|
-
* Run a command remotely without throwing — returns code + output.
|
|
1382
|
-
*/
|
|
1383
|
-
async execSilent(cmd, cwd) {
|
|
1384
|
-
const result = await this.ssh.execCommand(cmd, cwd ? { cwd } : {});
|
|
1385
|
-
return { stdout: result.stdout, stderr: result.stderr, code: result.code ?? 1 };
|
|
1386
|
-
}
|
|
1387
|
-
/**
|
|
1388
|
-
* Create a remote directory (including parents).
|
|
1389
|
-
*/
|
|
1390
|
-
async mkdir(remotePath) {
|
|
1391
|
-
await this.exec(`mkdir -p ${remotePath}`);
|
|
1392
|
-
}
|
|
1393
|
-
/**
|
|
1394
|
-
* Upload local files to the remote server via SFTP.
|
|
1395
|
-
*/
|
|
1396
|
-
async upload(files) {
|
|
1397
|
-
await this.ssh.putFiles(files);
|
|
1398
|
-
}
|
|
1399
|
-
/**
|
|
1400
|
-
* Resolve ~ in a remote path by querying $HOME on the server.
|
|
1401
|
-
*/
|
|
1402
|
-
async resolveHome(remotePath) {
|
|
1403
|
-
if (!remotePath.startsWith("~")) return remotePath;
|
|
1404
|
-
const { stdout } = await this.exec("echo $HOME");
|
|
1405
|
-
return stdout.trim() + remotePath.slice(1);
|
|
995
|
+
/**
|
|
996
|
+
* Resolve ~ in a remote path by querying $HOME on the server.
|
|
997
|
+
*/
|
|
998
|
+
async resolveHome(remotePath) {
|
|
999
|
+
if (!remotePath.startsWith("~")) return remotePath;
|
|
1000
|
+
const { stdout } = await this.exec("echo $HOME");
|
|
1001
|
+
return stdout.trim() + remotePath.slice(1);
|
|
1406
1002
|
}
|
|
1407
1003
|
/**
|
|
1408
1004
|
* Open an SSH port-forward tunnel.
|
|
@@ -1465,12 +1061,12 @@ async function checkDnsResolvable(appUrl, engineUrl) {
|
|
|
1465
1061
|
try {
|
|
1466
1062
|
await dns.lookup(host);
|
|
1467
1063
|
} catch {
|
|
1468
|
-
|
|
1469
|
-
`${
|
|
1470
|
-
Check for typos \u2014 a bad domain will cause a 2-minute timeout at step
|
|
1064
|
+
log7.warn(
|
|
1065
|
+
`${chalk.yellow(host)} does not resolve in DNS yet.
|
|
1066
|
+
Check for typos \u2014 a bad domain will cause a 2-minute timeout at step 7.`
|
|
1471
1067
|
);
|
|
1472
|
-
const go = await
|
|
1473
|
-
if (
|
|
1068
|
+
const go = await confirm({ message: "Continue anyway?", initialValue: true });
|
|
1069
|
+
if (isCancel3(go) || !go) {
|
|
1474
1070
|
cancel3("Setup cancelled.");
|
|
1475
1071
|
process.exit(0);
|
|
1476
1072
|
}
|
|
@@ -1482,14 +1078,14 @@ async function runInit(options = {}) {
|
|
|
1482
1078
|
const resume = options.resume ?? false;
|
|
1483
1079
|
console.log();
|
|
1484
1080
|
intro(
|
|
1485
|
-
|
|
1081
|
+
chalk.bold.cyan("Lead Routing \u2014 Self-Hosted Setup") + (dryRun ? chalk.yellow(" [dry run]") : "") + (resume ? chalk.yellow(" [resume]") : "")
|
|
1486
1082
|
);
|
|
1487
1083
|
const ssh = new SshConnection();
|
|
1488
1084
|
if (resume) {
|
|
1489
1085
|
try {
|
|
1490
1086
|
const dir = findInstallDir();
|
|
1491
1087
|
if (!dir) {
|
|
1492
|
-
|
|
1088
|
+
log7.error("No lead-routing.json found \u2014 run `lead-routing init` first.");
|
|
1493
1089
|
process.exit(1);
|
|
1494
1090
|
}
|
|
1495
1091
|
const saved = readConfig(dir);
|
|
@@ -1501,7 +1097,7 @@ async function runInit(options = {}) {
|
|
|
1501
1097
|
if (typeof pw === "symbol") process.exit(0);
|
|
1502
1098
|
sshPassword = pw;
|
|
1503
1099
|
}
|
|
1504
|
-
|
|
1100
|
+
log7.step("Connecting to server");
|
|
1505
1101
|
await ssh.connect({
|
|
1506
1102
|
host: saved.ssh.host,
|
|
1507
1103
|
port: saved.ssh.port,
|
|
@@ -1510,35 +1106,31 @@ async function runInit(options = {}) {
|
|
|
1510
1106
|
password: sshPassword,
|
|
1511
1107
|
remoteDir: saved.remoteDir
|
|
1512
1108
|
});
|
|
1513
|
-
|
|
1109
|
+
log7.success(`Connected to ${saved.ssh.host}`);
|
|
1514
1110
|
const remoteDir = await ssh.resolveHome(saved.remoteDir);
|
|
1515
|
-
|
|
1111
|
+
log7.step("Step 7/7 Verifying health");
|
|
1516
1112
|
await verifyHealth(saved.appUrl, saved.engineUrl, ssh, remoteDir);
|
|
1517
|
-
|
|
1518
|
-
|
|
1519
|
-
|
|
1520
|
-
|
|
1521
|
-
orgAlias: "lead-routing",
|
|
1522
|
-
sfdcClientId: saved.sfdcClientId ?? "",
|
|
1523
|
-
sfdcLoginUrl: saved.sfdcLoginUrl ?? "https://login.salesforce.com",
|
|
1524
|
-
installDir: dir
|
|
1525
|
-
});
|
|
1526
|
-
await guideAppLauncherSetup(saved.appUrl);
|
|
1113
|
+
note3(
|
|
1114
|
+
`Open ${saved.appUrl} \u2192 Integrations \u2192 Salesforce to connect your CRM and deploy the package.`,
|
|
1115
|
+
"Next: Connect Salesforce"
|
|
1116
|
+
);
|
|
1527
1117
|
outro(
|
|
1528
|
-
|
|
1118
|
+
chalk.green("\u2714 You're live!") + `
|
|
1529
1119
|
|
|
1530
|
-
Dashboard: ${
|
|
1531
|
-
Routing engine: ${
|
|
1120
|
+
Dashboard: ${chalk.cyan(saved.appUrl)}
|
|
1121
|
+
Routing engine: ${chalk.cyan(saved.engineUrl)}
|
|
1532
1122
|
|
|
1533
|
-
` +
|
|
1534
|
-
${
|
|
1123
|
+
` + chalk.bold(" Next steps:\n") + ` ${chalk.cyan("1.")} Open ${chalk.cyan(saved.appUrl)} and log in
|
|
1124
|
+
${chalk.cyan("2.")} Go to Integrations \u2192 Salesforce to connect your org
|
|
1125
|
+
${chalk.cyan("3.")} Deploy the package and configure routing objects
|
|
1126
|
+
${chalk.cyan("4.")} Create your first routing rule
|
|
1535
1127
|
|
|
1536
|
-
Run ${
|
|
1537
|
-
Run ${
|
|
1128
|
+
Run ${chalk.cyan("lead-routing doctor")} to check service health at any time.
|
|
1129
|
+
Run ${chalk.cyan("lead-routing deploy")} to update to a new version.`
|
|
1538
1130
|
);
|
|
1539
1131
|
} catch (err) {
|
|
1540
1132
|
const message = err instanceof Error ? err.message : String(err);
|
|
1541
|
-
|
|
1133
|
+
log7.error(`Resume failed: ${message}`);
|
|
1542
1134
|
process.exit(1);
|
|
1543
1135
|
} finally {
|
|
1544
1136
|
await ssh.disconnect();
|
|
@@ -1546,9 +1138,9 @@ async function runInit(options = {}) {
|
|
|
1546
1138
|
return;
|
|
1547
1139
|
}
|
|
1548
1140
|
try {
|
|
1549
|
-
|
|
1141
|
+
log7.step("Step 1/7 Checking local prerequisites");
|
|
1550
1142
|
await checkPrerequisites();
|
|
1551
|
-
|
|
1143
|
+
log7.step("Step 2/7 SSH connection");
|
|
1552
1144
|
const sshCfg = await collectSshConfig({
|
|
1553
1145
|
sshPort: options.sshPort,
|
|
1554
1146
|
sshUser: options.sshUser,
|
|
@@ -1558,74 +1150,78 @@ async function runInit(options = {}) {
|
|
|
1558
1150
|
if (!dryRun) {
|
|
1559
1151
|
try {
|
|
1560
1152
|
await ssh.connect(sshCfg);
|
|
1561
|
-
|
|
1153
|
+
log7.success(`Connected to ${sshCfg.host}`);
|
|
1562
1154
|
} catch (err) {
|
|
1563
|
-
|
|
1564
|
-
|
|
1155
|
+
log7.error(`SSH connection failed: ${String(err)}`);
|
|
1156
|
+
log7.info("Check your password and re-run `lead-routing init`.");
|
|
1565
1157
|
process.exit(1);
|
|
1566
1158
|
}
|
|
1567
1159
|
}
|
|
1568
|
-
|
|
1160
|
+
log7.step("Step 3/7 Configuration");
|
|
1569
1161
|
const cfg = await collectConfig({
|
|
1570
1162
|
sandbox: options.sandbox,
|
|
1571
1163
|
externalDb: options.externalDb,
|
|
1572
1164
|
externalRedis: options.externalRedis
|
|
1573
1165
|
});
|
|
1574
1166
|
await checkDnsResolvable(cfg.appUrl, cfg.engineUrl);
|
|
1575
|
-
|
|
1167
|
+
log7.step("Step 4/7 Generating config files");
|
|
1576
1168
|
const { dir, adminSecret } = generateFiles(cfg, sshCfg);
|
|
1577
|
-
|
|
1578
|
-
`Local config directory: ${
|
|
1169
|
+
note3(
|
|
1170
|
+
`Local config directory: ${chalk.cyan(dir)}
|
|
1579
1171
|
Files created: docker-compose.yml, Caddyfile, .env.web, .env.engine, lead-routing.json`,
|
|
1580
1172
|
"Files"
|
|
1581
1173
|
);
|
|
1582
1174
|
if (dryRun) {
|
|
1583
1175
|
outro(
|
|
1584
|
-
|
|
1176
|
+
chalk.yellow("Dry run complete \u2014 no connection made, no services started.") + `
|
|
1585
1177
|
|
|
1586
|
-
Config files written to: ${
|
|
1178
|
+
Config files written to: ${chalk.cyan(dir)}
|
|
1587
1179
|
|
|
1588
|
-
When ready, run ${
|
|
1180
|
+
When ready, run ${chalk.cyan("lead-routing init")} (without --dry-run) to deploy.`
|
|
1589
1181
|
);
|
|
1590
1182
|
return;
|
|
1591
1183
|
}
|
|
1592
|
-
|
|
1184
|
+
log7.step("Step 5/7 Remote setup");
|
|
1593
1185
|
const remoteDir = await ssh.resolveHome(sshCfg.remoteDir);
|
|
1594
1186
|
await checkRemotePrerequisites(ssh);
|
|
1595
1187
|
await uploadFiles(ssh, dir, remoteDir);
|
|
1596
|
-
|
|
1188
|
+
log7.step("Step 6/7 Starting services");
|
|
1597
1189
|
await startServices(ssh, remoteDir);
|
|
1598
|
-
|
|
1190
|
+
log7.step("Step 7/7 Verifying health");
|
|
1599
1191
|
await verifyHealth(cfg.appUrl, cfg.engineUrl, ssh, remoteDir);
|
|
1600
|
-
|
|
1601
|
-
|
|
1602
|
-
|
|
1603
|
-
|
|
1604
|
-
|
|
1605
|
-
|
|
1606
|
-
|
|
1607
|
-
|
|
1608
|
-
|
|
1609
|
-
|
|
1192
|
+
try {
|
|
1193
|
+
const envWebPath = join4(dir, ".env.web");
|
|
1194
|
+
const envContent = readFileSync3(envWebPath, "utf-8");
|
|
1195
|
+
const cleaned = envContent.split("\n").filter((line) => !line.startsWith("ADMIN_PASSWORD=")).join("\n");
|
|
1196
|
+
writeFileSync3(envWebPath, cleaned, "utf-8");
|
|
1197
|
+
log7.success("Removed ADMIN_PASSWORD from .env.web (no longer needed after seed)");
|
|
1198
|
+
} catch {
|
|
1199
|
+
}
|
|
1200
|
+
note3(
|
|
1201
|
+
`Open ${cfg.appUrl} \u2192 Integrations \u2192 Salesforce to connect your CRM and deploy the package.`,
|
|
1202
|
+
"Next: Connect Salesforce"
|
|
1203
|
+
);
|
|
1610
1204
|
outro(
|
|
1611
|
-
|
|
1205
|
+
chalk.green("\u2714 You're live!") + `
|
|
1612
1206
|
|
|
1613
|
-
Dashboard: ${
|
|
1614
|
-
Routing engine: ${
|
|
1207
|
+
Dashboard: ${chalk.cyan(cfg.appUrl)}
|
|
1208
|
+
Routing engine: ${chalk.cyan(cfg.engineUrl)}
|
|
1615
1209
|
|
|
1616
|
-
Admin email: ${
|
|
1617
|
-
Admin secret: ${
|
|
1618
|
-
${
|
|
1210
|
+
Admin email: ${chalk.white(cfg.adminEmail)}
|
|
1211
|
+
Admin secret: ${chalk.yellow(adminSecret)}
|
|
1212
|
+
${chalk.dim("run `lead-routing config show` to retrieve later")}
|
|
1619
1213
|
|
|
1620
|
-
` +
|
|
1621
|
-
${
|
|
1214
|
+
` + chalk.bold(" Next steps:\n") + ` ${chalk.cyan("1.")} Open ${chalk.cyan(cfg.appUrl)} and log in
|
|
1215
|
+
${chalk.cyan("2.")} Go to Integrations \u2192 Salesforce to connect your org
|
|
1216
|
+
${chalk.cyan("3.")} Deploy the package and configure routing objects
|
|
1217
|
+
${chalk.cyan("4.")} Create your first routing rule
|
|
1622
1218
|
|
|
1623
|
-
Run ${
|
|
1624
|
-
Run ${
|
|
1219
|
+
Run ${chalk.cyan("lead-routing doctor")} to check service health at any time.
|
|
1220
|
+
Run ${chalk.cyan("lead-routing deploy")} to update to a new version.`
|
|
1625
1221
|
);
|
|
1626
1222
|
} catch (err) {
|
|
1627
1223
|
const message = err instanceof Error ? err.message : String(err);
|
|
1628
|
-
|
|
1224
|
+
log7.error(`Setup failed: ${message}`);
|
|
1629
1225
|
process.exit(1);
|
|
1630
1226
|
} finally {
|
|
1631
1227
|
await ssh.disconnect();
|
|
@@ -1634,16 +1230,16 @@ Files created: docker-compose.yml, Caddyfile, .env.web, .env.engine, lead-routin
|
|
|
1634
1230
|
|
|
1635
1231
|
// src/commands/deploy.ts
|
|
1636
1232
|
import { writeFileSync as writeFileSync4, unlinkSync } from "fs";
|
|
1637
|
-
import { join as
|
|
1638
|
-
import { tmpdir
|
|
1639
|
-
import { intro as intro2, outro as outro2, log as
|
|
1640
|
-
import
|
|
1233
|
+
import { join as join5 } from "path";
|
|
1234
|
+
import { tmpdir } from "os";
|
|
1235
|
+
import { intro as intro2, outro as outro2, log as log8, password as promptPassword2 } from "@clack/prompts";
|
|
1236
|
+
import chalk2 from "chalk";
|
|
1641
1237
|
async function runDeploy() {
|
|
1642
1238
|
console.log();
|
|
1643
|
-
intro2(
|
|
1239
|
+
intro2(chalk2.bold.cyan("Lead Routing \u2014 Deploy"));
|
|
1644
1240
|
const dir = findInstallDir();
|
|
1645
1241
|
if (!dir) {
|
|
1646
|
-
|
|
1242
|
+
log8.error(
|
|
1647
1243
|
"No lead-routing.json found. Run `lead-routing init` first, or run this command from your install directory."
|
|
1648
1244
|
);
|
|
1649
1245
|
process.exit(1);
|
|
@@ -1667,35 +1263,35 @@ async function runDeploy() {
|
|
|
1667
1263
|
password: sshPassword,
|
|
1668
1264
|
remoteDir: cfg.remoteDir
|
|
1669
1265
|
});
|
|
1670
|
-
|
|
1266
|
+
log8.success(`Connected to ${cfg.ssh.host}`);
|
|
1671
1267
|
} catch (err) {
|
|
1672
|
-
|
|
1268
|
+
log8.error(`SSH connection failed: ${String(err)}`);
|
|
1673
1269
|
process.exit(1);
|
|
1674
1270
|
}
|
|
1675
1271
|
try {
|
|
1676
1272
|
const remoteDir = await ssh.resolveHome(cfg.remoteDir);
|
|
1677
|
-
|
|
1273
|
+
log8.step("Syncing Caddyfile");
|
|
1678
1274
|
const caddyContent = renderCaddyfile(cfg.appUrl, cfg.engineUrl);
|
|
1679
|
-
const tmpCaddy =
|
|
1275
|
+
const tmpCaddy = join5(tmpdir(), "lead-routing-Caddyfile");
|
|
1680
1276
|
writeFileSync4(tmpCaddy, caddyContent, "utf8");
|
|
1681
1277
|
await ssh.upload([{ local: tmpCaddy, remote: `${remoteDir}/Caddyfile` }]);
|
|
1682
1278
|
unlinkSync(tmpCaddy);
|
|
1683
1279
|
await ssh.exec("docker compose restart caddy", remoteDir);
|
|
1684
|
-
|
|
1685
|
-
|
|
1280
|
+
log8.success("Caddyfile synced \u2014 waiting for TLS cert (~30s)");
|
|
1281
|
+
log8.step("Pulling latest Docker images");
|
|
1686
1282
|
await ssh.exec("docker compose pull", remoteDir);
|
|
1687
|
-
|
|
1688
|
-
|
|
1283
|
+
log8.success("Images pulled");
|
|
1284
|
+
log8.step("Restarting services");
|
|
1689
1285
|
await ssh.exec("docker compose up -d --remove-orphans", remoteDir);
|
|
1690
|
-
|
|
1286
|
+
log8.success("Services restarted");
|
|
1691
1287
|
outro2(
|
|
1692
|
-
|
|
1288
|
+
chalk2.green("\u2714 Deployment complete!") + `
|
|
1693
1289
|
|
|
1694
|
-
${
|
|
1290
|
+
${chalk2.cyan(cfg.appUrl)}`
|
|
1695
1291
|
);
|
|
1696
1292
|
} catch (err) {
|
|
1697
1293
|
const message = err instanceof Error ? err.message : String(err);
|
|
1698
|
-
|
|
1294
|
+
log8.error(`Deploy failed: ${message}`);
|
|
1699
1295
|
process.exit(1);
|
|
1700
1296
|
} finally {
|
|
1701
1297
|
await ssh.disconnect();
|
|
@@ -1703,15 +1299,15 @@ async function runDeploy() {
|
|
|
1703
1299
|
}
|
|
1704
1300
|
|
|
1705
1301
|
// src/commands/doctor.ts
|
|
1706
|
-
import { intro as intro3, outro as outro3, log as
|
|
1707
|
-
import
|
|
1302
|
+
import { intro as intro3, outro as outro3, log as log9 } from "@clack/prompts";
|
|
1303
|
+
import chalk3 from "chalk";
|
|
1708
1304
|
import { execa } from "execa";
|
|
1709
1305
|
async function runDoctor() {
|
|
1710
1306
|
console.log();
|
|
1711
|
-
intro3(
|
|
1307
|
+
intro3(chalk3.bold.cyan("Lead Routing \u2014 Health Check"));
|
|
1712
1308
|
const dir = findInstallDir();
|
|
1713
1309
|
if (!dir) {
|
|
1714
|
-
|
|
1310
|
+
log9.error("No lead-routing.json found. Run `lead-routing init` first.");
|
|
1715
1311
|
process.exit(1);
|
|
1716
1312
|
}
|
|
1717
1313
|
const cfg = readConfig(dir);
|
|
@@ -1727,17 +1323,17 @@ async function runDoctor() {
|
|
|
1727
1323
|
checks.push(await checkEndpoint("Routing engine", `${cfg.engineUrl}/health`));
|
|
1728
1324
|
console.log();
|
|
1729
1325
|
for (const c of checks) {
|
|
1730
|
-
const icon = c.pass ?
|
|
1731
|
-
const label = c.pass ?
|
|
1732
|
-
const detail = c.detail ?
|
|
1326
|
+
const icon = c.pass ? chalk3.green("\u2714") : chalk3.red("\u2717");
|
|
1327
|
+
const label = c.pass ? chalk3.white(c.label) : chalk3.red(c.label);
|
|
1328
|
+
const detail = c.detail ? chalk3.dim(` \u2014 ${c.detail}`) : "";
|
|
1733
1329
|
console.log(` ${icon} ${label}${detail}`);
|
|
1734
1330
|
}
|
|
1735
1331
|
console.log();
|
|
1736
1332
|
const failed = checks.filter((c) => !c.pass);
|
|
1737
1333
|
if (failed.length === 0) {
|
|
1738
|
-
outro3(
|
|
1334
|
+
outro3(chalk3.green("All checks passed"));
|
|
1739
1335
|
} else {
|
|
1740
|
-
outro3(
|
|
1336
|
+
outro3(chalk3.yellow(`${failed.length} check(s) failed`));
|
|
1741
1337
|
process.exit(1);
|
|
1742
1338
|
}
|
|
1743
1339
|
}
|
|
@@ -1794,17 +1390,17 @@ async function checkEndpoint(label, url) {
|
|
|
1794
1390
|
}
|
|
1795
1391
|
|
|
1796
1392
|
// src/commands/logs.ts
|
|
1797
|
-
import { log as
|
|
1393
|
+
import { log as log10 } from "@clack/prompts";
|
|
1798
1394
|
import { execa as execa2 } from "execa";
|
|
1799
1395
|
var VALID_SERVICES = ["web", "engine", "postgres", "redis"];
|
|
1800
1396
|
async function runLogs(service = "engine") {
|
|
1801
1397
|
if (!VALID_SERVICES.includes(service)) {
|
|
1802
|
-
|
|
1398
|
+
log10.error(`Unknown service "${service}". Valid options: ${VALID_SERVICES.join(", ")}`);
|
|
1803
1399
|
process.exit(1);
|
|
1804
1400
|
}
|
|
1805
1401
|
const dir = findInstallDir();
|
|
1806
1402
|
if (!dir) {
|
|
1807
|
-
|
|
1403
|
+
log10.error("No lead-routing.json found. Run `lead-routing init` first.");
|
|
1808
1404
|
process.exit(1);
|
|
1809
1405
|
}
|
|
1810
1406
|
console.log(`
|
|
@@ -1819,12 +1415,12 @@ Streaming logs for ${service} (Ctrl+C to stop)...
|
|
|
1819
1415
|
}
|
|
1820
1416
|
|
|
1821
1417
|
// src/commands/status.ts
|
|
1822
|
-
import { log as
|
|
1418
|
+
import { log as log11 } from "@clack/prompts";
|
|
1823
1419
|
import { execa as execa3 } from "execa";
|
|
1824
1420
|
async function runStatus() {
|
|
1825
1421
|
const dir = findInstallDir();
|
|
1826
1422
|
if (!dir) {
|
|
1827
|
-
|
|
1423
|
+
log11.error("No lead-routing.json found. Run `lead-routing init` first.");
|
|
1828
1424
|
process.exit(1);
|
|
1829
1425
|
}
|
|
1830
1426
|
const result = await execa3("docker", ["compose", "ps"], {
|
|
@@ -1833,20 +1429,20 @@ async function runStatus() {
|
|
|
1833
1429
|
reject: false
|
|
1834
1430
|
});
|
|
1835
1431
|
if (result.exitCode !== 0) {
|
|
1836
|
-
|
|
1432
|
+
log11.error("Failed to get container status. Is Docker running?");
|
|
1837
1433
|
process.exit(1);
|
|
1838
1434
|
}
|
|
1839
1435
|
}
|
|
1840
1436
|
|
|
1841
1437
|
// src/commands/config.ts
|
|
1842
|
-
import { readFileSync as readFileSync4, writeFileSync as writeFileSync5, existsSync as
|
|
1843
|
-
import { join as
|
|
1844
|
-
import { intro as intro4, outro as outro4, text as text3, password as password3, spinner as
|
|
1845
|
-
import
|
|
1438
|
+
import { readFileSync as readFileSync4, writeFileSync as writeFileSync5, existsSync as existsSync3 } from "fs";
|
|
1439
|
+
import { join as join6 } from "path";
|
|
1440
|
+
import { intro as intro4, outro as outro4, text as text3, password as password3, spinner as spinner5, log as log12 } from "@clack/prompts";
|
|
1441
|
+
import chalk4 from "chalk";
|
|
1846
1442
|
import { execa as execa4 } from "execa";
|
|
1847
1443
|
function parseEnv(filePath) {
|
|
1848
1444
|
const map = /* @__PURE__ */ new Map();
|
|
1849
|
-
if (!
|
|
1445
|
+
if (!existsSync3(filePath)) return map;
|
|
1850
1446
|
for (const line of readFileSync4(filePath, "utf8").split("\n")) {
|
|
1851
1447
|
const trimmed = line.trim();
|
|
1852
1448
|
if (!trimmed || trimmed.startsWith("#")) continue;
|
|
@@ -1857,7 +1453,7 @@ function parseEnv(filePath) {
|
|
|
1857
1453
|
return map;
|
|
1858
1454
|
}
|
|
1859
1455
|
function writeEnv(filePath, updates) {
|
|
1860
|
-
const lines =
|
|
1456
|
+
const lines = existsSync3(filePath) ? readFileSync4(filePath, "utf8").split("\n") : [];
|
|
1861
1457
|
const updated = /* @__PURE__ */ new Set();
|
|
1862
1458
|
const result = lines.map((line) => {
|
|
1863
1459
|
const trimmed = line.trim();
|
|
@@ -1880,18 +1476,18 @@ async function runConfigSfdc() {
|
|
|
1880
1476
|
intro4("Lead Routing \u2014 Update Salesforce Credentials");
|
|
1881
1477
|
const dir = findInstallDir();
|
|
1882
1478
|
if (!dir) {
|
|
1883
|
-
|
|
1884
|
-
|
|
1479
|
+
log12.error("No lead-routing installation found in the current directory.");
|
|
1480
|
+
log12.info("Run `lead-routing init` first, or cd into your installation directory.");
|
|
1885
1481
|
process.exit(1);
|
|
1886
1482
|
}
|
|
1887
|
-
const envWeb =
|
|
1888
|
-
const envEngine =
|
|
1483
|
+
const envWeb = join6(dir, ".env.web");
|
|
1484
|
+
const envEngine = join6(dir, ".env.engine");
|
|
1889
1485
|
const currentWeb = parseEnv(envWeb);
|
|
1890
1486
|
const currentClientId = currentWeb.get("SFDC_CLIENT_ID") ?? "";
|
|
1891
1487
|
const currentLoginUrl = currentWeb.get("SFDC_LOGIN_URL") ?? "https://login.salesforce.com";
|
|
1892
1488
|
const currentAppUrl = currentWeb.get("APP_URL") ?? "";
|
|
1893
1489
|
const callbackUrl = `${currentAppUrl}/api/auth/callback`;
|
|
1894
|
-
|
|
1490
|
+
log12.info(
|
|
1895
1491
|
`Paste the credentials from your Salesforce Connected App.
|
|
1896
1492
|
Callback URL for your Connected App: ${callbackUrl}`
|
|
1897
1493
|
);
|
|
@@ -1916,8 +1512,8 @@ Callback URL for your Connected App: ${callbackUrl}`
|
|
|
1916
1512
|
};
|
|
1917
1513
|
writeEnv(envWeb, updates);
|
|
1918
1514
|
writeEnv(envEngine, updates);
|
|
1919
|
-
|
|
1920
|
-
const s =
|
|
1515
|
+
log12.success("Updated .env.web and .env.engine");
|
|
1516
|
+
const s = spinner5();
|
|
1921
1517
|
s.start("Restarting web and engine containers\u2026");
|
|
1922
1518
|
try {
|
|
1923
1519
|
await execa4("docker", ["compose", "up", "-d", "--force-recreate", "web", "engine"], {
|
|
@@ -1926,7 +1522,7 @@ Callback URL for your Connected App: ${callbackUrl}`
|
|
|
1926
1522
|
s.stop("Containers restarted");
|
|
1927
1523
|
} catch (err) {
|
|
1928
1524
|
s.stop("Restart failed \u2014 run `docker compose up -d --force-recreate web engine` manually");
|
|
1929
|
-
|
|
1525
|
+
log12.warn(String(err));
|
|
1930
1526
|
}
|
|
1931
1527
|
outro4(
|
|
1932
1528
|
"Salesforce credentials updated!\n\nNext: go to the web app \u2192 Settings \u2192 Connect Salesforce to refresh your OAuth tokens."
|
|
@@ -1938,24 +1534,567 @@ function runConfigShow() {
|
|
|
1938
1534
|
console.error("No lead-routing installation found in the current directory.");
|
|
1939
1535
|
process.exit(1);
|
|
1940
1536
|
}
|
|
1941
|
-
const envWeb =
|
|
1537
|
+
const envWeb = join6(dir, ".env.web");
|
|
1942
1538
|
const cfg = parseEnv(envWeb);
|
|
1943
1539
|
const adminSecret = cfg.get("ADMIN_SECRET") ?? "(not found)";
|
|
1944
1540
|
const appUrl = cfg.get("APP_URL") ?? "(not found)";
|
|
1945
1541
|
const sfdcClientId = cfg.get("SFDC_CLIENT_ID") ?? "(not found)";
|
|
1946
1542
|
console.log();
|
|
1947
|
-
console.log(
|
|
1543
|
+
console.log(chalk4.bold("Lead Routing \u2014 Installation Config"));
|
|
1948
1544
|
console.log();
|
|
1949
|
-
console.log(` Admin panel: ${
|
|
1950
|
-
console.log(` Admin secret: ${
|
|
1545
|
+
console.log(` Admin panel: ${chalk4.cyan(appUrl + "/admin")}`);
|
|
1546
|
+
console.log(` Admin secret: ${chalk4.yellow(adminSecret)}`);
|
|
1951
1547
|
console.log();
|
|
1952
|
-
console.log(` SFDC Client ID: ${
|
|
1548
|
+
console.log(` SFDC Client ID: ${chalk4.white(sfdcClientId)}`);
|
|
1953
1549
|
console.log();
|
|
1954
1550
|
}
|
|
1955
1551
|
|
|
1956
1552
|
// src/commands/sfdc.ts
|
|
1957
1553
|
import { intro as intro5, outro as outro5, text as text4, log as log15 } from "@clack/prompts";
|
|
1958
1554
|
import chalk6 from "chalk";
|
|
1555
|
+
|
|
1556
|
+
// src/steps/sfdc-deploy-inline.ts
|
|
1557
|
+
import { readFileSync as readFileSync6, writeFileSync as writeFileSync6, existsSync as existsSync5, cpSync, rmSync } from "fs";
|
|
1558
|
+
import { join as join8, dirname as dirname2 } from "path";
|
|
1559
|
+
import { tmpdir as tmpdir2 } from "os";
|
|
1560
|
+
import { fileURLToPath as fileURLToPath2 } from "url";
|
|
1561
|
+
import { execSync } from "child_process";
|
|
1562
|
+
import { spinner as spinner6, log as log13 } from "@clack/prompts";
|
|
1563
|
+
|
|
1564
|
+
// src/utils/sfdc-api.ts
|
|
1565
|
+
var API_VERSION = "v59.0";
|
|
1566
|
+
var SalesforceApi = class {
|
|
1567
|
+
constructor(instanceUrl, accessToken) {
|
|
1568
|
+
this.instanceUrl = instanceUrl;
|
|
1569
|
+
this.accessToken = accessToken;
|
|
1570
|
+
this.baseUrl = `${instanceUrl.replace(/\/+$/, "")}/services/data/${API_VERSION}`;
|
|
1571
|
+
}
|
|
1572
|
+
baseUrl;
|
|
1573
|
+
headers(extra) {
|
|
1574
|
+
return {
|
|
1575
|
+
Authorization: `Bearer ${this.accessToken}`,
|
|
1576
|
+
"Content-Type": "application/json",
|
|
1577
|
+
...extra
|
|
1578
|
+
};
|
|
1579
|
+
}
|
|
1580
|
+
/** Execute a SOQL query and return records */
|
|
1581
|
+
async query(soql) {
|
|
1582
|
+
const url = `${this.baseUrl}/query?q=${encodeURIComponent(soql)}`;
|
|
1583
|
+
const res = await fetch(url, { headers: this.headers() });
|
|
1584
|
+
if (!res.ok) {
|
|
1585
|
+
const body = await res.text();
|
|
1586
|
+
throw new Error(`SOQL query failed (${res.status}): ${body}`);
|
|
1587
|
+
}
|
|
1588
|
+
const data = await res.json();
|
|
1589
|
+
return data.records;
|
|
1590
|
+
}
|
|
1591
|
+
/** Create an sObject record, returns the new record ID */
|
|
1592
|
+
async create(sobject, data) {
|
|
1593
|
+
const url = `${this.baseUrl}/sobjects/${sobject}`;
|
|
1594
|
+
const res = await fetch(url, {
|
|
1595
|
+
method: "POST",
|
|
1596
|
+
headers: this.headers(),
|
|
1597
|
+
body: JSON.stringify(data)
|
|
1598
|
+
});
|
|
1599
|
+
if (!res.ok) {
|
|
1600
|
+
const body = await res.text();
|
|
1601
|
+
if (res.status === 400 && body.includes("Duplicate")) {
|
|
1602
|
+
throw new DuplicateError(body);
|
|
1603
|
+
}
|
|
1604
|
+
throw new Error(`Create ${sobject} failed (${res.status}): ${body}`);
|
|
1605
|
+
}
|
|
1606
|
+
const result = await res.json();
|
|
1607
|
+
return result.id;
|
|
1608
|
+
}
|
|
1609
|
+
/** Update an sObject record */
|
|
1610
|
+
async update(sobject, id, data) {
|
|
1611
|
+
const url = `${this.baseUrl}/sobjects/${sobject}/${id}`;
|
|
1612
|
+
const res = await fetch(url, {
|
|
1613
|
+
method: "PATCH",
|
|
1614
|
+
headers: this.headers(),
|
|
1615
|
+
body: JSON.stringify(data)
|
|
1616
|
+
});
|
|
1617
|
+
if (!res.ok) {
|
|
1618
|
+
const body = await res.text();
|
|
1619
|
+
throw new Error(`Update ${sobject}/${id} failed (${res.status}): ${body}`);
|
|
1620
|
+
}
|
|
1621
|
+
}
|
|
1622
|
+
/** Get current user info (for permission set assignment) */
|
|
1623
|
+
async getCurrentUserId() {
|
|
1624
|
+
const url = `${this.instanceUrl.replace(/\/+$/, "")}/services/oauth2/userinfo`;
|
|
1625
|
+
const res = await fetch(url, {
|
|
1626
|
+
headers: { Authorization: `Bearer ${this.accessToken}` }
|
|
1627
|
+
});
|
|
1628
|
+
if (!res.ok) {
|
|
1629
|
+
const body = await res.text();
|
|
1630
|
+
throw new Error(`Get current user failed (${res.status}): ${body}`);
|
|
1631
|
+
}
|
|
1632
|
+
const data = await res.json();
|
|
1633
|
+
return data.user_id;
|
|
1634
|
+
}
|
|
1635
|
+
/**
|
|
1636
|
+
* Deploy metadata using the Source Deploy API (same API that `sf project deploy start` uses).
|
|
1637
|
+
* Accepts a ZIP buffer containing the source-format package.
|
|
1638
|
+
* Returns the deploy request ID for polling.
|
|
1639
|
+
*/
|
|
1640
|
+
async deployMetadata(zipBuffer) {
|
|
1641
|
+
const url = `${this.baseUrl}/metadata/deployRequest`;
|
|
1642
|
+
const boundary = `----FormBoundary${Date.now()}`;
|
|
1643
|
+
const deployOptions = JSON.stringify({
|
|
1644
|
+
deployOptions: {
|
|
1645
|
+
rollbackOnError: true,
|
|
1646
|
+
singlePackage: true
|
|
1647
|
+
}
|
|
1648
|
+
});
|
|
1649
|
+
const parts = [];
|
|
1650
|
+
parts.push(
|
|
1651
|
+
Buffer.from(
|
|
1652
|
+
`--${boundary}\r
|
|
1653
|
+
Content-Disposition: form-data; name="json"\r
|
|
1654
|
+
Content-Type: application/json\r
|
|
1655
|
+
\r
|
|
1656
|
+
${deployOptions}\r
|
|
1657
|
+
`
|
|
1658
|
+
)
|
|
1659
|
+
);
|
|
1660
|
+
parts.push(
|
|
1661
|
+
Buffer.from(
|
|
1662
|
+
`--${boundary}\r
|
|
1663
|
+
Content-Disposition: form-data; name="file"; filename="deploy.zip"\r
|
|
1664
|
+
Content-Type: application/zip\r
|
|
1665
|
+
\r
|
|
1666
|
+
`
|
|
1667
|
+
)
|
|
1668
|
+
);
|
|
1669
|
+
parts.push(zipBuffer);
|
|
1670
|
+
parts.push(Buffer.from(`\r
|
|
1671
|
+
--${boundary}--\r
|
|
1672
|
+
`));
|
|
1673
|
+
const body = Buffer.concat(parts);
|
|
1674
|
+
const res = await fetch(url, {
|
|
1675
|
+
method: "POST",
|
|
1676
|
+
headers: {
|
|
1677
|
+
Authorization: `Bearer ${this.accessToken}`,
|
|
1678
|
+
"Content-Type": `multipart/form-data; boundary=${boundary}`
|
|
1679
|
+
},
|
|
1680
|
+
body
|
|
1681
|
+
});
|
|
1682
|
+
if (!res.ok) {
|
|
1683
|
+
const text5 = await res.text();
|
|
1684
|
+
throw new Error(
|
|
1685
|
+
`Metadata deploy request failed (${res.status}): ${text5}`
|
|
1686
|
+
);
|
|
1687
|
+
}
|
|
1688
|
+
const result = await res.json();
|
|
1689
|
+
return result.id;
|
|
1690
|
+
}
|
|
1691
|
+
/**
|
|
1692
|
+
* Poll deploy status until complete.
|
|
1693
|
+
* Returns deploy result with success/failure info.
|
|
1694
|
+
*/
|
|
1695
|
+
async waitForDeploy(deployId, timeoutMs = 3e5) {
|
|
1696
|
+
const startTime = Date.now();
|
|
1697
|
+
const pollInterval = 3e3;
|
|
1698
|
+
while (Date.now() - startTime < timeoutMs) {
|
|
1699
|
+
const url = `${this.baseUrl}/metadata/deployRequest/${deployId}?includeDetails=true`;
|
|
1700
|
+
const res = await fetch(url, { headers: this.headers() });
|
|
1701
|
+
if (!res.ok) {
|
|
1702
|
+
const text5 = await res.text();
|
|
1703
|
+
throw new Error(
|
|
1704
|
+
`Deploy status check failed (${res.status}): ${text5}`
|
|
1705
|
+
);
|
|
1706
|
+
}
|
|
1707
|
+
const data = await res.json();
|
|
1708
|
+
const result = data.deployResult;
|
|
1709
|
+
if (result.done) {
|
|
1710
|
+
return result;
|
|
1711
|
+
}
|
|
1712
|
+
await new Promise((resolve) => setTimeout(resolve, pollInterval));
|
|
1713
|
+
}
|
|
1714
|
+
throw new Error(`Deploy timed out after ${timeoutMs / 1e3}s`);
|
|
1715
|
+
}
|
|
1716
|
+
};
|
|
1717
|
+
var DuplicateError = class extends Error {
|
|
1718
|
+
constructor(message) {
|
|
1719
|
+
super(message);
|
|
1720
|
+
this.name = "DuplicateError";
|
|
1721
|
+
}
|
|
1722
|
+
};
|
|
1723
|
+
|
|
1724
|
+
// src/utils/zip-source.ts
|
|
1725
|
+
import { join as join7 } from "path";
|
|
1726
|
+
import { readdirSync, readFileSync as readFileSync5, existsSync as existsSync4 } from "fs";
|
|
1727
|
+
import archiver from "archiver";
|
|
1728
|
+
var META_TYPE_MAP = {
|
|
1729
|
+
applications: "CustomApplication",
|
|
1730
|
+
classes: "ApexClass",
|
|
1731
|
+
triggers: "ApexTrigger",
|
|
1732
|
+
lwc: "LightningComponentBundle",
|
|
1733
|
+
permissionsets: "PermissionSet",
|
|
1734
|
+
namedCredentials: "NamedCredential",
|
|
1735
|
+
remoteSiteSettings: "RemoteSiteSetting",
|
|
1736
|
+
tabs: "CustomTab"
|
|
1737
|
+
};
|
|
1738
|
+
async function zipSourcePackage(packageDir) {
|
|
1739
|
+
const forceAppDefault = join7(packageDir, "force-app", "main", "default");
|
|
1740
|
+
let apiVersion = "59.0";
|
|
1741
|
+
try {
|
|
1742
|
+
const proj = JSON.parse(readFileSync5(join7(packageDir, "sfdx-project.json"), "utf8"));
|
|
1743
|
+
if (proj.sourceApiVersion) apiVersion = proj.sourceApiVersion;
|
|
1744
|
+
} catch {
|
|
1745
|
+
}
|
|
1746
|
+
const members = /* @__PURE__ */ new Map();
|
|
1747
|
+
const addMember = (type, name) => {
|
|
1748
|
+
if (!members.has(type)) members.set(type, /* @__PURE__ */ new Set());
|
|
1749
|
+
members.get(type).add(name);
|
|
1750
|
+
};
|
|
1751
|
+
return new Promise((resolve, reject) => {
|
|
1752
|
+
const chunks = [];
|
|
1753
|
+
const archive = archiver("zip", { zlib: { level: 9 } });
|
|
1754
|
+
archive.on("data", (chunk) => chunks.push(chunk));
|
|
1755
|
+
archive.on("end", () => resolve(Buffer.concat(chunks)));
|
|
1756
|
+
archive.on("error", reject);
|
|
1757
|
+
for (const [dirName, metaType] of Object.entries(META_TYPE_MAP)) {
|
|
1758
|
+
const srcDir = join7(forceAppDefault, dirName);
|
|
1759
|
+
if (!existsSync4(srcDir)) continue;
|
|
1760
|
+
const entries = readdirSync(srcDir, { withFileTypes: true });
|
|
1761
|
+
for (const entry of entries) {
|
|
1762
|
+
if (dirName === "lwc" && entry.isDirectory()) {
|
|
1763
|
+
addMember(metaType, entry.name);
|
|
1764
|
+
archive.directory(join7(srcDir, entry.name), `${dirName}/${entry.name}`);
|
|
1765
|
+
} else if (entry.isFile()) {
|
|
1766
|
+
archive.file(join7(srcDir, entry.name), { name: `${dirName}/${entry.name}` });
|
|
1767
|
+
if (!entry.name.endsWith("-meta.xml")) {
|
|
1768
|
+
const memberName = entry.name.replace(/\.[^.]+$/, "");
|
|
1769
|
+
addMember(metaType, memberName);
|
|
1770
|
+
}
|
|
1771
|
+
}
|
|
1772
|
+
}
|
|
1773
|
+
}
|
|
1774
|
+
const objectsDir = join7(forceAppDefault, "objects");
|
|
1775
|
+
if (existsSync4(objectsDir)) {
|
|
1776
|
+
for (const objEntry of readdirSync(objectsDir, { withFileTypes: true })) {
|
|
1777
|
+
if (!objEntry.isDirectory()) continue;
|
|
1778
|
+
const objName = objEntry.name;
|
|
1779
|
+
addMember("CustomObject", objName);
|
|
1780
|
+
const objDir = join7(objectsDir, objName);
|
|
1781
|
+
const objectXml = mergeObjectXml(objDir, objName, apiVersion);
|
|
1782
|
+
archive.append(Buffer.from(objectXml, "utf8"), {
|
|
1783
|
+
name: `objects/${objName}.object`
|
|
1784
|
+
});
|
|
1785
|
+
}
|
|
1786
|
+
}
|
|
1787
|
+
const packageXml = generatePackageXml(members, apiVersion);
|
|
1788
|
+
archive.append(Buffer.from(packageXml, "utf8"), { name: "package.xml" });
|
|
1789
|
+
archive.finalize();
|
|
1790
|
+
});
|
|
1791
|
+
}
|
|
1792
|
+
function mergeObjectXml(objDir, objName, apiVersion) {
|
|
1793
|
+
const lines = [
|
|
1794
|
+
'<?xml version="1.0" encoding="UTF-8"?>',
|
|
1795
|
+
'<CustomObject xmlns="http://soap.sforce.com/2006/04/metadata">'
|
|
1796
|
+
];
|
|
1797
|
+
const objMetaPath = join7(objDir, `${objName}.object-meta.xml`);
|
|
1798
|
+
if (existsSync4(objMetaPath)) {
|
|
1799
|
+
const content = readFileSync5(objMetaPath, "utf8");
|
|
1800
|
+
const inner = content.replace(/<\?xml[^?]*\?>\s*/g, "").replace(/<CustomObject[^>]*>/g, "").replace(/<\/CustomObject>/g, "").trim();
|
|
1801
|
+
if (inner) lines.push(inner);
|
|
1802
|
+
}
|
|
1803
|
+
const fieldsDir = join7(objDir, "fields");
|
|
1804
|
+
if (existsSync4(fieldsDir)) {
|
|
1805
|
+
for (const fieldFile of readdirSync(fieldsDir).sort()) {
|
|
1806
|
+
if (!fieldFile.endsWith(".field-meta.xml")) continue;
|
|
1807
|
+
const content = readFileSync5(join7(fieldsDir, fieldFile), "utf8");
|
|
1808
|
+
const inner = content.replace(/<\?xml[^?]*\?>\s*/g, "").replace(/<CustomField[^>]*>/g, "").replace(/<\/CustomField>/g, "").trim();
|
|
1809
|
+
if (inner) {
|
|
1810
|
+
lines.push(" <fields>");
|
|
1811
|
+
lines.push(` ${inner}`);
|
|
1812
|
+
lines.push(" </fields>");
|
|
1813
|
+
}
|
|
1814
|
+
}
|
|
1815
|
+
}
|
|
1816
|
+
lines.push("</CustomObject>");
|
|
1817
|
+
return lines.join("\n");
|
|
1818
|
+
}
|
|
1819
|
+
function generatePackageXml(members, apiVersion) {
|
|
1820
|
+
const lines = [
|
|
1821
|
+
'<?xml version="1.0" encoding="UTF-8"?>',
|
|
1822
|
+
'<Package xmlns="http://soap.sforce.com/2006/04/metadata">'
|
|
1823
|
+
];
|
|
1824
|
+
for (const [metaType, names] of [...members.entries()].sort((a, b) => a[0].localeCompare(b[0]))) {
|
|
1825
|
+
lines.push(" <types>");
|
|
1826
|
+
for (const name of [...names].sort()) {
|
|
1827
|
+
lines.push(` <members>${name}</members>`);
|
|
1828
|
+
}
|
|
1829
|
+
lines.push(` <name>${metaType}</name>`);
|
|
1830
|
+
lines.push(" </types>");
|
|
1831
|
+
}
|
|
1832
|
+
lines.push(` <version>${apiVersion}</version>`);
|
|
1833
|
+
lines.push("</Package>");
|
|
1834
|
+
return lines.join("\n");
|
|
1835
|
+
}
|
|
1836
|
+
|
|
1837
|
+
// src/steps/sfdc-deploy-inline.ts
|
|
1838
|
+
var __dirname2 = dirname2(fileURLToPath2(import.meta.url));
|
|
1839
|
+
function patchXml(content, tag, value) {
|
|
1840
|
+
const re = new RegExp(`(<${tag}>)[^<]*(</\\s*${tag}>)`, "g");
|
|
1841
|
+
return content.replace(re, `$1${value}$2`);
|
|
1842
|
+
}
|
|
1843
|
+
async function sfdcDeployInline(params) {
|
|
1844
|
+
const { appUrl, engineUrl, installDir } = params;
|
|
1845
|
+
const s = spinner6();
|
|
1846
|
+
const { accessToken, instanceUrl } = await loginViaAppBridge(appUrl);
|
|
1847
|
+
const sf = new SalesforceApi(instanceUrl, accessToken);
|
|
1848
|
+
s.start("Copying Salesforce package\u2026");
|
|
1849
|
+
const inDist = join8(__dirname2, "sfdc-package");
|
|
1850
|
+
const nextToDist = join8(__dirname2, "..", "sfdc-package");
|
|
1851
|
+
const bundledPkg = existsSync5(inDist) ? inDist : nextToDist;
|
|
1852
|
+
const destPkg = join8(installDir ?? tmpdir2(), "lead-routing-sfdc-package");
|
|
1853
|
+
if (!existsSync5(bundledPkg)) {
|
|
1854
|
+
s.stop("sfdc-package not found in CLI bundle");
|
|
1855
|
+
throw new Error(
|
|
1856
|
+
`Expected bundle at: ${inDist}
|
|
1857
|
+
The CLI may need to be reinstalled: npm i -g @lead-routing/cli`
|
|
1858
|
+
);
|
|
1859
|
+
}
|
|
1860
|
+
if (existsSync5(destPkg)) rmSync(destPkg, { recursive: true, force: true });
|
|
1861
|
+
cpSync(bundledPkg, destPkg, { recursive: true });
|
|
1862
|
+
s.stop("Package copied");
|
|
1863
|
+
const ncPath = join8(
|
|
1864
|
+
destPkg,
|
|
1865
|
+
"force-app",
|
|
1866
|
+
"main",
|
|
1867
|
+
"default",
|
|
1868
|
+
"namedCredentials",
|
|
1869
|
+
"RoutingEngine.namedCredential-meta.xml"
|
|
1870
|
+
);
|
|
1871
|
+
if (existsSync5(ncPath)) {
|
|
1872
|
+
const nc = patchXml(readFileSync6(ncPath, "utf8"), "endpoint", engineUrl);
|
|
1873
|
+
writeFileSync6(ncPath, nc, "utf8");
|
|
1874
|
+
}
|
|
1875
|
+
const rssEnginePath = join8(
|
|
1876
|
+
destPkg,
|
|
1877
|
+
"force-app",
|
|
1878
|
+
"main",
|
|
1879
|
+
"default",
|
|
1880
|
+
"remoteSiteSettings",
|
|
1881
|
+
"LeadRouterEngine.remoteSite-meta.xml"
|
|
1882
|
+
);
|
|
1883
|
+
if (existsSync5(rssEnginePath)) {
|
|
1884
|
+
let rss = patchXml(readFileSync6(rssEnginePath, "utf8"), "url", engineUrl);
|
|
1885
|
+
rss = patchXml(rss, "description", "Lead Router Engine endpoint");
|
|
1886
|
+
writeFileSync6(rssEnginePath, rss, "utf8");
|
|
1887
|
+
}
|
|
1888
|
+
const rssAppPath = join8(
|
|
1889
|
+
destPkg,
|
|
1890
|
+
"force-app",
|
|
1891
|
+
"main",
|
|
1892
|
+
"default",
|
|
1893
|
+
"remoteSiteSettings",
|
|
1894
|
+
"LeadRouterApp.remoteSite-meta.xml"
|
|
1895
|
+
);
|
|
1896
|
+
if (existsSync5(rssAppPath)) {
|
|
1897
|
+
let rss = patchXml(readFileSync6(rssAppPath, "utf8"), "url", appUrl);
|
|
1898
|
+
rss = patchXml(rss, "description", "Lead Router App URL");
|
|
1899
|
+
writeFileSync6(rssAppPath, rss, "utf8");
|
|
1900
|
+
}
|
|
1901
|
+
log13.success("Remote Site Settings patched");
|
|
1902
|
+
s.start("Deploying Salesforce package (this may take ~2 min)\u2026");
|
|
1903
|
+
try {
|
|
1904
|
+
const zipBuffer = await zipSourcePackage(destPkg);
|
|
1905
|
+
const deployId = await sf.deployMetadata(zipBuffer);
|
|
1906
|
+
const result = await sf.waitForDeploy(deployId);
|
|
1907
|
+
if (!result.success) {
|
|
1908
|
+
const failures = result.details?.componentFailures ?? [];
|
|
1909
|
+
const failureMsg = failures.map((f) => ` ${f.componentType}/${f.fullName}: ${f.problem}`).join("\n");
|
|
1910
|
+
s.stop("Deployment failed");
|
|
1911
|
+
throw new Error(
|
|
1912
|
+
`Metadata deploy failed (${result.numberComponentErrors} error(s)):
|
|
1913
|
+
${failureMsg || result.errorMessage || "Unknown error"}`
|
|
1914
|
+
);
|
|
1915
|
+
}
|
|
1916
|
+
s.stop(`Package deployed (${result.numberComponentsDeployed} components)`);
|
|
1917
|
+
} catch (err) {
|
|
1918
|
+
if (err instanceof Error && err.message.startsWith("Metadata deploy failed")) {
|
|
1919
|
+
throw err;
|
|
1920
|
+
}
|
|
1921
|
+
s.stop("Deployment failed");
|
|
1922
|
+
throw new Error(
|
|
1923
|
+
`Metadata deploy failed: ${String(err)}
|
|
1924
|
+
|
|
1925
|
+
The patched package is at: ${destPkg}
|
|
1926
|
+
You can retry with: sf project deploy start --source-dir force-app`
|
|
1927
|
+
);
|
|
1928
|
+
}
|
|
1929
|
+
s.start("Assigning LeadRouterAdmin permission set\u2026");
|
|
1930
|
+
try {
|
|
1931
|
+
const permSets = await sf.query(
|
|
1932
|
+
"SELECT Id FROM PermissionSet WHERE Name = 'LeadRouterAdmin' LIMIT 1"
|
|
1933
|
+
);
|
|
1934
|
+
if (permSets.length === 0) {
|
|
1935
|
+
s.stop("LeadRouterAdmin permission set not found (non-fatal)");
|
|
1936
|
+
log13.warn("The permission set may not have been included in the deploy.");
|
|
1937
|
+
} else {
|
|
1938
|
+
const userId = await sf.getCurrentUserId();
|
|
1939
|
+
try {
|
|
1940
|
+
await sf.create("PermissionSetAssignment", {
|
|
1941
|
+
AssigneeId: userId,
|
|
1942
|
+
PermissionSetId: permSets[0].Id
|
|
1943
|
+
});
|
|
1944
|
+
s.stop("Permission set assigned \u2014 Lead Router Setup will appear in the App Launcher");
|
|
1945
|
+
} catch (err) {
|
|
1946
|
+
if (err instanceof DuplicateError) {
|
|
1947
|
+
s.stop("Permission set already assigned");
|
|
1948
|
+
} else {
|
|
1949
|
+
throw err;
|
|
1950
|
+
}
|
|
1951
|
+
}
|
|
1952
|
+
}
|
|
1953
|
+
} catch (err) {
|
|
1954
|
+
if (!(err instanceof DuplicateError)) {
|
|
1955
|
+
s.stop("Permission set assignment failed (non-fatal)");
|
|
1956
|
+
log13.warn(String(err));
|
|
1957
|
+
log13.info(
|
|
1958
|
+
"Grant access manually:\n Salesforce Setup \u2192 Users \u2192 Permission Sets \u2192 Lead Router Admin \u2192 Manage Assignments"
|
|
1959
|
+
);
|
|
1960
|
+
}
|
|
1961
|
+
}
|
|
1962
|
+
s.start("Writing org settings to Routing_Settings__c\u2026");
|
|
1963
|
+
try {
|
|
1964
|
+
const existing = await sf.query(
|
|
1965
|
+
"SELECT Id FROM Routing_Settings__c LIMIT 1"
|
|
1966
|
+
);
|
|
1967
|
+
const settingsData = {
|
|
1968
|
+
App_Url__c: appUrl,
|
|
1969
|
+
Engine_Endpoint__c: engineUrl
|
|
1970
|
+
};
|
|
1971
|
+
if (params.webhookSecret) {
|
|
1972
|
+
settingsData.Webhook_Secret__c = params.webhookSecret;
|
|
1973
|
+
}
|
|
1974
|
+
if (existing.length > 0) {
|
|
1975
|
+
await sf.update("Routing_Settings__c", existing[0].Id, settingsData);
|
|
1976
|
+
} else {
|
|
1977
|
+
await sf.create("Routing_Settings__c", settingsData);
|
|
1978
|
+
}
|
|
1979
|
+
s.stop("Org settings written");
|
|
1980
|
+
} catch (err) {
|
|
1981
|
+
s.stop("Org settings write failed (non-fatal)");
|
|
1982
|
+
log13.warn(String(err));
|
|
1983
|
+
log13.info("Set manually: Salesforce \u2192 Custom Settings \u2192 Routing Settings \u2192 Manage");
|
|
1984
|
+
}
|
|
1985
|
+
}
|
|
1986
|
+
async function loginViaAppBridge(rawAppUrl) {
|
|
1987
|
+
const appUrl = rawAppUrl.replace(/\/+$/, "");
|
|
1988
|
+
const s = spinner6();
|
|
1989
|
+
s.start("Starting Salesforce authentication via your Lead Router app\u2026");
|
|
1990
|
+
let sessionId;
|
|
1991
|
+
let authUrl;
|
|
1992
|
+
try {
|
|
1993
|
+
const res = await fetch(`${appUrl}/api/cli-auth/request`, { method: "POST" });
|
|
1994
|
+
if (!res.ok) {
|
|
1995
|
+
s.stop("Failed to start auth session");
|
|
1996
|
+
throw new Error(
|
|
1997
|
+
`Could not reach ${appUrl}/api/cli-auth/request (HTTP ${res.status}).
|
|
1998
|
+
Make sure the web app is running and accessible.`
|
|
1999
|
+
);
|
|
2000
|
+
}
|
|
2001
|
+
const data = await res.json();
|
|
2002
|
+
sessionId = data.sessionId;
|
|
2003
|
+
authUrl = data.authUrl;
|
|
2004
|
+
} catch (err) {
|
|
2005
|
+
s.stop("Could not reach Lead Router app");
|
|
2006
|
+
throw new Error(
|
|
2007
|
+
`Failed to connect to ${appUrl}: ${String(err)}
|
|
2008
|
+
Ensure the app is running and the URL is correct.`
|
|
2009
|
+
);
|
|
2010
|
+
}
|
|
2011
|
+
s.stop("Auth session started");
|
|
2012
|
+
log13.info(`Open this URL in your browser to authenticate with Salesforce:
|
|
2013
|
+
|
|
2014
|
+
${authUrl}
|
|
2015
|
+
`);
|
|
2016
|
+
log13.info('If Chrome shows a "Dangerous site" warning with no proceed option, paste the URL into Safari or Firefox.');
|
|
2017
|
+
const opener = process.platform === "win32" ? "start" : process.platform === "darwin" ? "open" : "xdg-open";
|
|
2018
|
+
try {
|
|
2019
|
+
execSync(`${opener} "${authUrl}"`, { stdio: "ignore" });
|
|
2020
|
+
} catch {
|
|
2021
|
+
}
|
|
2022
|
+
s.start("Waiting for Salesforce authentication in browser\u2026");
|
|
2023
|
+
const maxPolls = 150;
|
|
2024
|
+
let accessToken;
|
|
2025
|
+
let instanceUrl;
|
|
2026
|
+
for (let i = 0; i < maxPolls; i++) {
|
|
2027
|
+
await new Promise((r) => setTimeout(r, 2e3));
|
|
2028
|
+
try {
|
|
2029
|
+
const pollRes = await fetch(`${appUrl}/api/cli-auth/poll/${sessionId}`);
|
|
2030
|
+
if (pollRes.status === 410) {
|
|
2031
|
+
s.stop("Auth session expired");
|
|
2032
|
+
throw new Error("CLI auth session expired. Please re-run the command.");
|
|
2033
|
+
}
|
|
2034
|
+
const data = await pollRes.json();
|
|
2035
|
+
if (data.status === "ok") {
|
|
2036
|
+
accessToken = data.accessToken;
|
|
2037
|
+
instanceUrl = data.instanceUrl;
|
|
2038
|
+
break;
|
|
2039
|
+
}
|
|
2040
|
+
} catch (err) {
|
|
2041
|
+
if (String(err).includes("session expired")) throw err;
|
|
2042
|
+
}
|
|
2043
|
+
}
|
|
2044
|
+
if (!accessToken || !instanceUrl) {
|
|
2045
|
+
s.stop("Timed out");
|
|
2046
|
+
throw new Error(
|
|
2047
|
+
"Timed out waiting for Salesforce authentication (5 minutes).\nPlease re-run the command and complete login within 5 minutes."
|
|
2048
|
+
);
|
|
2049
|
+
}
|
|
2050
|
+
s.stop("Authenticated with Salesforce");
|
|
2051
|
+
return { accessToken, instanceUrl };
|
|
2052
|
+
}
|
|
2053
|
+
|
|
2054
|
+
// src/steps/app-launcher-guide.ts
|
|
2055
|
+
import { note as note4, confirm as confirm2, isCancel as isCancel4, log as log14 } from "@clack/prompts";
|
|
2056
|
+
import chalk5 from "chalk";
|
|
2057
|
+
async function guideAppLauncherSetup(appUrl) {
|
|
2058
|
+
note4(
|
|
2059
|
+
`Complete the following steps in Salesforce now:
|
|
2060
|
+
|
|
2061
|
+
${chalk5.cyan("1.")} Open ${chalk5.bold("App Launcher")} (grid icon, top-left in Salesforce)
|
|
2062
|
+
${chalk5.cyan("2.")} Search for ${chalk5.white('"Lead Router Setup"')} and click it
|
|
2063
|
+
${chalk5.cyan("3.")} Click ${chalk5.white('"Connect to Lead Router"')}
|
|
2064
|
+
\u2192 You will be redirected to ${chalk5.dim(appUrl)} and back
|
|
2065
|
+
\u2192 Authorize the OAuth connection when prompted
|
|
2066
|
+
|
|
2067
|
+
${chalk5.cyan("4.")} ${chalk5.bold("Step 1")} \u2014 wait for the ${chalk5.green('"Connected"')} checkmark (~5 sec)
|
|
2068
|
+
${chalk5.cyan("5.")} ${chalk5.bold("Step 2")} \u2014 click ${chalk5.white("Activate")} to enable Lead triggers
|
|
2069
|
+
${chalk5.cyan("6.")} ${chalk5.bold("Step 3")} \u2014 click ${chalk5.white("Sync Fields")} to index your Lead field schema
|
|
2070
|
+
${chalk5.cyan("7.")} ${chalk5.bold("Step 4")} \u2014 click ${chalk5.white("Send Test")} to fire a test routing event
|
|
2071
|
+
\u2192 ${chalk5.dim('"Test successful"')} or ${chalk5.dim('"No matching rule"')} are both valid
|
|
2072
|
+
|
|
2073
|
+
` + chalk5.dim("Keep this terminal open while you complete the wizard."),
|
|
2074
|
+
"Complete Salesforce setup"
|
|
2075
|
+
);
|
|
2076
|
+
const done = await confirm2({
|
|
2077
|
+
message: "Have you completed the App Launcher wizard?",
|
|
2078
|
+
initialValue: false
|
|
2079
|
+
});
|
|
2080
|
+
if (isCancel4(done)) {
|
|
2081
|
+
log14.warn(
|
|
2082
|
+
"Wizard skipped. Run `lead-routing sfdc deploy` to retry the Salesforce setup."
|
|
2083
|
+
);
|
|
2084
|
+
return;
|
|
2085
|
+
}
|
|
2086
|
+
if (!done) {
|
|
2087
|
+
log14.warn(
|
|
2088
|
+
`No problem \u2014 complete it at your own pace.
|
|
2089
|
+
Open App Launcher \u2192 Lead Router Setup \u2192 Connect to Lead Router
|
|
2090
|
+
Dashboard: ${appUrl}`
|
|
2091
|
+
);
|
|
2092
|
+
} else {
|
|
2093
|
+
log14.success("Salesforce setup complete");
|
|
2094
|
+
}
|
|
2095
|
+
}
|
|
2096
|
+
|
|
2097
|
+
// src/commands/sfdc.ts
|
|
1959
2098
|
async function runSfdcDeploy() {
|
|
1960
2099
|
intro5("Lead Routing \u2014 Deploy Salesforce Package");
|
|
1961
2100
|
let appUrl;
|
|
@@ -1996,7 +2135,8 @@ async function runSfdcDeploy() {
|
|
|
1996
2135
|
// Read from config if available
|
|
1997
2136
|
sfdcClientId: config2?.sfdcClientId ?? "",
|
|
1998
2137
|
sfdcLoginUrl: config2?.sfdcLoginUrl ?? "https://login.salesforce.com",
|
|
1999
|
-
installDir: dir ?? void 0
|
|
2138
|
+
installDir: dir ?? void 0,
|
|
2139
|
+
webhookSecret: config2?.engineWebhookSecret
|
|
2000
2140
|
});
|
|
2001
2141
|
} catch (err) {
|
|
2002
2142
|
log15.error(err instanceof Error ? err.message : String(err));
|
|
@@ -2011,7 +2151,7 @@ async function runSfdcDeploy() {
|
|
|
2011
2151
|
}
|
|
2012
2152
|
|
|
2013
2153
|
// src/commands/uninstall.ts
|
|
2014
|
-
import { rmSync as rmSync2, existsSync as
|
|
2154
|
+
import { rmSync as rmSync2, existsSync as existsSync6 } from "fs";
|
|
2015
2155
|
import { intro as intro6, outro as outro6, log as log16, confirm as confirm3, password as promptPassword3, isCancel as isCancel5 } from "@clack/prompts";
|
|
2016
2156
|
import chalk7 from "chalk";
|
|
2017
2157
|
async function runUninstall() {
|
|
@@ -2087,7 +2227,7 @@ async function runUninstall() {
|
|
|
2087
2227
|
await ssh.disconnect();
|
|
2088
2228
|
}
|
|
2089
2229
|
log16.step("Removing local config directory");
|
|
2090
|
-
if (
|
|
2230
|
+
if (existsSync6(dir)) {
|
|
2091
2231
|
rmSync2(dir, { recursive: true, force: true });
|
|
2092
2232
|
log16.success(`Removed ${dir}`);
|
|
2093
2233
|
}
|