@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 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 { intro, outro, note as note4, log as log9, confirm as confirm2, cancel as cancel3, isCancel as isCancel4, password as promptPassword } from "@clack/prompts";
9
- import chalk2 from "chalk";
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 ?? "redis://redis:6379";
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", "ping"]
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/steps/sfdc-deploy-inline.ts
897
- import { readFileSync as readFileSync3, writeFileSync as writeFileSync3, existsSync as existsSync3, cpSync, rmSync } from "fs";
898
- import { join as join5, dirname as dirname2 } from "path";
899
- import { tmpdir } from "os";
900
- import { fileURLToPath as fileURLToPath2 } from "url";
901
- import { execSync } from "child_process";
902
- import { spinner as spinner5, log as log7 } from "@clack/prompts";
903
-
904
- // src/utils/sfdc-api.ts
905
- var API_VERSION = "v59.0";
906
- var SalesforceApi = class {
907
- constructor(instanceUrl, accessToken) {
908
- this.instanceUrl = instanceUrl;
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
- /** Execute a SOQL query and return records */
921
- async query(soql) {
922
- const url = `${this.baseUrl}/query?q=${encodeURIComponent(soql)}`;
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
- /** Get current user info (for permission set assignment) */
963
- async getCurrentUserId() {
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
- * Deploy metadata using the Source Deploy API (same API that `sf project deploy start` uses).
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 deployMetadata(zipBuffer) {
981
- const url = `${this.baseUrl}/metadata/deployRequest`;
982
- const boundary = `----FormBoundary${Date.now()}`;
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
- `Metadata deploy request failed (${res.status}): ${text5}`
970
+ `Remote command failed (exit ${result.code}): ${cmd}
971
+ ${result.stderr || result.stdout}`
1027
972
  );
1028
973
  }
1029
- const result = await res.json();
1030
- return result.id;
974
+ return { stdout: result.stdout, stderr: result.stderr };
1031
975
  }
1032
976
  /**
1033
- * Poll deploy status until complete.
1034
- * Returns deploy result with success/failure info.
977
+ * Run a command remotely without throwing — returns code + output.
1035
978
  */
1036
- async waitForDeploy(deployId, timeoutMs = 3e5) {
1037
- const startTime = Date.now();
1038
- const pollInterval = 3e3;
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
- var DuplicateError = class extends Error {
1059
- constructor(message) {
1060
- super(message);
1061
- this.name = "DuplicateError";
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
- // src/utils/zip-source.ts
1066
- import { join as join4 } from "path";
1067
- import archiver from "archiver";
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
- if (existsSync3(destPkg)) rmSync(destPkg, { recursive: true, force: true });
1107
- cpSync(bundledPkg, destPkg, { recursive: true });
1108
- s.stop("Package copied");
1109
- const ncPath = join5(
1110
- destPkg,
1111
- "force-app",
1112
- "main",
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
- log9.warn(
1469
- `${chalk2.yellow(host)} does not resolve in DNS yet.
1470
- Check for typos \u2014 a bad domain will cause a 2-minute timeout at step 8.`
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 confirm2({ message: "Continue anyway?", initialValue: true });
1473
- if (isCancel4(go) || !go) {
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
- chalk2.bold.cyan("Lead Routing \u2014 Self-Hosted Setup") + (dryRun ? chalk2.yellow(" [dry run]") : "") + (resume ? chalk2.yellow(" [resume]") : "")
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
- log9.error("No lead-routing.json found \u2014 run `lead-routing init` first.");
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
- log9.step("Connecting to server");
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
- log9.success(`Connected to ${saved.ssh.host}`);
1109
+ log7.success(`Connected to ${saved.ssh.host}`);
1514
1110
  const remoteDir = await ssh.resolveHome(saved.remoteDir);
1515
- log9.step("Step 7/8 Verifying health");
1111
+ log7.step("Step 7/7 Verifying health");
1516
1112
  await verifyHealth(saved.appUrl, saved.engineUrl, ssh, remoteDir);
1517
- log9.step("Step 8/8 Deploying Salesforce package");
1518
- await sfdcDeployInline({
1519
- appUrl: saved.appUrl,
1520
- engineUrl: saved.engineUrl,
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
- chalk2.green("\u2714 You're live!") + `
1118
+ chalk.green("\u2714 You're live!") + `
1529
1119
 
1530
- Dashboard: ${chalk2.cyan(saved.appUrl)}
1531
- Routing engine: ${chalk2.cyan(saved.engineUrl)}
1120
+ Dashboard: ${chalk.cyan(saved.appUrl)}
1121
+ Routing engine: ${chalk.cyan(saved.engineUrl)}
1532
1122
 
1533
- ` + chalk2.bold(" Next steps:\n") + ` ${chalk2.cyan("1.")} Open ${chalk2.cyan(saved.appUrl)} and log in
1534
- ${chalk2.cyan("2.")} Create your first routing rule to start routing leads
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 ${chalk2.cyan("lead-routing doctor")} to check service health at any time.
1537
- Run ${chalk2.cyan("lead-routing deploy")} to update to a new version.`
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
- log9.error(`Resume failed: ${message}`);
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
- log9.step("Step 1/8 Checking local prerequisites");
1141
+ log7.step("Step 1/7 Checking local prerequisites");
1550
1142
  await checkPrerequisites();
1551
- log9.step("Step 2/8 SSH connection");
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
- log9.success(`Connected to ${sshCfg.host}`);
1153
+ log7.success(`Connected to ${sshCfg.host}`);
1562
1154
  } catch (err) {
1563
- log9.error(`SSH connection failed: ${String(err)}`);
1564
- log9.info("Check your password and re-run `lead-routing init`.");
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
- log9.step("Step 3/8 Configuration");
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
- log9.step("Step 4/8 Generating config files");
1167
+ log7.step("Step 4/7 Generating config files");
1576
1168
  const { dir, adminSecret } = generateFiles(cfg, sshCfg);
1577
- note4(
1578
- `Local config directory: ${chalk2.cyan(dir)}
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
- chalk2.yellow("Dry run complete \u2014 no connection made, no services started.") + `
1176
+ chalk.yellow("Dry run complete \u2014 no connection made, no services started.") + `
1585
1177
 
1586
- Config files written to: ${chalk2.cyan(dir)}
1178
+ Config files written to: ${chalk.cyan(dir)}
1587
1179
 
1588
- When ready, run ${chalk2.cyan("lead-routing init")} (without --dry-run) to deploy.`
1180
+ When ready, run ${chalk.cyan("lead-routing init")} (without --dry-run) to deploy.`
1589
1181
  );
1590
1182
  return;
1591
1183
  }
1592
- log9.step("Step 5/8 Remote setup");
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
- log9.step("Step 6/8 Starting services");
1188
+ log7.step("Step 6/7 Starting services");
1597
1189
  await startServices(ssh, remoteDir);
1598
- log9.step("Step 7/8 Verifying health");
1190
+ log7.step("Step 7/7 Verifying health");
1599
1191
  await verifyHealth(cfg.appUrl, cfg.engineUrl, ssh, remoteDir);
1600
- log9.step("Step 8/8 Deploying Salesforce package");
1601
- await sfdcDeployInline({
1602
- appUrl: cfg.appUrl,
1603
- engineUrl: cfg.engineUrl,
1604
- orgAlias: "lead-routing",
1605
- sfdcClientId: cfg.sfdcClientId,
1606
- sfdcLoginUrl: cfg.sfdcLoginUrl,
1607
- installDir: dir
1608
- });
1609
- await guideAppLauncherSetup(cfg.appUrl);
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
- chalk2.green("\u2714 You're live!") + `
1205
+ chalk.green("\u2714 You're live!") + `
1612
1206
 
1613
- Dashboard: ${chalk2.cyan(cfg.appUrl)}
1614
- Routing engine: ${chalk2.cyan(cfg.engineUrl)}
1207
+ Dashboard: ${chalk.cyan(cfg.appUrl)}
1208
+ Routing engine: ${chalk.cyan(cfg.engineUrl)}
1615
1209
 
1616
- Admin email: ${chalk2.white(cfg.adminEmail)}
1617
- Admin secret: ${chalk2.yellow(adminSecret)}
1618
- ${chalk2.dim("run `lead-routing config show` to retrieve later")}
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
- ` + chalk2.bold(" Next steps:\n") + ` ${chalk2.cyan("1.")} Open ${chalk2.cyan(cfg.appUrl)} and log in
1621
- ${chalk2.cyan("2.")} Create your first routing rule to start routing leads
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 ${chalk2.cyan("lead-routing doctor")} to check service health at any time.
1624
- Run ${chalk2.cyan("lead-routing deploy")} to update to a new version.`
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
- log9.error(`Setup failed: ${message}`);
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 join6 } from "path";
1638
- import { tmpdir as tmpdir2 } from "os";
1639
- import { intro as intro2, outro as outro2, log as log10, password as promptPassword2 } from "@clack/prompts";
1640
- import chalk3 from "chalk";
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(chalk3.bold.cyan("Lead Routing \u2014 Deploy"));
1239
+ intro2(chalk2.bold.cyan("Lead Routing \u2014 Deploy"));
1644
1240
  const dir = findInstallDir();
1645
1241
  if (!dir) {
1646
- log10.error(
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
- log10.success(`Connected to ${cfg.ssh.host}`);
1266
+ log8.success(`Connected to ${cfg.ssh.host}`);
1671
1267
  } catch (err) {
1672
- log10.error(`SSH connection failed: ${String(err)}`);
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
- log10.step("Syncing Caddyfile");
1273
+ log8.step("Syncing Caddyfile");
1678
1274
  const caddyContent = renderCaddyfile(cfg.appUrl, cfg.engineUrl);
1679
- const tmpCaddy = join6(tmpdir2(), "lead-routing-Caddyfile");
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
- log10.success("Caddyfile synced \u2014 waiting for TLS cert (~30s)");
1685
- log10.step("Pulling latest Docker images");
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
- log10.success("Images pulled");
1688
- log10.step("Restarting services");
1283
+ log8.success("Images pulled");
1284
+ log8.step("Restarting services");
1689
1285
  await ssh.exec("docker compose up -d --remove-orphans", remoteDir);
1690
- log10.success("Services restarted");
1286
+ log8.success("Services restarted");
1691
1287
  outro2(
1692
- chalk3.green("\u2714 Deployment complete!") + `
1288
+ chalk2.green("\u2714 Deployment complete!") + `
1693
1289
 
1694
- ${chalk3.cyan(cfg.appUrl)}`
1290
+ ${chalk2.cyan(cfg.appUrl)}`
1695
1291
  );
1696
1292
  } catch (err) {
1697
1293
  const message = err instanceof Error ? err.message : String(err);
1698
- log10.error(`Deploy failed: ${message}`);
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 log11 } from "@clack/prompts";
1707
- import chalk4 from "chalk";
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(chalk4.bold.cyan("Lead Routing \u2014 Health Check"));
1307
+ intro3(chalk3.bold.cyan("Lead Routing \u2014 Health Check"));
1712
1308
  const dir = findInstallDir();
1713
1309
  if (!dir) {
1714
- log11.error("No lead-routing.json found. Run `lead-routing init` first.");
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 ? chalk4.green("\u2714") : chalk4.red("\u2717");
1731
- const label = c.pass ? chalk4.white(c.label) : chalk4.red(c.label);
1732
- const detail = c.detail ? chalk4.dim(` \u2014 ${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(chalk4.green("All checks passed"));
1334
+ outro3(chalk3.green("All checks passed"));
1739
1335
  } else {
1740
- outro3(chalk4.yellow(`${failed.length} check(s) failed`));
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 log12 } from "@clack/prompts";
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
- log12.error(`Unknown service "${service}". Valid options: ${VALID_SERVICES.join(", ")}`);
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
- log12.error("No lead-routing.json found. Run `lead-routing init` first.");
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 log13 } from "@clack/prompts";
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
- log13.error("No lead-routing.json found. Run `lead-routing init` first.");
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
- log13.error("Failed to get container status. Is Docker running?");
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 existsSync4 } from "fs";
1843
- import { join as join7 } from "path";
1844
- import { intro as intro4, outro as outro4, text as text3, password as password3, spinner as spinner6, log as log14 } from "@clack/prompts";
1845
- import chalk5 from "chalk";
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 (!existsSync4(filePath)) return map;
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 = existsSync4(filePath) ? readFileSync4(filePath, "utf8").split("\n") : [];
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
- log14.error("No lead-routing installation found in the current directory.");
1884
- log14.info("Run `lead-routing init` first, or cd into your installation directory.");
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 = join7(dir, ".env.web");
1888
- const envEngine = join7(dir, ".env.engine");
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
- log14.info(
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
- log14.success("Updated .env.web and .env.engine");
1920
- const s = spinner6();
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
- log14.warn(String(err));
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 = join7(dir, ".env.web");
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(chalk5.bold("Lead Routing \u2014 Installation Config"));
1543
+ console.log(chalk4.bold("Lead Routing \u2014 Installation Config"));
1948
1544
  console.log();
1949
- console.log(` Admin panel: ${chalk5.cyan(appUrl + "/admin")}`);
1950
- console.log(` Admin secret: ${chalk5.yellow(adminSecret)}`);
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: ${chalk5.white(sfdcClientId)}`);
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 existsSync5 } from "fs";
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 (existsSync5(dir)) {
2230
+ if (existsSync6(dir)) {
2091
2231
  rmSync2(dir, { recursive: true, force: true });
2092
2232
  log16.success(`Removed ${dir}`);
2093
2233
  }