@lead-routing/cli 0.3.0 → 0.4.0

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