@lead-routing/cli 0.3.0 → 0.4.1

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,10 +5,10 @@ import { Command } from "commander";
5
5
 
6
6
  // src/commands/init.ts
7
7
  import { promises as dns } from "dns";
8
- import { readFileSync as readFileSync5, writeFileSync as writeFileSync4 } from "fs";
9
- import { join as join6 } from "path";
10
- import { intro, outro, note as note4, log as log9, confirm as confirm2, cancel as cancel3, isCancel as isCancel4, password as promptPassword } from "@clack/prompts";
11
- 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";
12
12
 
13
13
  // src/steps/prerequisites.ts
14
14
  import { log } from "@clack/prompts";
@@ -111,7 +111,7 @@ function bail2(value) {
111
111
  }
112
112
  async function collectConfig(opts = {}) {
113
113
  note2(
114
- "You will need:\n \u2022 A Salesforce Connected App (Client ID + Secret) \u2014 instructions below\n \u2022 Public HTTPS URLs for the web app and routing engine",
114
+ "You will need:\n \u2022 Public HTTPS URLs for the web app and routing engine",
115
115
  "Before you begin"
116
116
  );
117
117
  const appUrl = await text2({
@@ -142,33 +142,6 @@ async function collectConfig(opts = {}) {
142
142
  }
143
143
  });
144
144
  if (isCancel2(engineUrl)) bail2(engineUrl);
145
- const callbackUrl = `${appUrl.trim().replace(/\/+$/, "")}/api/auth/sfdc/callback`;
146
- note2(
147
- `You need a Salesforce Connected App. If you haven't created one yet:
148
-
149
- 1. Go to Salesforce Setup \u2192 App Manager \u2192 New Connected App
150
- 2. Connected App Name: Lead Routing
151
- 3. Check "Enable OAuth Settings"
152
- 4. Callback URL (copy exactly \u2014 must match):
153
- ${callbackUrl}
154
- 5. Selected Scopes: api \u2022 refresh_token, offline_access
155
- 6. Check "Require Secret for Web Server Flow"
156
- 7. Save \u2014 wait ~2 min, then click "Manage Consumer Details"
157
- 8. Copy the Consumer Key (Client ID) and Consumer Secret below`,
158
- "Salesforce Connected App setup"
159
- );
160
- const sfdcClientId = await text2({
161
- message: 'Consumer Key (labelled "Client ID" in newer orgs)',
162
- placeholder: "3MVG9...",
163
- validate: (v) => !v ? "Required" : void 0
164
- });
165
- if (isCancel2(sfdcClientId)) bail2(sfdcClientId);
166
- const sfdcClientSecret = await password2({
167
- message: 'Consumer Secret (labelled "Client Secret" in newer orgs)',
168
- validate: (v) => !v ? "Required" : void 0
169
- });
170
- if (isCancel2(sfdcClientSecret)) bail2(sfdcClientSecret);
171
- const sfdcLoginUrl = opts.sandbox ? "https://test.salesforce.com" : "https://login.salesforce.com";
172
145
  const dbPassword = generateSecret(16);
173
146
  const managedDb = !opts.externalDb;
174
147
  const databaseUrl = opts.externalDb ?? `postgresql://leadrouting:${dbPassword}@postgres:5432/leadrouting`;
@@ -200,9 +173,6 @@ async function collectConfig(opts = {}) {
200
173
  return {
201
174
  appUrl: appUrl.trim().replace(/\/+$/, ""),
202
175
  engineUrl: engineUrl.trim().replace(/\/+$/, ""),
203
- sfdcClientId: sfdcClientId.trim(),
204
- sfdcClientSecret: sfdcClientSecret.trim(),
205
- sfdcLoginUrl,
206
176
  managedDb,
207
177
  databaseUrl,
208
178
  dbPassword: managedDb ? dbPassword : "",
@@ -360,12 +330,6 @@ function renderEnvWeb(c) {
360
330
  `# Redis`,
361
331
  `REDIS_URL=${c.redisUrl}`,
362
332
  ``,
363
- `# Salesforce OAuth`,
364
- `SFDC_CLIENT_ID=${c.sfdcClientId}`,
365
- `SFDC_CLIENT_SECRET=${c.sfdcClientSecret}`,
366
- `SFDC_LOGIN_URL=${c.sfdcLoginUrl}`,
367
- `SFDC_REDIRECT_URI=${c.appUrl.replace(/\/+$/, "")}/api/auth/sfdc/callback`,
368
- ``,
369
333
  `# Session`,
370
334
  `SESSION_SECRET=${c.sessionSecret}`,
371
335
  ``,
@@ -400,11 +364,6 @@ function renderEnvEngine(c) {
400
364
  `# Redis`,
401
365
  `REDIS_URL=${c.redisUrl}`,
402
366
  ``,
403
- `# Salesforce OAuth`,
404
- `SFDC_CLIENT_ID=${c.sfdcClientId}`,
405
- `SFDC_CLIENT_SECRET=${c.sfdcClientSecret}`,
406
- `SFDC_LOGIN_URL=${c.sfdcLoginUrl}`,
407
- ``,
408
367
  `# Webhook`,
409
368
  `ENGINE_WEBHOOK_SECRET=${c.engineWebhookSecret}`,
410
369
  ``,
@@ -544,9 +503,6 @@ function generateFiles(cfg, sshCfg) {
544
503
  publicEngineUrl: cfg.engineUrl,
545
504
  databaseUrl: cfg.databaseUrl,
546
505
  redisUrl: cfg.redisUrl,
547
- sfdcClientId: cfg.sfdcClientId,
548
- sfdcClientSecret: cfg.sfdcClientSecret,
549
- sfdcLoginUrl: cfg.sfdcLoginUrl,
550
506
  sessionSecret: cfg.sessionSecret,
551
507
  engineWebhookSecret: cfg.engineWebhookSecret,
552
508
  adminSecret: cfg.adminSecret,
@@ -562,9 +518,6 @@ function generateFiles(cfg, sshCfg) {
562
518
  const envEngineContent = renderEnvEngine({
563
519
  databaseUrl: cfg.databaseUrl,
564
520
  redisUrl: cfg.redisUrl,
565
- sfdcClientId: cfg.sfdcClientId,
566
- sfdcClientSecret: cfg.sfdcClientSecret,
567
- sfdcLoginUrl: cfg.sfdcLoginUrl,
568
521
  engineWebhookSecret: cfg.engineWebhookSecret,
569
522
  internalApiKey: cfg.internalApiKey
570
523
  });
@@ -587,9 +540,6 @@ function generateFiles(cfg, sshCfg) {
587
540
  db: cfg.managedDb,
588
541
  redis: cfg.managedRedis
589
542
  },
590
- // Stored so `lead-routing sfdc deploy` can re-authenticate without re-prompting
591
- sfdcClientId: cfg.sfdcClientId,
592
- sfdcLoginUrl: cfg.sfdcLoginUrl,
593
543
  engineWebhookSecret: cfg.engineWebhookSecret,
594
544
  installedAt: (/* @__PURE__ */ new Date()).toISOString(),
595
545
  version: getCliVersion()
@@ -935,1174 +885,1098 @@ function sleep2(ms) {
935
885
  return new Promise((resolve) => setTimeout(resolve, ms));
936
886
  }
937
887
 
938
- // src/steps/sfdc-deploy-inline.ts
939
- import { readFileSync as readFileSync4, writeFileSync as writeFileSync3, existsSync as existsSync4, cpSync, rmSync } from "fs";
940
- import { join as join5, dirname as dirname2 } from "path";
941
- import { tmpdir } from "os";
942
- import { fileURLToPath as fileURLToPath2 } from "url";
943
- import { execSync } from "child_process";
944
- import { spinner as spinner5, log as log7 } from "@clack/prompts";
945
-
946
- // src/utils/sfdc-api.ts
947
- var API_VERSION = "v59.0";
948
- var SalesforceApi = class {
949
- constructor(instanceUrl, accessToken) {
950
- this.instanceUrl = instanceUrl;
951
- this.accessToken = accessToken;
952
- this.baseUrl = `${instanceUrl.replace(/\/+$/, "")}/services/data/${API_VERSION}`;
953
- }
954
- baseUrl;
955
- headers(extra) {
956
- return {
957
- Authorization: `Bearer ${this.accessToken}`,
958
- "Content-Type": "application/json",
959
- ...extra
888
+ // src/utils/ssh.ts
889
+ import net from "net";
890
+ import { NodeSSH } from "node-ssh";
891
+ var SshConnection = class {
892
+ ssh = new NodeSSH();
893
+ _connected = false;
894
+ async connect(config2) {
895
+ const opts = {
896
+ host: config2.host,
897
+ port: config2.port,
898
+ username: config2.username,
899
+ // Reduce connection timeout to fail fast on bad creds
900
+ readyTimeout: 15e3
960
901
  };
961
- }
962
- /** Execute a SOQL query and return records */
963
- async query(soql) {
964
- const url = `${this.baseUrl}/query?q=${encodeURIComponent(soql)}`;
965
- const res = await fetch(url, { headers: this.headers() });
966
- if (!res.ok) {
967
- const body = await res.text();
968
- throw new Error(`SOQL query failed (${res.status}): ${body}`);
969
- }
970
- const data = await res.json();
971
- return data.records;
972
- }
973
- /** Create an sObject record, returns the new record ID */
974
- async create(sobject, data) {
975
- const url = `${this.baseUrl}/sobjects/${sobject}`;
976
- const res = await fetch(url, {
977
- method: "POST",
978
- headers: this.headers(),
979
- body: JSON.stringify(data)
980
- });
981
- if (!res.ok) {
982
- const body = await res.text();
983
- if (res.status === 400 && body.includes("Duplicate")) {
984
- throw new DuplicateError(body);
985
- }
986
- throw new Error(`Create ${sobject} failed (${res.status}): ${body}`);
987
- }
988
- const result = await res.json();
989
- return result.id;
990
- }
991
- /** Update an sObject record */
992
- async update(sobject, id, data) {
993
- const url = `${this.baseUrl}/sobjects/${sobject}/${id}`;
994
- const res = await fetch(url, {
995
- method: "PATCH",
996
- headers: this.headers(),
997
- body: JSON.stringify(data)
998
- });
999
- if (!res.ok) {
1000
- const body = await res.text();
1001
- throw new Error(`Update ${sobject}/${id} failed (${res.status}): ${body}`);
902
+ if (config2.privateKeyPath) {
903
+ opts.privateKeyPath = config2.privateKeyPath;
904
+ } else if (config2.password) {
905
+ opts.password = config2.password;
1002
906
  }
907
+ await this.ssh.connect(opts);
908
+ this._connected = true;
1003
909
  }
1004
- /** Get current user info (for permission set assignment) */
1005
- async getCurrentUserId() {
1006
- const url = `${this.instanceUrl.replace(/\/+$/, "")}/services/oauth2/userinfo`;
1007
- const res = await fetch(url, {
1008
- headers: { Authorization: `Bearer ${this.accessToken}` }
1009
- });
1010
- if (!res.ok) {
1011
- const body = await res.text();
1012
- throw new Error(`Get current user failed (${res.status}): ${body}`);
1013
- }
1014
- const data = await res.json();
1015
- return data.user_id;
910
+ get isConnected() {
911
+ return this._connected;
1016
912
  }
1017
913
  /**
1018
- * Deploy metadata using the Source Deploy API (same API that `sf project deploy start` uses).
1019
- * Accepts a ZIP buffer containing the source-format package.
1020
- * Returns the deploy request ID for polling.
914
+ * Run a command remotely. Throws if exit code is non-zero.
1021
915
  */
1022
- async deployMetadata(zipBuffer) {
1023
- const url = `${this.baseUrl}/metadata/deployRequest`;
1024
- const boundary = `----FormBoundary${Date.now()}`;
1025
- const deployOptions = JSON.stringify({
1026
- deployOptions: {
1027
- rollbackOnError: true,
1028
- singlePackage: true
1029
- }
1030
- });
1031
- const parts = [];
1032
- parts.push(
1033
- Buffer.from(
1034
- `--${boundary}\r
1035
- Content-Disposition: form-data; name="json"\r
1036
- Content-Type: application/json\r
1037
- \r
1038
- ${deployOptions}\r
1039
- `
1040
- )
1041
- );
1042
- parts.push(
1043
- Buffer.from(
1044
- `--${boundary}\r
1045
- Content-Disposition: form-data; name="file"; filename="deploy.zip"\r
1046
- Content-Type: application/zip\r
1047
- \r
1048
- `
1049
- )
1050
- );
1051
- parts.push(zipBuffer);
1052
- parts.push(Buffer.from(`\r
1053
- --${boundary}--\r
1054
- `));
1055
- const body = Buffer.concat(parts);
1056
- const res = await fetch(url, {
1057
- method: "POST",
1058
- headers: {
1059
- Authorization: `Bearer ${this.accessToken}`,
1060
- "Content-Type": `multipart/form-data; boundary=${boundary}`
1061
- },
1062
- body
1063
- });
1064
- if (!res.ok) {
1065
- const text5 = await res.text();
916
+ async exec(cmd, cwd) {
917
+ const result = await this.ssh.execCommand(cmd, cwd ? { cwd } : {});
918
+ if (result.code !== 0) {
1066
919
  throw new Error(
1067
- `Metadata deploy request failed (${res.status}): ${text5}`
920
+ `Remote command failed (exit ${result.code}): ${cmd}
921
+ ${result.stderr || result.stdout}`
1068
922
  );
1069
923
  }
1070
- const result = await res.json();
1071
- return result.id;
924
+ return { stdout: result.stdout, stderr: result.stderr };
1072
925
  }
1073
926
  /**
1074
- * Poll deploy status until complete.
1075
- * Returns deploy result with success/failure info.
927
+ * Run a command remotely without throwing — returns code + output.
1076
928
  */
1077
- async waitForDeploy(deployId, timeoutMs = 3e5) {
1078
- const startTime = Date.now();
1079
- const pollInterval = 3e3;
1080
- while (Date.now() - startTime < timeoutMs) {
1081
- const url = `${this.baseUrl}/metadata/deployRequest/${deployId}?includeDetails=true`;
1082
- const res = await fetch(url, { headers: this.headers() });
1083
- if (!res.ok) {
1084
- const text5 = await res.text();
1085
- throw new Error(
1086
- `Deploy status check failed (${res.status}): ${text5}`
1087
- );
1088
- }
1089
- const data = await res.json();
1090
- const result = data.deployResult;
1091
- if (result.done) {
1092
- return result;
1093
- }
1094
- await new Promise((resolve) => setTimeout(resolve, pollInterval));
1095
- }
1096
- throw new Error(`Deploy timed out after ${timeoutMs / 1e3}s`);
929
+ async execSilent(cmd, cwd) {
930
+ const result = await this.ssh.execCommand(cmd, cwd ? { cwd } : {});
931
+ return { stdout: result.stdout, stderr: result.stderr, code: result.code ?? 1 };
1097
932
  }
1098
- };
1099
- var DuplicateError = class extends Error {
1100
- constructor(message) {
1101
- super(message);
1102
- this.name = "DuplicateError";
933
+ /**
934
+ * Create a remote directory (including parents).
935
+ */
936
+ async mkdir(remotePath) {
937
+ await this.exec(`mkdir -p ${remotePath}`);
938
+ }
939
+ /**
940
+ * Upload local files to the remote server via SFTP.
941
+ */
942
+ async upload(files) {
943
+ await this.ssh.putFiles(files);
944
+ }
945
+ /**
946
+ * Resolve ~ in a remote path by querying $HOME on the server.
947
+ */
948
+ async resolveHome(remotePath) {
949
+ if (!remotePath.startsWith("~")) return remotePath;
950
+ const { stdout } = await this.exec("echo $HOME");
951
+ return stdout.trim() + remotePath.slice(1);
952
+ }
953
+ /**
954
+ * Open an SSH port-forward tunnel.
955
+ * Creates a local TCP server that pipes connections through to
956
+ * localhost:{remotePort} on the remote machine.
957
+ *
958
+ * Returns the local port and a close() function.
959
+ * Call close() when migrations are done.
960
+ */
961
+ async tunnel(remotePort) {
962
+ const sshClient = this.ssh.connection;
963
+ if (!sshClient) throw new Error("SSH not connected \u2014 cannot open tunnel");
964
+ const server = net.createServer((socket) => {
965
+ ;
966
+ sshClient.forwardOut(
967
+ "127.0.0.1",
968
+ 0,
969
+ "localhost",
970
+ remotePort,
971
+ (err, stream) => {
972
+ if (err) {
973
+ socket.destroy();
974
+ return;
975
+ }
976
+ socket.pipe(stream);
977
+ stream.pipe(socket);
978
+ socket.on("close", () => stream.destroy());
979
+ stream.on("close", () => socket.destroy());
980
+ }
981
+ );
982
+ });
983
+ return new Promise((resolve, reject) => {
984
+ server.listen(0, "127.0.0.1", () => {
985
+ const { port } = server.address();
986
+ resolve({
987
+ localPort: port,
988
+ close: () => server.close()
989
+ });
990
+ });
991
+ server.on("error", reject);
992
+ });
993
+ }
994
+ async disconnect() {
995
+ if (this._connected) {
996
+ this.ssh.dispose();
997
+ this._connected = false;
998
+ }
1103
999
  }
1104
1000
  };
1105
1001
 
1106
- // src/utils/zip-source.ts
1107
- import { join as join4 } from "path";
1108
- import { readdirSync, readFileSync as readFileSync3, existsSync as existsSync3 } from "fs";
1109
- import archiver from "archiver";
1110
- var META_TYPE_MAP = {
1111
- applications: "CustomApplication",
1112
- classes: "ApexClass",
1113
- triggers: "ApexTrigger",
1114
- lwc: "LightningComponentBundle",
1115
- permissionsets: "PermissionSet",
1116
- namedCredentials: "NamedCredential",
1117
- remoteSiteSettings: "RemoteSiteSetting",
1118
- tabs: "CustomTab"
1119
- };
1120
- async function zipSourcePackage(packageDir) {
1121
- const forceAppDefault = join4(packageDir, "force-app", "main", "default");
1122
- let apiVersion = "59.0";
1002
+ // src/commands/init.ts
1003
+ async function checkDnsResolvable(appUrl, engineUrl) {
1004
+ let hosts;
1123
1005
  try {
1124
- const proj = JSON.parse(readFileSync3(join4(packageDir, "sfdx-project.json"), "utf8"));
1125
- if (proj.sourceApiVersion) apiVersion = proj.sourceApiVersion;
1006
+ hosts = [.../* @__PURE__ */ new Set([new URL(appUrl).hostname, new URL(engineUrl).hostname])];
1126
1007
  } catch {
1008
+ return;
1127
1009
  }
1128
- const members = /* @__PURE__ */ new Map();
1129
- const addMember = (type, name) => {
1130
- if (!members.has(type)) members.set(type, /* @__PURE__ */ new Set());
1131
- members.get(type).add(name);
1132
- };
1133
- return new Promise((resolve, reject) => {
1134
- const chunks = [];
1135
- const archive = archiver("zip", { zlib: { level: 9 } });
1136
- archive.on("data", (chunk) => chunks.push(chunk));
1137
- archive.on("end", () => resolve(Buffer.concat(chunks)));
1138
- archive.on("error", reject);
1139
- for (const [dirName, metaType] of Object.entries(META_TYPE_MAP)) {
1140
- const srcDir = join4(forceAppDefault, dirName);
1141
- if (!existsSync3(srcDir)) continue;
1142
- const entries = readdirSync(srcDir, { withFileTypes: true });
1143
- for (const entry of entries) {
1144
- if (dirName === "lwc" && entry.isDirectory()) {
1145
- addMember(metaType, entry.name);
1146
- archive.directory(join4(srcDir, entry.name), `${dirName}/${entry.name}`);
1147
- } else if (entry.isFile()) {
1148
- archive.file(join4(srcDir, entry.name), { name: `${dirName}/${entry.name}` });
1149
- if (!entry.name.endsWith("-meta.xml")) {
1150
- const memberName = entry.name.replace(/\.[^.]+$/, "");
1151
- addMember(metaType, memberName);
1152
- }
1153
- }
1010
+ for (const host of hosts) {
1011
+ try {
1012
+ await dns.lookup(host);
1013
+ } catch {
1014
+ log7.warn(
1015
+ `${chalk.yellow(host)} does not resolve in DNS yet.
1016
+ Check for typos \u2014 a bad domain will cause a 2-minute timeout at step 7.`
1017
+ );
1018
+ const go = await confirm({ message: "Continue anyway?", initialValue: true });
1019
+ if (isCancel3(go) || !go) {
1020
+ cancel3("Setup cancelled.");
1021
+ process.exit(0);
1154
1022
  }
1155
1023
  }
1156
- const objectsDir = join4(forceAppDefault, "objects");
1157
- if (existsSync3(objectsDir)) {
1158
- for (const objEntry of readdirSync(objectsDir, { withFileTypes: true })) {
1159
- if (!objEntry.isDirectory()) continue;
1160
- const objName = objEntry.name;
1161
- addMember("CustomObject", objName);
1162
- const objDir = join4(objectsDir, objName);
1163
- const objectXml = mergeObjectXml(objDir, objName, apiVersion);
1164
- archive.append(Buffer.from(objectXml, "utf8"), {
1165
- name: `objects/${objName}.object`
1024
+ }
1025
+ }
1026
+ async function runInit(options = {}) {
1027
+ const dryRun = options.dryRun ?? false;
1028
+ const resume = options.resume ?? false;
1029
+ console.log();
1030
+ intro(
1031
+ chalk.bold.cyan("Lead Routing \u2014 Self-Hosted Setup") + (dryRun ? chalk.yellow(" [dry run]") : "") + (resume ? chalk.yellow(" [resume]") : "")
1032
+ );
1033
+ const ssh = new SshConnection();
1034
+ if (resume) {
1035
+ try {
1036
+ const dir = findInstallDir();
1037
+ if (!dir) {
1038
+ log7.error("No lead-routing.json found \u2014 run `lead-routing init` first.");
1039
+ process.exit(1);
1040
+ }
1041
+ const saved = readConfig(dir);
1042
+ let sshPassword;
1043
+ if (!saved.ssh.privateKeyPath) {
1044
+ const pw = await promptPassword({
1045
+ message: `SSH password for ${saved.ssh.username}@${saved.ssh.host}`
1166
1046
  });
1047
+ if (typeof pw === "symbol") process.exit(0);
1048
+ sshPassword = pw;
1167
1049
  }
1050
+ log7.step("Connecting to server");
1051
+ await ssh.connect({
1052
+ host: saved.ssh.host,
1053
+ port: saved.ssh.port,
1054
+ username: saved.ssh.username,
1055
+ privateKeyPath: saved.ssh.privateKeyPath,
1056
+ password: sshPassword,
1057
+ remoteDir: saved.remoteDir
1058
+ });
1059
+ log7.success(`Connected to ${saved.ssh.host}`);
1060
+ const remoteDir = await ssh.resolveHome(saved.remoteDir);
1061
+ log7.step("Step 7/7 Verifying health");
1062
+ await verifyHealth(saved.appUrl, saved.engineUrl, ssh, remoteDir);
1063
+ note3(
1064
+ `Open ${saved.appUrl} \u2192 Integrations \u2192 Salesforce to connect your CRM and deploy the package.`,
1065
+ "Next: Connect Salesforce"
1066
+ );
1067
+ outro(
1068
+ chalk.green("\u2714 You're live!") + `
1069
+
1070
+ Dashboard: ${chalk.cyan(saved.appUrl)}
1071
+ Routing engine: ${chalk.cyan(saved.engineUrl)}
1072
+
1073
+ ` + chalk.bold(" Next steps:\n") + ` ${chalk.cyan("1.")} Open ${chalk.cyan(saved.appUrl)} and log in
1074
+ ${chalk.cyan("2.")} Go to Integrations \u2192 Salesforce to connect your org
1075
+ ${chalk.cyan("3.")} Deploy the package and configure routing objects
1076
+ ${chalk.cyan("4.")} Create your first routing rule
1077
+
1078
+ Run ${chalk.cyan("lead-routing doctor")} to check service health at any time.
1079
+ Run ${chalk.cyan("lead-routing deploy")} to update to a new version.`
1080
+ );
1081
+ } catch (err) {
1082
+ const message = err instanceof Error ? err.message : String(err);
1083
+ log7.error(`Resume failed: ${message}`);
1084
+ process.exit(1);
1085
+ } finally {
1086
+ await ssh.disconnect();
1168
1087
  }
1169
- const packageXml = generatePackageXml(members, apiVersion);
1170
- archive.append(Buffer.from(packageXml, "utf8"), { name: "package.xml" });
1171
- archive.finalize();
1172
- });
1173
- }
1174
- function mergeObjectXml(objDir, objName, apiVersion) {
1175
- const lines = [
1176
- '<?xml version="1.0" encoding="UTF-8"?>',
1177
- '<CustomObject xmlns="http://soap.sforce.com/2006/04/metadata">'
1178
- ];
1179
- const objMetaPath = join4(objDir, `${objName}.object-meta.xml`);
1180
- if (existsSync3(objMetaPath)) {
1181
- const content = readFileSync3(objMetaPath, "utf8");
1182
- const inner = content.replace(/<\?xml[^?]*\?>\s*/g, "").replace(/<CustomObject[^>]*>/g, "").replace(/<\/CustomObject>/g, "").trim();
1183
- if (inner) lines.push(inner);
1088
+ return;
1184
1089
  }
1185
- const fieldsDir = join4(objDir, "fields");
1186
- if (existsSync3(fieldsDir)) {
1187
- for (const fieldFile of readdirSync(fieldsDir).sort()) {
1188
- if (!fieldFile.endsWith(".field-meta.xml")) continue;
1189
- const content = readFileSync3(join4(fieldsDir, fieldFile), "utf8");
1190
- const inner = content.replace(/<\?xml[^?]*\?>\s*/g, "").replace(/<CustomField[^>]*>/g, "").replace(/<\/CustomField>/g, "").trim();
1191
- if (inner) {
1192
- lines.push(" <fields>");
1193
- lines.push(` ${inner}`);
1194
- lines.push(" </fields>");
1090
+ try {
1091
+ log7.step("Step 1/7 Checking local prerequisites");
1092
+ await checkPrerequisites();
1093
+ log7.step("Step 2/7 SSH connection");
1094
+ const sshCfg = await collectSshConfig({
1095
+ sshPort: options.sshPort,
1096
+ sshUser: options.sshUser,
1097
+ sshKey: options.sshKey,
1098
+ remoteDir: options.remoteDir
1099
+ });
1100
+ if (!dryRun) {
1101
+ try {
1102
+ await ssh.connect(sshCfg);
1103
+ log7.success(`Connected to ${sshCfg.host}`);
1104
+ } catch (err) {
1105
+ log7.error(`SSH connection failed: ${String(err)}`);
1106
+ log7.info("Check your password and re-run `lead-routing init`.");
1107
+ process.exit(1);
1195
1108
  }
1196
1109
  }
1197
- }
1198
- lines.push("</CustomObject>");
1199
- return lines.join("\n");
1200
- }
1201
- function generatePackageXml(members, apiVersion) {
1202
- const lines = [
1203
- '<?xml version="1.0" encoding="UTF-8"?>',
1204
- '<Package xmlns="http://soap.sforce.com/2006/04/metadata">'
1205
- ];
1206
- for (const [metaType, names] of [...members.entries()].sort((a, b) => a[0].localeCompare(b[0]))) {
1207
- lines.push(" <types>");
1208
- for (const name of [...names].sort()) {
1209
- lines.push(` <members>${name}</members>`);
1110
+ log7.step("Step 3/7 Configuration");
1111
+ const cfg = await collectConfig({
1112
+ externalDb: options.externalDb,
1113
+ externalRedis: options.externalRedis
1114
+ });
1115
+ await checkDnsResolvable(cfg.appUrl, cfg.engineUrl);
1116
+ log7.step("Step 4/7 Generating config files");
1117
+ const { dir, adminSecret } = generateFiles(cfg, sshCfg);
1118
+ note3(
1119
+ `Local config directory: ${chalk.cyan(dir)}
1120
+ Files created: docker-compose.yml, Caddyfile, .env.web, .env.engine, lead-routing.json`,
1121
+ "Files"
1122
+ );
1123
+ if (dryRun) {
1124
+ outro(
1125
+ chalk.yellow("Dry run complete \u2014 no connection made, no services started.") + `
1126
+
1127
+ Config files written to: ${chalk.cyan(dir)}
1128
+
1129
+ When ready, run ${chalk.cyan("lead-routing init")} (without --dry-run) to deploy.`
1130
+ );
1131
+ return;
1210
1132
  }
1211
- lines.push(` <name>${metaType}</name>`);
1212
- lines.push(" </types>");
1133
+ log7.step("Step 5/7 Remote setup");
1134
+ const remoteDir = await ssh.resolveHome(sshCfg.remoteDir);
1135
+ await checkRemotePrerequisites(ssh);
1136
+ await uploadFiles(ssh, dir, remoteDir);
1137
+ log7.step("Step 6/7 Starting services");
1138
+ await startServices(ssh, remoteDir);
1139
+ log7.step("Step 7/7 Verifying health");
1140
+ await verifyHealth(cfg.appUrl, cfg.engineUrl, ssh, remoteDir);
1141
+ try {
1142
+ const envWebPath = join4(dir, ".env.web");
1143
+ const envContent = readFileSync3(envWebPath, "utf-8");
1144
+ const cleaned = envContent.split("\n").filter((line) => !line.startsWith("ADMIN_PASSWORD=")).join("\n");
1145
+ writeFileSync3(envWebPath, cleaned, "utf-8");
1146
+ log7.success("Removed ADMIN_PASSWORD from .env.web (no longer needed after seed)");
1147
+ } catch {
1148
+ }
1149
+ note3(
1150
+ `Open ${cfg.appUrl} \u2192 Integrations \u2192 Salesforce to connect your CRM and deploy the package.`,
1151
+ "Next: Connect Salesforce"
1152
+ );
1153
+ outro(
1154
+ chalk.green("\u2714 You're live!") + `
1155
+
1156
+ Dashboard: ${chalk.cyan(cfg.appUrl)}
1157
+ Routing engine: ${chalk.cyan(cfg.engineUrl)}
1158
+
1159
+ Admin email: ${chalk.white(cfg.adminEmail)}
1160
+ Admin secret: ${chalk.yellow(adminSecret)}
1161
+ ${chalk.dim("run `lead-routing config show` to retrieve later")}
1162
+
1163
+ ` + chalk.bold(" Next steps:\n") + ` ${chalk.cyan("1.")} Open ${chalk.cyan(cfg.appUrl)} and log in
1164
+ ${chalk.cyan("2.")} Go to Integrations \u2192 Salesforce to connect your org
1165
+ ${chalk.cyan("3.")} Deploy the package and configure routing objects
1166
+ ${chalk.cyan("4.")} Create your first routing rule
1167
+
1168
+ Run ${chalk.cyan("lead-routing doctor")} to check service health at any time.
1169
+ Run ${chalk.cyan("lead-routing deploy")} to update to a new version.`
1170
+ );
1171
+ } catch (err) {
1172
+ const message = err instanceof Error ? err.message : String(err);
1173
+ log7.error(`Setup failed: ${message}`);
1174
+ process.exit(1);
1175
+ } finally {
1176
+ await ssh.disconnect();
1213
1177
  }
1214
- lines.push(` <version>${apiVersion}</version>`);
1215
- lines.push("</Package>");
1216
- return lines.join("\n");
1217
1178
  }
1218
1179
 
1219
- // src/steps/sfdc-deploy-inline.ts
1220
- var __dirname2 = dirname2(fileURLToPath2(import.meta.url));
1221
- function patchXml(content, tag, value) {
1222
- const re = new RegExp(`(<${tag}>)[^<]*(</\\s*${tag}>)`, "g");
1223
- return content.replace(re, `$1${value}$2`);
1224
- }
1225
- async function sfdcDeployInline(params) {
1226
- const { appUrl, engineUrl, installDir } = params;
1227
- const s = spinner5();
1228
- const { accessToken, instanceUrl } = await loginViaAppBridge(appUrl);
1229
- const sf = new SalesforceApi(instanceUrl, accessToken);
1230
- s.start("Copying Salesforce package\u2026");
1231
- const inDist = join5(__dirname2, "sfdc-package");
1232
- const nextToDist = join5(__dirname2, "..", "sfdc-package");
1233
- const bundledPkg = existsSync4(inDist) ? inDist : nextToDist;
1234
- const destPkg = join5(installDir ?? tmpdir(), "lead-routing-sfdc-package");
1235
- if (!existsSync4(bundledPkg)) {
1236
- s.stop("sfdc-package not found in CLI bundle");
1237
- throw new Error(
1238
- `Expected bundle at: ${inDist}
1239
- The CLI may need to be reinstalled: npm i -g @lead-routing/cli`
1180
+ // src/commands/deploy.ts
1181
+ import { writeFileSync as writeFileSync4, unlinkSync } from "fs";
1182
+ import { join as join5 } from "path";
1183
+ import { tmpdir } from "os";
1184
+ import { intro as intro2, outro as outro2, log as log8, password as promptPassword2 } from "@clack/prompts";
1185
+ import chalk2 from "chalk";
1186
+ async function runDeploy() {
1187
+ console.log();
1188
+ intro2(chalk2.bold.cyan("Lead Routing \u2014 Deploy"));
1189
+ const dir = findInstallDir();
1190
+ if (!dir) {
1191
+ log8.error(
1192
+ "No lead-routing.json found. Run `lead-routing init` first, or run this command from your install directory."
1240
1193
  );
1194
+ process.exit(1);
1241
1195
  }
1242
- if (existsSync4(destPkg)) rmSync(destPkg, { recursive: true, force: true });
1243
- cpSync(bundledPkg, destPkg, { recursive: true });
1244
- s.stop("Package copied");
1245
- const ncPath = join5(
1246
- destPkg,
1247
- "force-app",
1248
- "main",
1249
- "default",
1250
- "namedCredentials",
1251
- "RoutingEngine.namedCredential-meta.xml"
1252
- );
1253
- if (existsSync4(ncPath)) {
1254
- const nc = patchXml(readFileSync4(ncPath, "utf8"), "endpoint", engineUrl);
1255
- writeFileSync3(ncPath, nc, "utf8");
1256
- }
1257
- const rssEnginePath = join5(
1258
- destPkg,
1259
- "force-app",
1260
- "main",
1261
- "default",
1262
- "remoteSiteSettings",
1263
- "LeadRouterEngine.remoteSite-meta.xml"
1264
- );
1265
- if (existsSync4(rssEnginePath)) {
1266
- let rss = patchXml(readFileSync4(rssEnginePath, "utf8"), "url", engineUrl);
1267
- rss = patchXml(rss, "description", "Lead Router Engine endpoint");
1268
- writeFileSync3(rssEnginePath, rss, "utf8");
1269
- }
1270
- const rssAppPath = join5(
1271
- destPkg,
1272
- "force-app",
1273
- "main",
1274
- "default",
1275
- "remoteSiteSettings",
1276
- "LeadRouterApp.remoteSite-meta.xml"
1277
- );
1278
- if (existsSync4(rssAppPath)) {
1279
- let rss = patchXml(readFileSync4(rssAppPath, "utf8"), "url", appUrl);
1280
- rss = patchXml(rss, "description", "Lead Router App URL");
1281
- writeFileSync3(rssAppPath, rss, "utf8");
1196
+ const cfg = readConfig(dir);
1197
+ const ssh = new SshConnection();
1198
+ let sshPassword;
1199
+ if (!cfg.ssh.privateKeyPath) {
1200
+ const pw = await promptPassword2({
1201
+ message: `SSH password for ${cfg.ssh.username}@${cfg.ssh.host}`
1202
+ });
1203
+ if (typeof pw === "symbol") process.exit(0);
1204
+ sshPassword = pw;
1282
1205
  }
1283
- log7.success("Remote Site Settings patched");
1284
- s.start("Deploying Salesforce package (this may take ~2 min)\u2026");
1285
1206
  try {
1286
- const zipBuffer = await zipSourcePackage(destPkg);
1287
- const deployId = await sf.deployMetadata(zipBuffer);
1288
- const result = await sf.waitForDeploy(deployId);
1289
- if (!result.success) {
1290
- const failures = result.details?.componentFailures ?? [];
1291
- const failureMsg = failures.map((f) => ` ${f.componentType}/${f.fullName}: ${f.problem}`).join("\n");
1292
- s.stop("Deployment failed");
1293
- throw new Error(
1294
- `Metadata deploy failed (${result.numberComponentErrors} error(s)):
1295
- ${failureMsg || result.errorMessage || "Unknown error"}`
1296
- );
1297
- }
1298
- s.stop(`Package deployed (${result.numberComponentsDeployed} components)`);
1207
+ await ssh.connect({
1208
+ host: cfg.ssh.host,
1209
+ port: cfg.ssh.port,
1210
+ username: cfg.ssh.username,
1211
+ privateKeyPath: cfg.ssh.privateKeyPath,
1212
+ password: sshPassword,
1213
+ remoteDir: cfg.remoteDir
1214
+ });
1215
+ log8.success(`Connected to ${cfg.ssh.host}`);
1299
1216
  } catch (err) {
1300
- if (err instanceof Error && err.message.startsWith("Metadata deploy failed")) {
1301
- throw err;
1302
- }
1303
- s.stop("Deployment failed");
1304
- throw new Error(
1305
- `Metadata deploy failed: ${String(err)}
1306
-
1307
- The patched package is at: ${destPkg}
1308
- You can retry with: sf project deploy start --source-dir force-app`
1309
- );
1217
+ log8.error(`SSH connection failed: ${String(err)}`);
1218
+ process.exit(1);
1310
1219
  }
1311
- s.start("Assigning LeadRouterAdmin permission set\u2026");
1312
1220
  try {
1313
- const permSets = await sf.query(
1314
- "SELECT Id FROM PermissionSet WHERE Name = 'LeadRouterAdmin' LIMIT 1"
1221
+ const remoteDir = await ssh.resolveHome(cfg.remoteDir);
1222
+ log8.step("Syncing Caddyfile");
1223
+ const caddyContent = renderCaddyfile(cfg.appUrl, cfg.engineUrl);
1224
+ const tmpCaddy = join5(tmpdir(), "lead-routing-Caddyfile");
1225
+ writeFileSync4(tmpCaddy, caddyContent, "utf8");
1226
+ await ssh.upload([{ local: tmpCaddy, remote: `${remoteDir}/Caddyfile` }]);
1227
+ unlinkSync(tmpCaddy);
1228
+ await ssh.exec("docker compose restart caddy", remoteDir);
1229
+ log8.success("Caddyfile synced \u2014 waiting for TLS cert (~30s)");
1230
+ log8.step("Pulling latest Docker images");
1231
+ await ssh.exec("docker compose pull", remoteDir);
1232
+ log8.success("Images pulled");
1233
+ log8.step("Restarting services");
1234
+ await ssh.exec("docker compose up -d --remove-orphans", remoteDir);
1235
+ log8.success("Services restarted");
1236
+ outro2(
1237
+ chalk2.green("\u2714 Deployment complete!") + `
1238
+
1239
+ ${chalk2.cyan(cfg.appUrl)}`
1315
1240
  );
1316
- if (permSets.length === 0) {
1317
- s.stop("LeadRouterAdmin permission set not found (non-fatal)");
1318
- log7.warn("The permission set may not have been included in the deploy.");
1319
- } else {
1320
- const userId = await sf.getCurrentUserId();
1321
- try {
1322
- await sf.create("PermissionSetAssignment", {
1323
- AssigneeId: userId,
1324
- PermissionSetId: permSets[0].Id
1325
- });
1326
- s.stop("Permission set assigned \u2014 Lead Router Setup will appear in the App Launcher");
1327
- } catch (err) {
1328
- if (err instanceof DuplicateError) {
1329
- s.stop("Permission set already assigned");
1330
- } else {
1331
- throw err;
1332
- }
1333
- }
1334
- }
1335
1241
  } catch (err) {
1336
- if (!(err instanceof DuplicateError)) {
1337
- s.stop("Permission set assignment failed (non-fatal)");
1338
- log7.warn(String(err));
1339
- log7.info(
1340
- "Grant access manually:\n Salesforce Setup \u2192 Users \u2192 Permission Sets \u2192 Lead Router Admin \u2192 Manage Assignments"
1341
- );
1342
- }
1242
+ const message = err instanceof Error ? err.message : String(err);
1243
+ log8.error(`Deploy failed: ${message}`);
1244
+ process.exit(1);
1245
+ } finally {
1246
+ await ssh.disconnect();
1343
1247
  }
1344
- s.start("Writing org settings to Routing_Settings__c\u2026");
1248
+ }
1249
+
1250
+ // src/commands/doctor.ts
1251
+ import { intro as intro3, outro as outro3, log as log9 } from "@clack/prompts";
1252
+ import chalk3 from "chalk";
1253
+ import { execa } from "execa";
1254
+ async function runDoctor() {
1255
+ console.log();
1256
+ intro3(chalk3.bold.cyan("Lead Routing \u2014 Health Check"));
1257
+ const dir = findInstallDir();
1258
+ if (!dir) {
1259
+ log9.error("No lead-routing.json found. Run `lead-routing init` first.");
1260
+ process.exit(1);
1261
+ }
1262
+ const cfg = readConfig(dir);
1263
+ const checks = [];
1264
+ checks.push(await checkDockerDaemon());
1265
+ const containers = ["web", "engine"];
1266
+ if (cfg.dockerManaged.db) containers.push("postgres");
1267
+ if (cfg.dockerManaged.redis) containers.push("redis");
1268
+ for (const name of containers) {
1269
+ checks.push(await checkContainer(name, dir));
1270
+ }
1271
+ checks.push(await checkEndpoint("Web app", `${cfg.appUrl}/api/health`));
1272
+ checks.push(await checkEndpoint("Routing engine", `${cfg.engineUrl}/health`));
1273
+ console.log();
1274
+ for (const c of checks) {
1275
+ const icon = c.pass ? chalk3.green("\u2714") : chalk3.red("\u2717");
1276
+ const label = c.pass ? chalk3.white(c.label) : chalk3.red(c.label);
1277
+ const detail = c.detail ? chalk3.dim(` \u2014 ${c.detail}`) : "";
1278
+ console.log(` ${icon} ${label}${detail}`);
1279
+ }
1280
+ console.log();
1281
+ const failed = checks.filter((c) => !c.pass);
1282
+ if (failed.length === 0) {
1283
+ outro3(chalk3.green("All checks passed"));
1284
+ } else {
1285
+ outro3(chalk3.yellow(`${failed.length} check(s) failed`));
1286
+ process.exit(1);
1287
+ }
1288
+ }
1289
+ async function checkDockerDaemon() {
1345
1290
  try {
1346
- const existing = await sf.query(
1347
- "SELECT Id FROM Routing_Settings__c LIMIT 1"
1348
- );
1349
- const settingsData = {
1350
- App_Url__c: appUrl,
1351
- Engine_Endpoint__c: engineUrl
1352
- };
1353
- if (params.webhookSecret) {
1354
- settingsData.Webhook_Secret__c = params.webhookSecret;
1355
- }
1356
- if (existing.length > 0) {
1357
- await sf.update("Routing_Settings__c", existing[0].Id, settingsData);
1358
- } else {
1359
- await sf.create("Routing_Settings__c", settingsData);
1360
- }
1361
- s.stop("Org settings written");
1362
- } catch (err) {
1363
- s.stop("Org settings write failed (non-fatal)");
1364
- log7.warn(String(err));
1365
- log7.info("Set manually: Salesforce \u2192 Custom Settings \u2192 Routing Settings \u2192 Manage");
1291
+ await execa("docker", ["info"], { reject: true });
1292
+ return { label: "Docker daemon", pass: true };
1293
+ } catch {
1294
+ return { label: "Docker daemon", pass: false, detail: "not running" };
1366
1295
  }
1367
1296
  }
1368
- async function loginViaAppBridge(rawAppUrl) {
1369
- const appUrl = rawAppUrl.replace(/\/+$/, "");
1370
- const s = spinner5();
1371
- s.start("Starting Salesforce authentication via your Lead Router app\u2026");
1372
- let sessionId;
1373
- let authUrl;
1297
+ async function checkContainer(name, dir) {
1374
1298
  try {
1375
- const res = await fetch(`${appUrl}/api/cli-auth/request`, { method: "POST" });
1376
- if (!res.ok) {
1377
- s.stop("Failed to start auth session");
1378
- throw new Error(
1379
- `Could not reach ${appUrl}/api/cli-auth/request (HTTP ${res.status}).
1380
- Make sure the web app is running and accessible.`
1381
- );
1299
+ const result = await execa(
1300
+ "docker",
1301
+ ["compose", "ps", "--format", "json", name],
1302
+ { cwd: dir, reject: false }
1303
+ );
1304
+ const output = result.stdout.trim();
1305
+ if (!output) {
1306
+ return { label: `Container: ${name}`, pass: false, detail: "not found" };
1382
1307
  }
1383
- const data = await res.json();
1384
- sessionId = data.sessionId;
1385
- authUrl = data.authUrl;
1386
- } catch (err) {
1387
- s.stop("Could not reach Lead Router app");
1388
- throw new Error(
1389
- `Failed to connect to ${appUrl}: ${String(err)}
1390
- Ensure the app is running and the URL is correct.`
1308
+ const rows = output.split("\n").map((l) => {
1309
+ try {
1310
+ return JSON.parse(l);
1311
+ } catch {
1312
+ return null;
1313
+ }
1314
+ }).filter(Boolean);
1315
+ const running = rows.some(
1316
+ (r) => r.State === "running" || r.Status && r.Status.toLowerCase().includes("up")
1391
1317
  );
1392
- }
1393
- s.stop("Auth session started");
1394
- log7.info(`Open this URL in your browser to authenticate with Salesforce:
1395
-
1396
- ${authUrl}
1397
- `);
1398
- log7.info('If Chrome shows a "Dangerous site" warning with no proceed option, paste the URL into Safari or Firefox.');
1399
- const opener = process.platform === "win32" ? "start" : process.platform === "darwin" ? "open" : "xdg-open";
1400
- try {
1401
- execSync(`${opener} "${authUrl}"`, { stdio: "ignore" });
1318
+ return {
1319
+ label: `Container: ${name}`,
1320
+ pass: running,
1321
+ detail: running ? "running" : "not running"
1322
+ };
1402
1323
  } catch {
1324
+ return { label: `Container: ${name}`, pass: false, detail: "error checking status" };
1403
1325
  }
1404
- s.start("Waiting for Salesforce authentication in browser\u2026");
1405
- const maxPolls = 150;
1406
- let accessToken;
1407
- let instanceUrl;
1408
- for (let i = 0; i < maxPolls; i++) {
1409
- await new Promise((r) => setTimeout(r, 2e3));
1410
- try {
1411
- const pollRes = await fetch(`${appUrl}/api/cli-auth/poll/${sessionId}`);
1412
- if (pollRes.status === 410) {
1413
- s.stop("Auth session expired");
1414
- throw new Error("CLI auth session expired. Please re-run the command.");
1415
- }
1416
- const data = await pollRes.json();
1417
- if (data.status === "ok") {
1418
- accessToken = data.accessToken;
1419
- instanceUrl = data.instanceUrl;
1420
- break;
1421
- }
1422
- } catch (err) {
1423
- if (String(err).includes("session expired")) throw err;
1424
- }
1425
- }
1426
- if (!accessToken || !instanceUrl) {
1427
- s.stop("Timed out");
1428
- throw new Error(
1429
- "Timed out waiting for Salesforce authentication (5 minutes).\nPlease re-run the command and complete login within 5 minutes."
1430
- );
1326
+ }
1327
+ async function checkEndpoint(label, url) {
1328
+ try {
1329
+ const res = await fetch(url, { signal: AbortSignal.timeout(5e3) });
1330
+ return {
1331
+ label: `Health: ${label}`,
1332
+ pass: res.ok,
1333
+ detail: `HTTP ${res.status}`
1334
+ };
1335
+ } catch (err) {
1336
+ const detail = err instanceof Error ? err.message : String(err);
1337
+ return { label: `Health: ${label}`, pass: false, detail };
1431
1338
  }
1432
- s.stop("Authenticated with Salesforce");
1433
- return { accessToken, instanceUrl };
1434
1339
  }
1435
1340
 
1436
- // src/steps/app-launcher-guide.ts
1437
- import { note as note3, confirm, isCancel as isCancel3, log as log8 } from "@clack/prompts";
1438
- import chalk from "chalk";
1439
- async function guideAppLauncherSetup(appUrl) {
1440
- note3(
1441
- `Complete the following steps in Salesforce now:
1442
-
1443
- ${chalk.cyan("1.")} Open ${chalk.bold("App Launcher")} (grid icon, top-left in Salesforce)
1444
- ${chalk.cyan("2.")} Search for ${chalk.white('"Lead Router Setup"')} and click it
1445
- ${chalk.cyan("3.")} Click ${chalk.white('"Connect to Lead Router"')}
1446
- \u2192 You will be redirected to ${chalk.dim(appUrl)} and back
1447
- \u2192 Authorize the OAuth connection when prompted
1448
-
1449
- ${chalk.cyan("4.")} ${chalk.bold("Step 1")} \u2014 wait for the ${chalk.green('"Connected"')} checkmark (~5 sec)
1450
- ${chalk.cyan("5.")} ${chalk.bold("Step 2")} \u2014 click ${chalk.white("Activate")} to enable Lead triggers
1451
- ${chalk.cyan("6.")} ${chalk.bold("Step 3")} \u2014 click ${chalk.white("Sync Fields")} to index your Lead field schema
1452
- ${chalk.cyan("7.")} ${chalk.bold("Step 4")} \u2014 click ${chalk.white("Send Test")} to fire a test routing event
1453
- \u2192 ${chalk.dim('"Test successful"')} or ${chalk.dim('"No matching rule"')} are both valid
1454
-
1455
- ` + chalk.dim("Keep this terminal open while you complete the wizard."),
1456
- "Complete Salesforce setup"
1457
- );
1458
- const done = await confirm({
1459
- message: "Have you completed the App Launcher wizard?",
1460
- initialValue: false
1461
- });
1462
- if (isCancel3(done)) {
1463
- log8.warn(
1464
- "Wizard skipped. Run `lead-routing sfdc deploy` to retry the Salesforce setup."
1465
- );
1466
- return;
1341
+ // src/commands/logs.ts
1342
+ import { log as log10 } from "@clack/prompts";
1343
+ import { execa as execa2 } from "execa";
1344
+ var VALID_SERVICES = ["web", "engine", "postgres", "redis"];
1345
+ async function runLogs(service = "engine") {
1346
+ if (!VALID_SERVICES.includes(service)) {
1347
+ log10.error(`Unknown service "${service}". Valid options: ${VALID_SERVICES.join(", ")}`);
1348
+ process.exit(1);
1467
1349
  }
1468
- if (!done) {
1469
- log8.warn(
1470
- `No problem \u2014 complete it at your own pace.
1471
- Open App Launcher \u2192 Lead Router Setup \u2192 Connect to Lead Router
1472
- Dashboard: ${appUrl}`
1473
- );
1474
- } else {
1475
- log8.success("Salesforce setup complete");
1350
+ const dir = findInstallDir();
1351
+ if (!dir) {
1352
+ log10.error("No lead-routing.json found. Run `lead-routing init` first.");
1353
+ process.exit(1);
1476
1354
  }
1355
+ console.log(`
1356
+ Streaming logs for ${service} (Ctrl+C to stop)...
1357
+ `);
1358
+ const child = execa2("docker", ["compose", "logs", "-f", "--tail=100", service], {
1359
+ cwd: dir,
1360
+ stdio: "inherit",
1361
+ reject: false
1362
+ });
1363
+ await child;
1477
1364
  }
1478
1365
 
1479
- // src/utils/ssh.ts
1480
- import net from "net";
1481
- import { NodeSSH } from "node-ssh";
1482
- var SshConnection = class {
1483
- ssh = new NodeSSH();
1484
- _connected = false;
1485
- async connect(config2) {
1486
- const opts = {
1487
- host: config2.host,
1488
- port: config2.port,
1489
- username: config2.username,
1490
- // Reduce connection timeout to fail fast on bad creds
1491
- readyTimeout: 15e3
1492
- };
1493
- if (config2.privateKeyPath) {
1494
- opts.privateKeyPath = config2.privateKeyPath;
1495
- } else if (config2.password) {
1496
- opts.password = config2.password;
1497
- }
1498
- await this.ssh.connect(opts);
1499
- this._connected = true;
1366
+ // src/commands/status.ts
1367
+ import { log as log11 } from "@clack/prompts";
1368
+ import { execa as execa3 } from "execa";
1369
+ async function runStatus() {
1370
+ const dir = findInstallDir();
1371
+ if (!dir) {
1372
+ log11.error("No lead-routing.json found. Run `lead-routing init` first.");
1373
+ process.exit(1);
1500
1374
  }
1501
- get isConnected() {
1502
- return this._connected;
1375
+ const result = await execa3("docker", ["compose", "ps"], {
1376
+ cwd: dir,
1377
+ stdio: "inherit",
1378
+ reject: false
1379
+ });
1380
+ if (result.exitCode !== 0) {
1381
+ log11.error("Failed to get container status. Is Docker running?");
1382
+ process.exit(1);
1503
1383
  }
1504
- /**
1505
- * Run a command remotely. Throws if exit code is non-zero.
1506
- */
1507
- async exec(cmd, cwd) {
1508
- const result = await this.ssh.execCommand(cmd, cwd ? { cwd } : {});
1509
- if (result.code !== 0) {
1510
- throw new Error(
1511
- `Remote command failed (exit ${result.code}): ${cmd}
1512
- ${result.stderr || result.stdout}`
1513
- );
1514
- }
1515
- return { stdout: result.stdout, stderr: result.stderr };
1384
+ }
1385
+
1386
+ // src/commands/config.ts
1387
+ import { readFileSync as readFileSync4, writeFileSync as writeFileSync5, existsSync as existsSync3 } from "fs";
1388
+ import { join as join6 } from "path";
1389
+ import { intro as intro4, outro as outro4, log as log12 } from "@clack/prompts";
1390
+ import chalk4 from "chalk";
1391
+ function parseEnv(filePath) {
1392
+ const map = /* @__PURE__ */ new Map();
1393
+ if (!existsSync3(filePath)) return map;
1394
+ for (const line of readFileSync4(filePath, "utf8").split("\n")) {
1395
+ const trimmed = line.trim();
1396
+ if (!trimmed || trimmed.startsWith("#")) continue;
1397
+ const eq = trimmed.indexOf("=");
1398
+ if (eq === -1) continue;
1399
+ map.set(trimmed.slice(0, eq), trimmed.slice(eq + 1));
1516
1400
  }
1517
- /**
1518
- * Run a command remotely without throwing — returns code + output.
1519
- */
1520
- async execSilent(cmd, cwd) {
1521
- const result = await this.ssh.execCommand(cmd, cwd ? { cwd } : {});
1522
- return { stdout: result.stdout, stderr: result.stderr, code: result.code ?? 1 };
1401
+ return map;
1402
+ }
1403
+ async function runConfigSfdc() {
1404
+ intro4("Lead Routing \u2014 Salesforce Configuration");
1405
+ const dir = findInstallDir();
1406
+ if (!dir) {
1407
+ log12.error("No lead-routing installation found in the current directory.");
1408
+ log12.info("Run `lead-routing init` first, or cd into your installation directory.");
1409
+ process.exit(1);
1523
1410
  }
1524
- /**
1525
- * Create a remote directory (including parents).
1526
- */
1527
- async mkdir(remotePath) {
1528
- await this.exec(`mkdir -p ${remotePath}`);
1411
+ log12.info(
1412
+ "Salesforce credentials are now managed automatically via the managed package.\nNo manual configuration is needed.\n\nIf you need to reconnect Salesforce, go to the web app \u2192 Settings \u2192 Connect Salesforce."
1413
+ );
1414
+ outro4("No changes needed.");
1415
+ }
1416
+ function runConfigShow() {
1417
+ const dir = findInstallDir();
1418
+ if (!dir) {
1419
+ console.error("No lead-routing installation found in the current directory.");
1420
+ process.exit(1);
1529
1421
  }
1530
- /**
1531
- * Upload local files to the remote server via SFTP.
1532
- */
1533
- async upload(files) {
1534
- await this.ssh.putFiles(files);
1422
+ const envWeb = join6(dir, ".env.web");
1423
+ const cfg = parseEnv(envWeb);
1424
+ const adminSecret = cfg.get("ADMIN_SECRET") ?? "(not found)";
1425
+ const appUrl = cfg.get("APP_URL") ?? "(not found)";
1426
+ console.log();
1427
+ console.log(chalk4.bold("Lead Routing \u2014 Installation Config"));
1428
+ console.log();
1429
+ console.log(` Admin panel: ${chalk4.cyan(appUrl + "/admin")}`);
1430
+ console.log(` Admin secret: ${chalk4.yellow(adminSecret)}`);
1431
+ console.log();
1432
+ }
1433
+
1434
+ // src/commands/sfdc.ts
1435
+ import { intro as intro5, outro as outro5, text as text3, log as log15 } from "@clack/prompts";
1436
+ import chalk6 from "chalk";
1437
+
1438
+ // src/steps/sfdc-deploy-inline.ts
1439
+ import { readFileSync as readFileSync6, writeFileSync as writeFileSync6, existsSync as existsSync5, cpSync, rmSync } from "fs";
1440
+ import { join as join8, dirname as dirname2 } from "path";
1441
+ import { tmpdir as tmpdir2 } from "os";
1442
+ import { fileURLToPath as fileURLToPath2 } from "url";
1443
+ import { execSync } from "child_process";
1444
+ import { spinner as spinner5, log as log13 } from "@clack/prompts";
1445
+
1446
+ // src/utils/sfdc-api.ts
1447
+ var API_VERSION = "v59.0";
1448
+ var SalesforceApi = class {
1449
+ constructor(instanceUrl, accessToken) {
1450
+ this.instanceUrl = instanceUrl;
1451
+ this.accessToken = accessToken;
1452
+ this.baseUrl = `${instanceUrl.replace(/\/+$/, "")}/services/data/${API_VERSION}`;
1535
1453
  }
1536
- /**
1537
- * Resolve ~ in a remote path by querying $HOME on the server.
1538
- */
1539
- async resolveHome(remotePath) {
1540
- if (!remotePath.startsWith("~")) return remotePath;
1541
- const { stdout } = await this.exec("echo $HOME");
1542
- return stdout.trim() + remotePath.slice(1);
1454
+ baseUrl;
1455
+ headers(extra) {
1456
+ return {
1457
+ Authorization: `Bearer ${this.accessToken}`,
1458
+ "Content-Type": "application/json",
1459
+ ...extra
1460
+ };
1461
+ }
1462
+ /** Execute a SOQL query and return records */
1463
+ async query(soql) {
1464
+ const url = `${this.baseUrl}/query?q=${encodeURIComponent(soql)}`;
1465
+ const res = await fetch(url, { headers: this.headers() });
1466
+ if (!res.ok) {
1467
+ const body = await res.text();
1468
+ throw new Error(`SOQL query failed (${res.status}): ${body}`);
1469
+ }
1470
+ const data = await res.json();
1471
+ return data.records;
1472
+ }
1473
+ /** Create an sObject record, returns the new record ID */
1474
+ async create(sobject, data) {
1475
+ const url = `${this.baseUrl}/sobjects/${sobject}`;
1476
+ const res = await fetch(url, {
1477
+ method: "POST",
1478
+ headers: this.headers(),
1479
+ body: JSON.stringify(data)
1480
+ });
1481
+ if (!res.ok) {
1482
+ const body = await res.text();
1483
+ if (res.status === 400 && body.includes("Duplicate")) {
1484
+ throw new DuplicateError(body);
1485
+ }
1486
+ throw new Error(`Create ${sobject} failed (${res.status}): ${body}`);
1487
+ }
1488
+ const result = await res.json();
1489
+ return result.id;
1490
+ }
1491
+ /** Update an sObject record */
1492
+ async update(sobject, id, data) {
1493
+ const url = `${this.baseUrl}/sobjects/${sobject}/${id}`;
1494
+ const res = await fetch(url, {
1495
+ method: "PATCH",
1496
+ headers: this.headers(),
1497
+ body: JSON.stringify(data)
1498
+ });
1499
+ if (!res.ok) {
1500
+ const body = await res.text();
1501
+ throw new Error(`Update ${sobject}/${id} failed (${res.status}): ${body}`);
1502
+ }
1503
+ }
1504
+ /** Get current user info (for permission set assignment) */
1505
+ async getCurrentUserId() {
1506
+ const url = `${this.instanceUrl.replace(/\/+$/, "")}/services/oauth2/userinfo`;
1507
+ const res = await fetch(url, {
1508
+ headers: { Authorization: `Bearer ${this.accessToken}` }
1509
+ });
1510
+ if (!res.ok) {
1511
+ const body = await res.text();
1512
+ throw new Error(`Get current user failed (${res.status}): ${body}`);
1513
+ }
1514
+ const data = await res.json();
1515
+ return data.user_id;
1543
1516
  }
1544
1517
  /**
1545
- * Open an SSH port-forward tunnel.
1546
- * Creates a local TCP server that pipes connections through to
1547
- * localhost:{remotePort} on the remote machine.
1548
- *
1549
- * Returns the local port and a close() function.
1550
- * Call close() when migrations are done.
1518
+ * Deploy metadata using the Source Deploy API (same API that `sf project deploy start` uses).
1519
+ * Accepts a ZIP buffer containing the source-format package.
1520
+ * Returns the deploy request ID for polling.
1551
1521
  */
1552
- async tunnel(remotePort) {
1553
- const sshClient = this.ssh.connection;
1554
- if (!sshClient) throw new Error("SSH not connected \u2014 cannot open tunnel");
1555
- const server = net.createServer((socket) => {
1556
- ;
1557
- sshClient.forwardOut(
1558
- "127.0.0.1",
1559
- 0,
1560
- "localhost",
1561
- remotePort,
1562
- (err, stream) => {
1563
- if (err) {
1564
- socket.destroy();
1565
- return;
1566
- }
1567
- socket.pipe(stream);
1568
- stream.pipe(socket);
1569
- socket.on("close", () => stream.destroy());
1570
- stream.on("close", () => socket.destroy());
1571
- }
1572
- );
1522
+ async deployMetadata(zipBuffer) {
1523
+ const url = `${this.baseUrl}/metadata/deployRequest`;
1524
+ const boundary = `----FormBoundary${Date.now()}`;
1525
+ const deployOptions = JSON.stringify({
1526
+ deployOptions: {
1527
+ rollbackOnError: true,
1528
+ singlePackage: true
1529
+ }
1573
1530
  });
1574
- return new Promise((resolve, reject) => {
1575
- server.listen(0, "127.0.0.1", () => {
1576
- const { port } = server.address();
1577
- resolve({
1578
- localPort: port,
1579
- close: () => server.close()
1580
- });
1581
- });
1582
- server.on("error", reject);
1531
+ const parts = [];
1532
+ parts.push(
1533
+ Buffer.from(
1534
+ `--${boundary}\r
1535
+ Content-Disposition: form-data; name="json"\r
1536
+ Content-Type: application/json\r
1537
+ \r
1538
+ ${deployOptions}\r
1539
+ `
1540
+ )
1541
+ );
1542
+ parts.push(
1543
+ Buffer.from(
1544
+ `--${boundary}\r
1545
+ Content-Disposition: form-data; name="file"; filename="deploy.zip"\r
1546
+ Content-Type: application/zip\r
1547
+ \r
1548
+ `
1549
+ )
1550
+ );
1551
+ parts.push(zipBuffer);
1552
+ parts.push(Buffer.from(`\r
1553
+ --${boundary}--\r
1554
+ `));
1555
+ const body = Buffer.concat(parts);
1556
+ const res = await fetch(url, {
1557
+ method: "POST",
1558
+ headers: {
1559
+ Authorization: `Bearer ${this.accessToken}`,
1560
+ "Content-Type": `multipart/form-data; boundary=${boundary}`
1561
+ },
1562
+ body
1583
1563
  });
1564
+ if (!res.ok) {
1565
+ const text4 = await res.text();
1566
+ throw new Error(
1567
+ `Metadata deploy request failed (${res.status}): ${text4}`
1568
+ );
1569
+ }
1570
+ const result = await res.json();
1571
+ return result.id;
1584
1572
  }
1585
- async disconnect() {
1586
- if (this._connected) {
1587
- this.ssh.dispose();
1588
- this._connected = false;
1573
+ /**
1574
+ * Poll deploy status until complete.
1575
+ * Returns deploy result with success/failure info.
1576
+ */
1577
+ async waitForDeploy(deployId, timeoutMs = 3e5) {
1578
+ const startTime = Date.now();
1579
+ const pollInterval = 3e3;
1580
+ while (Date.now() - startTime < timeoutMs) {
1581
+ const url = `${this.baseUrl}/metadata/deployRequest/${deployId}?includeDetails=true`;
1582
+ const res = await fetch(url, { headers: this.headers() });
1583
+ if (!res.ok) {
1584
+ const text4 = await res.text();
1585
+ throw new Error(
1586
+ `Deploy status check failed (${res.status}): ${text4}`
1587
+ );
1588
+ }
1589
+ const data = await res.json();
1590
+ const result = data.deployResult;
1591
+ if (result.done) {
1592
+ return result;
1593
+ }
1594
+ await new Promise((resolve) => setTimeout(resolve, pollInterval));
1589
1595
  }
1596
+ throw new Error(`Deploy timed out after ${timeoutMs / 1e3}s`);
1597
+ }
1598
+ };
1599
+ var DuplicateError = class extends Error {
1600
+ constructor(message) {
1601
+ super(message);
1602
+ this.name = "DuplicateError";
1590
1603
  }
1591
1604
  };
1592
1605
 
1593
- // src/commands/init.ts
1594
- async function checkDnsResolvable(appUrl, engineUrl) {
1595
- let hosts;
1606
+ // src/utils/zip-source.ts
1607
+ import { join as join7 } from "path";
1608
+ import { readdirSync, readFileSync as readFileSync5, existsSync as existsSync4 } from "fs";
1609
+ import archiver from "archiver";
1610
+ var META_TYPE_MAP = {
1611
+ applications: "CustomApplication",
1612
+ classes: "ApexClass",
1613
+ triggers: "ApexTrigger",
1614
+ lwc: "LightningComponentBundle",
1615
+ permissionsets: "PermissionSet",
1616
+ namedCredentials: "NamedCredential",
1617
+ remoteSiteSettings: "RemoteSiteSetting",
1618
+ tabs: "CustomTab"
1619
+ };
1620
+ async function zipSourcePackage(packageDir) {
1621
+ const forceAppDefault = join7(packageDir, "force-app", "main", "default");
1622
+ let apiVersion = "59.0";
1596
1623
  try {
1597
- hosts = [.../* @__PURE__ */ new Set([new URL(appUrl).hostname, new URL(engineUrl).hostname])];
1624
+ const proj = JSON.parse(readFileSync5(join7(packageDir, "sfdx-project.json"), "utf8"));
1625
+ if (proj.sourceApiVersion) apiVersion = proj.sourceApiVersion;
1598
1626
  } catch {
1599
- return;
1600
- }
1601
- for (const host of hosts) {
1602
- try {
1603
- await dns.lookup(host);
1604
- } catch {
1605
- log9.warn(
1606
- `${chalk2.yellow(host)} does not resolve in DNS yet.
1607
- Check for typos \u2014 a bad domain will cause a 2-minute timeout at step 8.`
1608
- );
1609
- const go = await confirm2({ message: "Continue anyway?", initialValue: true });
1610
- if (isCancel4(go) || !go) {
1611
- cancel3("Setup cancelled.");
1612
- process.exit(0);
1613
- }
1614
- }
1615
1627
  }
1616
- }
1617
- async function runInit(options = {}) {
1618
- const dryRun = options.dryRun ?? false;
1619
- const resume = options.resume ?? false;
1620
- console.log();
1621
- intro(
1622
- chalk2.bold.cyan("Lead Routing \u2014 Self-Hosted Setup") + (dryRun ? chalk2.yellow(" [dry run]") : "") + (resume ? chalk2.yellow(" [resume]") : "")
1623
- );
1624
- const ssh = new SshConnection();
1625
- if (resume) {
1626
- try {
1627
- const dir = findInstallDir();
1628
- if (!dir) {
1629
- log9.error("No lead-routing.json found \u2014 run `lead-routing init` first.");
1630
- process.exit(1);
1628
+ const members = /* @__PURE__ */ new Map();
1629
+ const addMember = (type, name) => {
1630
+ if (!members.has(type)) members.set(type, /* @__PURE__ */ new Set());
1631
+ members.get(type).add(name);
1632
+ };
1633
+ return new Promise((resolve, reject) => {
1634
+ const chunks = [];
1635
+ const archive = archiver("zip", { zlib: { level: 9 } });
1636
+ archive.on("data", (chunk) => chunks.push(chunk));
1637
+ archive.on("end", () => resolve(Buffer.concat(chunks)));
1638
+ archive.on("error", reject);
1639
+ for (const [dirName, metaType] of Object.entries(META_TYPE_MAP)) {
1640
+ const srcDir = join7(forceAppDefault, dirName);
1641
+ if (!existsSync4(srcDir)) continue;
1642
+ const entries = readdirSync(srcDir, { withFileTypes: true });
1643
+ for (const entry of entries) {
1644
+ if (dirName === "lwc" && entry.isDirectory()) {
1645
+ addMember(metaType, entry.name);
1646
+ archive.directory(join7(srcDir, entry.name), `${dirName}/${entry.name}`);
1647
+ } else if (entry.isFile()) {
1648
+ archive.file(join7(srcDir, entry.name), { name: `${dirName}/${entry.name}` });
1649
+ if (!entry.name.endsWith("-meta.xml")) {
1650
+ const memberName = entry.name.replace(/\.[^.]+$/, "");
1651
+ addMember(metaType, memberName);
1652
+ }
1653
+ }
1631
1654
  }
1632
- const saved = readConfig(dir);
1633
- let sshPassword;
1634
- if (!saved.ssh.privateKeyPath) {
1635
- const pw = await promptPassword({
1636
- message: `SSH password for ${saved.ssh.username}@${saved.ssh.host}`
1655
+ }
1656
+ const objectsDir = join7(forceAppDefault, "objects");
1657
+ if (existsSync4(objectsDir)) {
1658
+ for (const objEntry of readdirSync(objectsDir, { withFileTypes: true })) {
1659
+ if (!objEntry.isDirectory()) continue;
1660
+ const objName = objEntry.name;
1661
+ addMember("CustomObject", objName);
1662
+ const objDir = join7(objectsDir, objName);
1663
+ const objectXml = mergeObjectXml(objDir, objName, apiVersion);
1664
+ archive.append(Buffer.from(objectXml, "utf8"), {
1665
+ name: `objects/${objName}.object`
1637
1666
  });
1638
- if (typeof pw === "symbol") process.exit(0);
1639
- sshPassword = pw;
1640
1667
  }
1641
- log9.step("Connecting to server");
1642
- await ssh.connect({
1643
- host: saved.ssh.host,
1644
- port: saved.ssh.port,
1645
- username: saved.ssh.username,
1646
- privateKeyPath: saved.ssh.privateKeyPath,
1647
- password: sshPassword,
1648
- remoteDir: saved.remoteDir
1649
- });
1650
- log9.success(`Connected to ${saved.ssh.host}`);
1651
- const remoteDir = await ssh.resolveHome(saved.remoteDir);
1652
- log9.step("Step 7/8 Verifying health");
1653
- await verifyHealth(saved.appUrl, saved.engineUrl, ssh, remoteDir);
1654
- log9.step("Step 8/8 Deploying Salesforce package");
1655
- await sfdcDeployInline({
1656
- appUrl: saved.appUrl,
1657
- engineUrl: saved.engineUrl,
1658
- orgAlias: "lead-routing",
1659
- sfdcClientId: saved.sfdcClientId ?? "",
1660
- sfdcLoginUrl: saved.sfdcLoginUrl ?? "https://login.salesforce.com",
1661
- installDir: dir,
1662
- webhookSecret: saved.engineWebhookSecret
1663
- });
1664
- await guideAppLauncherSetup(saved.appUrl);
1665
- outro(
1666
- chalk2.green("\u2714 You're live!") + `
1667
-
1668
- Dashboard: ${chalk2.cyan(saved.appUrl)}
1669
- Routing engine: ${chalk2.cyan(saved.engineUrl)}
1670
-
1671
- ` + chalk2.bold(" Next steps:\n") + ` ${chalk2.cyan("1.")} Open ${chalk2.cyan(saved.appUrl)} and log in
1672
- ${chalk2.cyan("2.")} Create your first routing rule to start routing leads
1673
-
1674
- Run ${chalk2.cyan("lead-routing doctor")} to check service health at any time.
1675
- Run ${chalk2.cyan("lead-routing deploy")} to update to a new version.`
1676
- );
1677
- } catch (err) {
1678
- const message = err instanceof Error ? err.message : String(err);
1679
- log9.error(`Resume failed: ${message}`);
1680
- process.exit(1);
1681
- } finally {
1682
- await ssh.disconnect();
1683
1668
  }
1684
- return;
1669
+ const packageXml = generatePackageXml(members, apiVersion);
1670
+ archive.append(Buffer.from(packageXml, "utf8"), { name: "package.xml" });
1671
+ archive.finalize();
1672
+ });
1673
+ }
1674
+ function mergeObjectXml(objDir, objName, apiVersion) {
1675
+ const lines = [
1676
+ '<?xml version="1.0" encoding="UTF-8"?>',
1677
+ '<CustomObject xmlns="http://soap.sforce.com/2006/04/metadata">'
1678
+ ];
1679
+ const objMetaPath = join7(objDir, `${objName}.object-meta.xml`);
1680
+ if (existsSync4(objMetaPath)) {
1681
+ const content = readFileSync5(objMetaPath, "utf8");
1682
+ const inner = content.replace(/<\?xml[^?]*\?>\s*/g, "").replace(/<CustomObject[^>]*>/g, "").replace(/<\/CustomObject>/g, "").trim();
1683
+ if (inner) lines.push(inner);
1685
1684
  }
1686
- try {
1687
- log9.step("Step 1/8 Checking local prerequisites");
1688
- await checkPrerequisites();
1689
- log9.step("Step 2/8 SSH connection");
1690
- const sshCfg = await collectSshConfig({
1691
- sshPort: options.sshPort,
1692
- sshUser: options.sshUser,
1693
- sshKey: options.sshKey,
1694
- remoteDir: options.remoteDir
1695
- });
1696
- if (!dryRun) {
1697
- try {
1698
- await ssh.connect(sshCfg);
1699
- log9.success(`Connected to ${sshCfg.host}`);
1700
- } catch (err) {
1701
- log9.error(`SSH connection failed: ${String(err)}`);
1702
- log9.info("Check your password and re-run `lead-routing init`.");
1703
- process.exit(1);
1685
+ const fieldsDir = join7(objDir, "fields");
1686
+ if (existsSync4(fieldsDir)) {
1687
+ for (const fieldFile of readdirSync(fieldsDir).sort()) {
1688
+ if (!fieldFile.endsWith(".field-meta.xml")) continue;
1689
+ const content = readFileSync5(join7(fieldsDir, fieldFile), "utf8");
1690
+ const inner = content.replace(/<\?xml[^?]*\?>\s*/g, "").replace(/<CustomField[^>]*>/g, "").replace(/<\/CustomField>/g, "").trim();
1691
+ if (inner) {
1692
+ lines.push(" <fields>");
1693
+ lines.push(` ${inner}`);
1694
+ lines.push(" </fields>");
1704
1695
  }
1705
1696
  }
1706
- log9.step("Step 3/8 Configuration");
1707
- const cfg = await collectConfig({
1708
- sandbox: options.sandbox,
1709
- externalDb: options.externalDb,
1710
- externalRedis: options.externalRedis
1711
- });
1712
- await checkDnsResolvable(cfg.appUrl, cfg.engineUrl);
1713
- log9.step("Step 4/8 Generating config files");
1714
- const { dir, adminSecret } = generateFiles(cfg, sshCfg);
1715
- note4(
1716
- `Local config directory: ${chalk2.cyan(dir)}
1717
- Files created: docker-compose.yml, Caddyfile, .env.web, .env.engine, lead-routing.json`,
1718
- "Files"
1719
- );
1720
- if (dryRun) {
1721
- outro(
1722
- chalk2.yellow("Dry run complete \u2014 no connection made, no services started.") + `
1723
-
1724
- Config files written to: ${chalk2.cyan(dir)}
1725
-
1726
- When ready, run ${chalk2.cyan("lead-routing init")} (without --dry-run) to deploy.`
1727
- );
1728
- return;
1729
- }
1730
- log9.step("Step 5/8 Remote setup");
1731
- const remoteDir = await ssh.resolveHome(sshCfg.remoteDir);
1732
- await checkRemotePrerequisites(ssh);
1733
- await uploadFiles(ssh, dir, remoteDir);
1734
- log9.step("Step 6/8 Starting services");
1735
- await startServices(ssh, remoteDir);
1736
- log9.step("Step 7/8 Verifying health");
1737
- await verifyHealth(cfg.appUrl, cfg.engineUrl, ssh, remoteDir);
1738
- try {
1739
- const envWebPath = join6(dir, ".env.web");
1740
- const envContent = readFileSync5(envWebPath, "utf-8");
1741
- const cleaned = envContent.split("\n").filter((line) => !line.startsWith("ADMIN_PASSWORD=")).join("\n");
1742
- writeFileSync4(envWebPath, cleaned, "utf-8");
1743
- log9.success("Removed ADMIN_PASSWORD from .env.web (no longer needed after seed)");
1744
- } catch {
1745
- }
1746
- log9.step("Step 8/8 Deploying Salesforce package");
1747
- await sfdcDeployInline({
1748
- appUrl: cfg.appUrl,
1749
- engineUrl: cfg.engineUrl,
1750
- orgAlias: "lead-routing",
1751
- sfdcClientId: cfg.sfdcClientId,
1752
- sfdcLoginUrl: cfg.sfdcLoginUrl,
1753
- installDir: dir,
1754
- webhookSecret: cfg.engineWebhookSecret
1755
- });
1756
- await guideAppLauncherSetup(cfg.appUrl);
1757
- outro(
1758
- chalk2.green("\u2714 You're live!") + `
1759
-
1760
- Dashboard: ${chalk2.cyan(cfg.appUrl)}
1761
- Routing engine: ${chalk2.cyan(cfg.engineUrl)}
1762
-
1763
- Admin email: ${chalk2.white(cfg.adminEmail)}
1764
- Admin secret: ${chalk2.yellow(adminSecret)}
1765
- ${chalk2.dim("run `lead-routing config show` to retrieve later")}
1766
-
1767
- ` + chalk2.bold(" Next steps:\n") + ` ${chalk2.cyan("1.")} Open ${chalk2.cyan(cfg.appUrl)} and log in
1768
- ${chalk2.cyan("2.")} Create your first routing rule to start routing leads
1769
-
1770
- Run ${chalk2.cyan("lead-routing doctor")} to check service health at any time.
1771
- Run ${chalk2.cyan("lead-routing deploy")} to update to a new version.`
1772
- );
1773
- } catch (err) {
1774
- const message = err instanceof Error ? err.message : String(err);
1775
- log9.error(`Setup failed: ${message}`);
1776
- process.exit(1);
1777
- } finally {
1778
- await ssh.disconnect();
1779
1697
  }
1698
+ lines.push("</CustomObject>");
1699
+ return lines.join("\n");
1780
1700
  }
1781
-
1782
- // src/commands/deploy.ts
1783
- import { writeFileSync as writeFileSync5, unlinkSync } from "fs";
1784
- import { join as join7 } from "path";
1785
- import { tmpdir as tmpdir2 } from "os";
1786
- import { intro as intro2, outro as outro2, log as log10, password as promptPassword2 } from "@clack/prompts";
1787
- import chalk3 from "chalk";
1788
- async function runDeploy() {
1789
- console.log();
1790
- intro2(chalk3.bold.cyan("Lead Routing \u2014 Deploy"));
1791
- const dir = findInstallDir();
1792
- if (!dir) {
1793
- log10.error(
1794
- "No lead-routing.json found. Run `lead-routing init` first, or run this command from your install directory."
1795
- );
1796
- process.exit(1);
1797
- }
1798
- const cfg = readConfig(dir);
1799
- const ssh = new SshConnection();
1800
- let sshPassword;
1801
- if (!cfg.ssh.privateKeyPath) {
1802
- const pw = await promptPassword2({
1803
- message: `SSH password for ${cfg.ssh.username}@${cfg.ssh.host}`
1804
- });
1805
- if (typeof pw === "symbol") process.exit(0);
1806
- sshPassword = pw;
1807
- }
1808
- try {
1809
- await ssh.connect({
1810
- host: cfg.ssh.host,
1811
- port: cfg.ssh.port,
1812
- username: cfg.ssh.username,
1813
- privateKeyPath: cfg.ssh.privateKeyPath,
1814
- password: sshPassword,
1815
- remoteDir: cfg.remoteDir
1816
- });
1817
- log10.success(`Connected to ${cfg.ssh.host}`);
1818
- } catch (err) {
1819
- log10.error(`SSH connection failed: ${String(err)}`);
1820
- process.exit(1);
1821
- }
1822
- try {
1823
- const remoteDir = await ssh.resolveHome(cfg.remoteDir);
1824
- log10.step("Syncing Caddyfile");
1825
- const caddyContent = renderCaddyfile(cfg.appUrl, cfg.engineUrl);
1826
- const tmpCaddy = join7(tmpdir2(), "lead-routing-Caddyfile");
1827
- writeFileSync5(tmpCaddy, caddyContent, "utf8");
1828
- await ssh.upload([{ local: tmpCaddy, remote: `${remoteDir}/Caddyfile` }]);
1829
- unlinkSync(tmpCaddy);
1830
- await ssh.exec("docker compose restart caddy", remoteDir);
1831
- log10.success("Caddyfile synced \u2014 waiting for TLS cert (~30s)");
1832
- log10.step("Pulling latest Docker images");
1833
- await ssh.exec("docker compose pull", remoteDir);
1834
- log10.success("Images pulled");
1835
- log10.step("Restarting services");
1836
- await ssh.exec("docker compose up -d --remove-orphans", remoteDir);
1837
- log10.success("Services restarted");
1838
- outro2(
1839
- chalk3.green("\u2714 Deployment complete!") + `
1840
-
1841
- ${chalk3.cyan(cfg.appUrl)}`
1842
- );
1843
- } catch (err) {
1844
- const message = err instanceof Error ? err.message : String(err);
1845
- log10.error(`Deploy failed: ${message}`);
1846
- process.exit(1);
1847
- } finally {
1848
- await ssh.disconnect();
1701
+ function generatePackageXml(members, apiVersion) {
1702
+ const lines = [
1703
+ '<?xml version="1.0" encoding="UTF-8"?>',
1704
+ '<Package xmlns="http://soap.sforce.com/2006/04/metadata">'
1705
+ ];
1706
+ for (const [metaType, names] of [...members.entries()].sort((a, b) => a[0].localeCompare(b[0]))) {
1707
+ lines.push(" <types>");
1708
+ for (const name of [...names].sort()) {
1709
+ lines.push(` <members>${name}</members>`);
1710
+ }
1711
+ lines.push(` <name>${metaType}</name>`);
1712
+ lines.push(" </types>");
1849
1713
  }
1714
+ lines.push(` <version>${apiVersion}</version>`);
1715
+ lines.push("</Package>");
1716
+ return lines.join("\n");
1850
1717
  }
1851
1718
 
1852
- // src/commands/doctor.ts
1853
- import { intro as intro3, outro as outro3, log as log11 } from "@clack/prompts";
1854
- import chalk4 from "chalk";
1855
- import { execa } from "execa";
1856
- async function runDoctor() {
1857
- console.log();
1858
- intro3(chalk4.bold.cyan("Lead Routing \u2014 Health Check"));
1859
- const dir = findInstallDir();
1860
- if (!dir) {
1861
- log11.error("No lead-routing.json found. Run `lead-routing init` first.");
1862
- process.exit(1);
1719
+ // src/steps/sfdc-deploy-inline.ts
1720
+ var __dirname2 = dirname2(fileURLToPath2(import.meta.url));
1721
+ function patchXml(content, tag, value) {
1722
+ const re = new RegExp(`(<${tag}>)[^<]*(</\\s*${tag}>)`, "g");
1723
+ return content.replace(re, `$1${value}$2`);
1724
+ }
1725
+ async function sfdcDeployInline(params) {
1726
+ const { appUrl, engineUrl, installDir } = params;
1727
+ const s = spinner5();
1728
+ const { accessToken, instanceUrl } = await loginViaAppBridge(appUrl);
1729
+ const sf = new SalesforceApi(instanceUrl, accessToken);
1730
+ s.start("Copying Salesforce package\u2026");
1731
+ const inDist = join8(__dirname2, "sfdc-package");
1732
+ const nextToDist = join8(__dirname2, "..", "sfdc-package");
1733
+ const bundledPkg = existsSync5(inDist) ? inDist : nextToDist;
1734
+ const destPkg = join8(installDir ?? tmpdir2(), "lead-routing-sfdc-package");
1735
+ if (!existsSync5(bundledPkg)) {
1736
+ s.stop("sfdc-package not found in CLI bundle");
1737
+ throw new Error(
1738
+ `Expected bundle at: ${inDist}
1739
+ The CLI may need to be reinstalled: npm i -g @lead-routing/cli`
1740
+ );
1863
1741
  }
1864
- const cfg = readConfig(dir);
1865
- const checks = [];
1866
- checks.push(await checkDockerDaemon());
1867
- const containers = ["web", "engine"];
1868
- if (cfg.dockerManaged.db) containers.push("postgres");
1869
- if (cfg.dockerManaged.redis) containers.push("redis");
1870
- for (const name of containers) {
1871
- checks.push(await checkContainer(name, dir));
1742
+ if (existsSync5(destPkg)) rmSync(destPkg, { recursive: true, force: true });
1743
+ cpSync(bundledPkg, destPkg, { recursive: true });
1744
+ s.stop("Package copied");
1745
+ const ncPath = join8(
1746
+ destPkg,
1747
+ "force-app",
1748
+ "main",
1749
+ "default",
1750
+ "namedCredentials",
1751
+ "RoutingEngine.namedCredential-meta.xml"
1752
+ );
1753
+ if (existsSync5(ncPath)) {
1754
+ const nc = patchXml(readFileSync6(ncPath, "utf8"), "endpoint", engineUrl);
1755
+ writeFileSync6(ncPath, nc, "utf8");
1872
1756
  }
1873
- checks.push(await checkEndpoint("Web app", `${cfg.appUrl}/api/health`));
1874
- checks.push(await checkEndpoint("Routing engine", `${cfg.engineUrl}/health`));
1875
- console.log();
1876
- for (const c of checks) {
1877
- const icon = c.pass ? chalk4.green("\u2714") : chalk4.red("\u2717");
1878
- const label = c.pass ? chalk4.white(c.label) : chalk4.red(c.label);
1879
- const detail = c.detail ? chalk4.dim(` \u2014 ${c.detail}`) : "";
1880
- console.log(` ${icon} ${label}${detail}`);
1757
+ const rssEnginePath = join8(
1758
+ destPkg,
1759
+ "force-app",
1760
+ "main",
1761
+ "default",
1762
+ "remoteSiteSettings",
1763
+ "LeadRouterEngine.remoteSite-meta.xml"
1764
+ );
1765
+ if (existsSync5(rssEnginePath)) {
1766
+ let rss = patchXml(readFileSync6(rssEnginePath, "utf8"), "url", engineUrl);
1767
+ rss = patchXml(rss, "description", "Lead Router Engine endpoint");
1768
+ writeFileSync6(rssEnginePath, rss, "utf8");
1881
1769
  }
1882
- console.log();
1883
- const failed = checks.filter((c) => !c.pass);
1884
- if (failed.length === 0) {
1885
- outro3(chalk4.green("All checks passed"));
1886
- } else {
1887
- outro3(chalk4.yellow(`${failed.length} check(s) failed`));
1888
- process.exit(1);
1770
+ const rssAppPath = join8(
1771
+ destPkg,
1772
+ "force-app",
1773
+ "main",
1774
+ "default",
1775
+ "remoteSiteSettings",
1776
+ "LeadRouterApp.remoteSite-meta.xml"
1777
+ );
1778
+ if (existsSync5(rssAppPath)) {
1779
+ let rss = patchXml(readFileSync6(rssAppPath, "utf8"), "url", appUrl);
1780
+ rss = patchXml(rss, "description", "Lead Router App URL");
1781
+ writeFileSync6(rssAppPath, rss, "utf8");
1889
1782
  }
1890
- }
1891
- async function checkDockerDaemon() {
1783
+ log13.success("Remote Site Settings patched");
1784
+ s.start("Deploying Salesforce package (this may take ~2 min)\u2026");
1892
1785
  try {
1893
- await execa("docker", ["info"], { reject: true });
1894
- return { label: "Docker daemon", pass: true };
1895
- } catch {
1896
- return { label: "Docker daemon", pass: false, detail: "not running" };
1786
+ const zipBuffer = await zipSourcePackage(destPkg);
1787
+ const deployId = await sf.deployMetadata(zipBuffer);
1788
+ const result = await sf.waitForDeploy(deployId);
1789
+ if (!result.success) {
1790
+ const failures = result.details?.componentFailures ?? [];
1791
+ const failureMsg = failures.map((f) => ` ${f.componentType}/${f.fullName}: ${f.problem}`).join("\n");
1792
+ s.stop("Deployment failed");
1793
+ throw new Error(
1794
+ `Metadata deploy failed (${result.numberComponentErrors} error(s)):
1795
+ ${failureMsg || result.errorMessage || "Unknown error"}`
1796
+ );
1797
+ }
1798
+ s.stop(`Package deployed (${result.numberComponentsDeployed} components)`);
1799
+ } catch (err) {
1800
+ if (err instanceof Error && err.message.startsWith("Metadata deploy failed")) {
1801
+ throw err;
1802
+ }
1803
+ s.stop("Deployment failed");
1804
+ throw new Error(
1805
+ `Metadata deploy failed: ${String(err)}
1806
+
1807
+ The patched package is at: ${destPkg}
1808
+ You can retry with: sf project deploy start --source-dir force-app`
1809
+ );
1897
1810
  }
1898
- }
1899
- async function checkContainer(name, dir) {
1811
+ s.start("Assigning LeadRouterAdmin permission set\u2026");
1900
1812
  try {
1901
- const result = await execa(
1902
- "docker",
1903
- ["compose", "ps", "--format", "json", name],
1904
- { cwd: dir, reject: false }
1813
+ const permSets = await sf.query(
1814
+ "SELECT Id FROM PermissionSet WHERE Name = 'LeadRouterAdmin' LIMIT 1"
1905
1815
  );
1906
- const output = result.stdout.trim();
1907
- if (!output) {
1908
- return { label: `Container: ${name}`, pass: false, detail: "not found" };
1909
- }
1910
- const rows = output.split("\n").map((l) => {
1816
+ if (permSets.length === 0) {
1817
+ s.stop("LeadRouterAdmin permission set not found (non-fatal)");
1818
+ log13.warn("The permission set may not have been included in the deploy.");
1819
+ } else {
1820
+ const userId = await sf.getCurrentUserId();
1911
1821
  try {
1912
- return JSON.parse(l);
1913
- } catch {
1914
- return null;
1822
+ await sf.create("PermissionSetAssignment", {
1823
+ AssigneeId: userId,
1824
+ PermissionSetId: permSets[0].Id
1825
+ });
1826
+ s.stop("Permission set assigned \u2014 Lead Router Setup will appear in the App Launcher");
1827
+ } catch (err) {
1828
+ if (err instanceof DuplicateError) {
1829
+ s.stop("Permission set already assigned");
1830
+ } else {
1831
+ throw err;
1832
+ }
1915
1833
  }
1916
- }).filter(Boolean);
1917
- const running = rows.some(
1918
- (r) => r.State === "running" || r.Status && r.Status.toLowerCase().includes("up")
1834
+ }
1835
+ } catch (err) {
1836
+ if (!(err instanceof DuplicateError)) {
1837
+ s.stop("Permission set assignment failed (non-fatal)");
1838
+ log13.warn(String(err));
1839
+ log13.info(
1840
+ "Grant access manually:\n Salesforce Setup \u2192 Users \u2192 Permission Sets \u2192 Lead Router Admin \u2192 Manage Assignments"
1841
+ );
1842
+ }
1843
+ }
1844
+ s.start("Writing org settings to Routing_Settings__c\u2026");
1845
+ try {
1846
+ const existing = await sf.query(
1847
+ "SELECT Id FROM Routing_Settings__c LIMIT 1"
1919
1848
  );
1920
- return {
1921
- label: `Container: ${name}`,
1922
- pass: running,
1923
- detail: running ? "running" : "not running"
1849
+ const settingsData = {
1850
+ App_Url__c: appUrl,
1851
+ Engine_Endpoint__c: engineUrl
1924
1852
  };
1925
- } catch {
1926
- return { label: `Container: ${name}`, pass: false, detail: "error checking status" };
1853
+ if (params.webhookSecret) {
1854
+ settingsData.Webhook_Secret__c = params.webhookSecret;
1855
+ }
1856
+ if (existing.length > 0) {
1857
+ await sf.update("Routing_Settings__c", existing[0].Id, settingsData);
1858
+ } else {
1859
+ await sf.create("Routing_Settings__c", settingsData);
1860
+ }
1861
+ s.stop("Org settings written");
1862
+ } catch (err) {
1863
+ s.stop("Org settings write failed (non-fatal)");
1864
+ log13.warn(String(err));
1865
+ log13.info("Set manually: Salesforce \u2192 Custom Settings \u2192 Routing Settings \u2192 Manage");
1927
1866
  }
1928
1867
  }
1929
- async function checkEndpoint(label, url) {
1868
+ async function loginViaAppBridge(rawAppUrl) {
1869
+ const appUrl = rawAppUrl.replace(/\/+$/, "");
1870
+ const s = spinner5();
1871
+ s.start("Starting Salesforce authentication via your Lead Router app\u2026");
1872
+ let sessionId;
1873
+ let authUrl;
1930
1874
  try {
1931
- const res = await fetch(url, { signal: AbortSignal.timeout(5e3) });
1932
- return {
1933
- label: `Health: ${label}`,
1934
- pass: res.ok,
1935
- detail: `HTTP ${res.status}`
1936
- };
1875
+ const res = await fetch(`${appUrl}/api/cli-auth/request`, { method: "POST" });
1876
+ if (!res.ok) {
1877
+ s.stop("Failed to start auth session");
1878
+ throw new Error(
1879
+ `Could not reach ${appUrl}/api/cli-auth/request (HTTP ${res.status}).
1880
+ Make sure the web app is running and accessible.`
1881
+ );
1882
+ }
1883
+ const data = await res.json();
1884
+ sessionId = data.sessionId;
1885
+ authUrl = data.authUrl;
1937
1886
  } catch (err) {
1938
- const detail = err instanceof Error ? err.message : String(err);
1939
- return { label: `Health: ${label}`, pass: false, detail };
1887
+ s.stop("Could not reach Lead Router app");
1888
+ throw new Error(
1889
+ `Failed to connect to ${appUrl}: ${String(err)}
1890
+ Ensure the app is running and the URL is correct.`
1891
+ );
1940
1892
  }
1941
- }
1893
+ s.stop("Auth session started");
1894
+ log13.info(`Open this URL in your browser to authenticate with Salesforce:
1942
1895
 
1943
- // src/commands/logs.ts
1944
- import { log as log12 } from "@clack/prompts";
1945
- import { execa as execa2 } from "execa";
1946
- var VALID_SERVICES = ["web", "engine", "postgres", "redis"];
1947
- async function runLogs(service = "engine") {
1948
- if (!VALID_SERVICES.includes(service)) {
1949
- log12.error(`Unknown service "${service}". Valid options: ${VALID_SERVICES.join(", ")}`);
1950
- process.exit(1);
1951
- }
1952
- const dir = findInstallDir();
1953
- if (!dir) {
1954
- log12.error("No lead-routing.json found. Run `lead-routing init` first.");
1955
- process.exit(1);
1956
- }
1957
- console.log(`
1958
- Streaming logs for ${service} (Ctrl+C to stop)...
1896
+ ${authUrl}
1959
1897
  `);
1960
- const child = execa2("docker", ["compose", "logs", "-f", "--tail=100", service], {
1961
- cwd: dir,
1962
- stdio: "inherit",
1963
- reject: false
1964
- });
1965
- await child;
1966
- }
1967
-
1968
- // src/commands/status.ts
1969
- import { log as log13 } from "@clack/prompts";
1970
- import { execa as execa3 } from "execa";
1971
- async function runStatus() {
1972
- const dir = findInstallDir();
1973
- if (!dir) {
1974
- log13.error("No lead-routing.json found. Run `lead-routing init` first.");
1975
- process.exit(1);
1898
+ log13.info('If Chrome shows a "Dangerous site" warning with no proceed option, paste the URL into Safari or Firefox.');
1899
+ const opener = process.platform === "win32" ? "start" : process.platform === "darwin" ? "open" : "xdg-open";
1900
+ try {
1901
+ execSync(`${opener} "${authUrl}"`, { stdio: "ignore" });
1902
+ } catch {
1976
1903
  }
1977
- const result = await execa3("docker", ["compose", "ps"], {
1978
- cwd: dir,
1979
- stdio: "inherit",
1980
- reject: false
1981
- });
1982
- if (result.exitCode !== 0) {
1983
- log13.error("Failed to get container status. Is Docker running?");
1984
- process.exit(1);
1904
+ s.start("Waiting for Salesforce authentication in browser\u2026");
1905
+ const maxPolls = 150;
1906
+ let accessToken;
1907
+ let instanceUrl;
1908
+ for (let i = 0; i < maxPolls; i++) {
1909
+ await new Promise((r) => setTimeout(r, 2e3));
1910
+ try {
1911
+ const pollRes = await fetch(`${appUrl}/api/cli-auth/poll/${sessionId}`);
1912
+ if (pollRes.status === 410) {
1913
+ s.stop("Auth session expired");
1914
+ throw new Error("CLI auth session expired. Please re-run the command.");
1915
+ }
1916
+ const data = await pollRes.json();
1917
+ if (data.status === "ok") {
1918
+ accessToken = data.accessToken;
1919
+ instanceUrl = data.instanceUrl;
1920
+ break;
1921
+ }
1922
+ } catch (err) {
1923
+ if (String(err).includes("session expired")) throw err;
1924
+ }
1925
+ }
1926
+ if (!accessToken || !instanceUrl) {
1927
+ s.stop("Timed out");
1928
+ throw new Error(
1929
+ "Timed out waiting for Salesforce authentication (5 minutes).\nPlease re-run the command and complete login within 5 minutes."
1930
+ );
1985
1931
  }
1932
+ s.stop("Authenticated with Salesforce");
1933
+ return { accessToken, instanceUrl };
1986
1934
  }
1987
1935
 
1988
- // src/commands/config.ts
1989
- import { readFileSync as readFileSync6, writeFileSync as writeFileSync6, existsSync as existsSync5 } from "fs";
1990
- import { join as join8 } from "path";
1991
- import { intro as intro4, outro as outro4, text as text3, password as password3, spinner as spinner6, log as log14 } from "@clack/prompts";
1936
+ // src/steps/app-launcher-guide.ts
1937
+ import { note as note4, confirm as confirm2, isCancel as isCancel4, log as log14 } from "@clack/prompts";
1992
1938
  import chalk5 from "chalk";
1993
- import { execa as execa4 } from "execa";
1994
- function parseEnv(filePath) {
1995
- const map = /* @__PURE__ */ new Map();
1996
- if (!existsSync5(filePath)) return map;
1997
- for (const line of readFileSync6(filePath, "utf8").split("\n")) {
1998
- const trimmed = line.trim();
1999
- if (!trimmed || trimmed.startsWith("#")) continue;
2000
- const eq = trimmed.indexOf("=");
2001
- if (eq === -1) continue;
2002
- map.set(trimmed.slice(0, eq), trimmed.slice(eq + 1));
2003
- }
2004
- return map;
2005
- }
2006
- function writeEnv(filePath, updates) {
2007
- const lines = existsSync5(filePath) ? readFileSync6(filePath, "utf8").split("\n") : [];
2008
- const updated = /* @__PURE__ */ new Set();
2009
- const result = lines.map((line) => {
2010
- const trimmed = line.trim();
2011
- if (!trimmed || trimmed.startsWith("#")) return line;
2012
- const eq = trimmed.indexOf("=");
2013
- if (eq === -1) return line;
2014
- const key = trimmed.slice(0, eq);
2015
- if (key in updates) {
2016
- updated.add(key);
2017
- return `${key}=${updates[key]}`;
2018
- }
2019
- return line;
2020
- });
2021
- for (const [key, val] of Object.entries(updates)) {
2022
- if (!updated.has(key)) result.push(`${key}=${val}`);
2023
- }
2024
- writeFileSync6(filePath, result.join("\n"), "utf8");
2025
- }
2026
- async function runConfigSfdc() {
2027
- intro4("Lead Routing \u2014 Update Salesforce Credentials");
2028
- const dir = findInstallDir();
2029
- if (!dir) {
2030
- log14.error("No lead-routing installation found in the current directory.");
2031
- log14.info("Run `lead-routing init` first, or cd into your installation directory.");
2032
- process.exit(1);
2033
- }
2034
- const envWeb = join8(dir, ".env.web");
2035
- const envEngine = join8(dir, ".env.engine");
2036
- const currentWeb = parseEnv(envWeb);
2037
- const currentClientId = currentWeb.get("SFDC_CLIENT_ID") ?? "";
2038
- const currentLoginUrl = currentWeb.get("SFDC_LOGIN_URL") ?? "https://login.salesforce.com";
2039
- const currentAppUrl = currentWeb.get("APP_URL") ?? "";
2040
- const callbackUrl = `${currentAppUrl}/api/auth/callback`;
2041
- log14.info(
2042
- `Paste the credentials from your Salesforce Connected App.
2043
- Callback URL for your Connected App: ${callbackUrl}`
1939
+ async function guideAppLauncherSetup(appUrl) {
1940
+ note4(
1941
+ `Complete the following steps in Salesforce now:
1942
+
1943
+ ${chalk5.cyan("1.")} Open ${chalk5.bold("App Launcher")} (grid icon, top-left in Salesforce)
1944
+ ${chalk5.cyan("2.")} Search for ${chalk5.white('"Lead Router Setup"')} and click it
1945
+ ${chalk5.cyan("3.")} Click ${chalk5.white('"Connect to Lead Router"')}
1946
+ \u2192 You will be redirected to ${chalk5.dim(appUrl)} and back
1947
+ \u2192 Authorize the OAuth connection when prompted
1948
+
1949
+ ${chalk5.cyan("4.")} ${chalk5.bold("Step 1")} \u2014 wait for the ${chalk5.green('"Connected"')} checkmark (~5 sec)
1950
+ ${chalk5.cyan("5.")} ${chalk5.bold("Step 2")} \u2014 click ${chalk5.white("Activate")} to enable Lead triggers
1951
+ ${chalk5.cyan("6.")} ${chalk5.bold("Step 3")} \u2014 click ${chalk5.white("Sync Fields")} to index your Lead field schema
1952
+ ${chalk5.cyan("7.")} ${chalk5.bold("Step 4")} \u2014 click ${chalk5.white("Send Test")} to fire a test routing event
1953
+ \u2192 ${chalk5.dim('"Test successful"')} or ${chalk5.dim('"No matching rule"')} are both valid
1954
+
1955
+ ` + chalk5.dim("Keep this terminal open while you complete the wizard."),
1956
+ "Complete Salesforce setup"
2044
1957
  );
2045
- const clientId = await text3({
2046
- message: "Consumer Key (Client ID)",
2047
- initialValue: currentClientId,
2048
- validate: (v) => !v ? "Required" : void 0
2049
- });
2050
- if (clientId === null || typeof clientId === "symbol") {
2051
- process.exit(0);
2052
- }
2053
- const clientSecret = await password3({
2054
- message: "Consumer Secret (Client Secret)",
2055
- validate: (v) => !v ? "Required" : void 0
1958
+ const done = await confirm2({
1959
+ message: "Have you completed the App Launcher wizard?",
1960
+ initialValue: false
2056
1961
  });
2057
- if (clientSecret === null || typeof clientSecret === "symbol") {
2058
- process.exit(0);
2059
- }
2060
- const updates = {
2061
- SFDC_CLIENT_ID: clientId,
2062
- SFDC_CLIENT_SECRET: clientSecret
2063
- };
2064
- writeEnv(envWeb, updates);
2065
- writeEnv(envEngine, updates);
2066
- log14.success("Updated .env.web and .env.engine");
2067
- const s = spinner6();
2068
- s.start("Restarting web and engine containers\u2026");
2069
- try {
2070
- await execa4("docker", ["compose", "up", "-d", "--force-recreate", "web", "engine"], {
2071
- cwd: dir
2072
- });
2073
- s.stop("Containers restarted");
2074
- } catch (err) {
2075
- s.stop("Restart failed \u2014 run `docker compose up -d --force-recreate web engine` manually");
2076
- log14.warn(String(err));
1962
+ if (isCancel4(done)) {
1963
+ log14.warn(
1964
+ "Wizard skipped. Run `lead-routing sfdc deploy` to retry the Salesforce setup."
1965
+ );
1966
+ return;
2077
1967
  }
2078
- outro4(
2079
- "Salesforce credentials updated!\n\nNext: go to the web app \u2192 Settings \u2192 Connect Salesforce to refresh your OAuth tokens."
2080
- );
2081
- }
2082
- function runConfigShow() {
2083
- const dir = findInstallDir();
2084
- if (!dir) {
2085
- console.error("No lead-routing installation found in the current directory.");
2086
- process.exit(1);
1968
+ if (!done) {
1969
+ log14.warn(
1970
+ `No problem \u2014 complete it at your own pace.
1971
+ Open App Launcher \u2192 Lead Router Setup \u2192 Connect to Lead Router
1972
+ Dashboard: ${appUrl}`
1973
+ );
1974
+ } else {
1975
+ log14.success("Salesforce setup complete");
2087
1976
  }
2088
- const envWeb = join8(dir, ".env.web");
2089
- const cfg = parseEnv(envWeb);
2090
- const adminSecret = cfg.get("ADMIN_SECRET") ?? "(not found)";
2091
- const appUrl = cfg.get("APP_URL") ?? "(not found)";
2092
- const sfdcClientId = cfg.get("SFDC_CLIENT_ID") ?? "(not found)";
2093
- console.log();
2094
- console.log(chalk5.bold("Lead Routing \u2014 Installation Config"));
2095
- console.log();
2096
- console.log(` Admin panel: ${chalk5.cyan(appUrl + "/admin")}`);
2097
- console.log(` Admin secret: ${chalk5.yellow(adminSecret)}`);
2098
- console.log();
2099
- console.log(` SFDC Client ID: ${chalk5.white(sfdcClientId)}`);
2100
- console.log();
2101
1977
  }
2102
1978
 
2103
1979
  // src/commands/sfdc.ts
2104
- import { intro as intro5, outro as outro5, text as text4, log as log15 } from "@clack/prompts";
2105
- import chalk6 from "chalk";
2106
1980
  async function runSfdcDeploy() {
2107
1981
  intro5("Lead Routing \u2014 Deploy Salesforce Package");
2108
1982
  let appUrl;
@@ -2115,20 +1989,20 @@ async function runSfdcDeploy() {
2115
1989
  log15.info(`Using config from ${dir}/lead-routing.json`);
2116
1990
  } else {
2117
1991
  log15.warn("No lead-routing.json found \u2014 enter the URLs from your installation.");
2118
- const rawApp = await text4({
1992
+ const rawApp = await text3({
2119
1993
  message: "App URL (e.g. https://leads.acme.com)",
2120
1994
  validate: (v) => !v ? "Required" : void 0
2121
1995
  });
2122
1996
  if (typeof rawApp === "symbol") process.exit(0);
2123
1997
  appUrl = rawApp.trim();
2124
- const rawEngine = await text4({
1998
+ const rawEngine = await text3({
2125
1999
  message: "Engine URL (e.g. https://engine.acme.com or https://acme.com:3001)",
2126
2000
  validate: (v) => !v ? "Required" : void 0
2127
2001
  });
2128
2002
  if (typeof rawEngine === "symbol") process.exit(0);
2129
2003
  engineUrl = rawEngine.trim();
2130
2004
  }
2131
- const alias = await text4({
2005
+ const alias = await text3({
2132
2006
  message: "Salesforce org alias (used to log in)",
2133
2007
  placeholder: "lead-routing",
2134
2008
  initialValue: "lead-routing",
@@ -2140,9 +2014,6 @@ async function runSfdcDeploy() {
2140
2014
  appUrl,
2141
2015
  engineUrl,
2142
2016
  orgAlias: alias,
2143
- // Read from config if available
2144
- sfdcClientId: config2?.sfdcClientId ?? "",
2145
- sfdcLoginUrl: config2?.sfdcLoginUrl ?? "https://login.salesforce.com",
2146
2017
  installDir: dir ?? void 0,
2147
2018
  webhookSecret: config2?.engineWebhookSecret
2148
2019
  });
@@ -2249,10 +2120,9 @@ async function runUninstall() {
2249
2120
  // src/index.ts
2250
2121
  var program = new Command();
2251
2122
  program.name("lead-routing").description("Self-hosted Lead Routing \u2014 scaffold, deploy, and manage your installation").version("0.1.13");
2252
- program.command("init").description("Interactive setup wizard \u2014 configure and deploy the full Lead Routing stack").option("--dry-run", "Generate config files without connecting or deploying").option("--resume", "Skip to health check using existing lead-routing.json (post-timeout recovery)").option("--sandbox", "Use Salesforce sandbox (test.salesforce.com) instead of production").option("--ssh-port <port>", "SSH port (default: 22)", parseInt).option("--ssh-user <user>", "SSH username (default: root)").option("--ssh-key <path>", "Path to SSH private key (overrides auto-detection)").option("--remote-dir <path>", "Remote install directory (default: ~/lead-routing)").option("--external-db <url>", "Use external PostgreSQL URL instead of managed Docker container").option("--external-redis <url>", "Use external Redis URL instead of managed Docker container").action((opts) => runInit({
2123
+ program.command("init").description("Interactive setup wizard \u2014 configure and deploy the full Lead Routing stack").option("--dry-run", "Generate config files without connecting or deploying").option("--resume", "Skip to health check using existing lead-routing.json (post-timeout recovery)").option("--ssh-port <port>", "SSH port (default: 22)", parseInt).option("--ssh-user <user>", "SSH username (default: root)").option("--ssh-key <path>", "Path to SSH private key (overrides auto-detection)").option("--remote-dir <path>", "Remote install directory (default: ~/lead-routing)").option("--external-db <url>", "Use external PostgreSQL URL instead of managed Docker container").option("--external-redis <url>", "Use external Redis URL instead of managed Docker container").action((opts) => runInit({
2253
2124
  dryRun: opts.dryRun,
2254
2125
  resume: opts.resume,
2255
- sandbox: opts.sandbox,
2256
2126
  sshPort: opts.sshPort,
2257
2127
  sshUser: opts.sshUser,
2258
2128
  sshKey: opts.sshKey,