@lead-routing/cli 0.1.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 +1916 -0
- package/dist/prisma/migrations/20260101000000_init/migration.sql +276 -0
- package/dist/prisma/migrations/20260223000000_add_routing_log_dismissed/migration.sql +2 -0
- package/dist/prisma/migrations/20260224000000_add_org_notification_webhook/migration.sql +2 -0
- package/dist/prisma/migrations/20260227000000_self_hosted_schema_updates/migration.sql +68 -0
- package/dist/prisma/schema.prisma +315 -0
- package/dist/sfdc-package/force-app/main/default/applications/Lead_Router_Setup.app-meta.xml +12 -0
- package/dist/sfdc-package/force-app/main/default/classes/AccountTriggerTest.cls +58 -0
- package/dist/sfdc-package/force-app/main/default/classes/AccountTriggerTest.cls-meta.xml +5 -0
- package/dist/sfdc-package/force-app/main/default/classes/ContactTriggerTest.cls +62 -0
- package/dist/sfdc-package/force-app/main/default/classes/ContactTriggerTest.cls-meta.xml +5 -0
- package/dist/sfdc-package/force-app/main/default/classes/LeadTriggerTest.cls +95 -0
- package/dist/sfdc-package/force-app/main/default/classes/LeadTriggerTest.cls-meta.xml +5 -0
- package/dist/sfdc-package/force-app/main/default/classes/OnboardingController.cls +183 -0
- package/dist/sfdc-package/force-app/main/default/classes/OnboardingController.cls-meta.xml +5 -0
- package/dist/sfdc-package/force-app/main/default/classes/RoutingEngineCallout.cls +96 -0
- package/dist/sfdc-package/force-app/main/default/classes/RoutingEngineCallout.cls-meta.xml +5 -0
- package/dist/sfdc-package/force-app/main/default/classes/RoutingEngineMock.cls +28 -0
- package/dist/sfdc-package/force-app/main/default/classes/RoutingEngineMock.cls-meta.xml +5 -0
- package/dist/sfdc-package/force-app/main/default/classes/RoutingPayloadBuilder.cls +50 -0
- package/dist/sfdc-package/force-app/main/default/classes/RoutingPayloadBuilder.cls-meta.xml +5 -0
- package/dist/sfdc-package/force-app/main/default/lwc/onboardingWizard/onboardingWizard.html +230 -0
- package/dist/sfdc-package/force-app/main/default/lwc/onboardingWizard/onboardingWizard.js +222 -0
- package/dist/sfdc-package/force-app/main/default/lwc/onboardingWizard/onboardingWizard.js-meta.xml +11 -0
- package/dist/sfdc-package/force-app/main/default/namedCredentials/RoutingEngine.namedCredential-meta.xml +10 -0
- package/dist/sfdc-package/force-app/main/default/objects/Routing_Error_Log__c/Routing_Error_Log__c.object-meta.xml +21 -0
- package/dist/sfdc-package/force-app/main/default/objects/Routing_Error_Log__c/fields/Created_At__c.field-meta.xml +6 -0
- package/dist/sfdc-package/force-app/main/default/objects/Routing_Error_Log__c/fields/Payload__c.field-meta.xml +8 -0
- package/dist/sfdc-package/force-app/main/default/objects/Routing_Error_Log__c/fields/Response_Body__c.field-meta.xml +8 -0
- package/dist/sfdc-package/force-app/main/default/objects/Routing_Error_Log__c/fields/Status_Code__c.field-meta.xml +9 -0
- package/dist/sfdc-package/force-app/main/default/objects/Routing_Settings__c/Routing_Settings__c.object-meta.xml +8 -0
- package/dist/sfdc-package/force-app/main/default/objects/Routing_Settings__c/fields/Account_Insert_Enabled__c.field-meta.xml +7 -0
- package/dist/sfdc-package/force-app/main/default/objects/Routing_Settings__c/fields/Account_Routing_Enabled__c.field-meta.xml +7 -0
- package/dist/sfdc-package/force-app/main/default/objects/Routing_Settings__c/fields/Account_Update_Enabled__c.field-meta.xml +7 -0
- package/dist/sfdc-package/force-app/main/default/objects/Routing_Settings__c/fields/App_Url__c.field-meta.xml +8 -0
- package/dist/sfdc-package/force-app/main/default/objects/Routing_Settings__c/fields/Contact_Insert_Enabled__c.field-meta.xml +7 -0
- package/dist/sfdc-package/force-app/main/default/objects/Routing_Settings__c/fields/Contact_Routing_Enabled__c.field-meta.xml +7 -0
- package/dist/sfdc-package/force-app/main/default/objects/Routing_Settings__c/fields/Contact_Update_Enabled__c.field-meta.xml +7 -0
- package/dist/sfdc-package/force-app/main/default/objects/Routing_Settings__c/fields/Engine_Endpoint__c.field-meta.xml +8 -0
- package/dist/sfdc-package/force-app/main/default/objects/Routing_Settings__c/fields/Lead_Insert_Enabled__c.field-meta.xml +7 -0
- package/dist/sfdc-package/force-app/main/default/objects/Routing_Settings__c/fields/Lead_Routing_Enabled__c.field-meta.xml +7 -0
- package/dist/sfdc-package/force-app/main/default/objects/Routing_Settings__c/fields/Lead_Update_Enabled__c.field-meta.xml +7 -0
- package/dist/sfdc-package/force-app/main/default/objects/Routing_Settings__c/fields/Webhook_Secret__c.field-meta.xml +8 -0
- package/dist/sfdc-package/force-app/main/default/permissionsets/LeadRouterAdmin.permissionset-meta.xml +14 -0
- package/dist/sfdc-package/force-app/main/default/remoteSiteSettings/LeadRouterEngine.remoteSite-meta.xml +7 -0
- package/dist/sfdc-package/force-app/main/default/tabs/Lead_Router_Setup.tab-meta.xml +7 -0
- package/dist/sfdc-package/force-app/main/default/triggers/AccountTrigger.trigger +28 -0
- package/dist/sfdc-package/force-app/main/default/triggers/AccountTrigger.trigger-meta.xml +5 -0
- package/dist/sfdc-package/force-app/main/default/triggers/ContactTrigger.trigger +28 -0
- package/dist/sfdc-package/force-app/main/default/triggers/ContactTrigger.trigger-meta.xml +5 -0
- package/dist/sfdc-package/force-app/main/default/triggers/LeadTrigger.trigger +28 -0
- package/dist/sfdc-package/force-app/main/default/triggers/LeadTrigger.trigger-meta.xml +5 -0
- package/dist/sfdc-package/sfdx-project.json +14 -0
- package/package.json +41 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,1916 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/index.ts
|
|
4
|
+
import { Command } from "commander";
|
|
5
|
+
|
|
6
|
+
// src/commands/init.ts
|
|
7
|
+
import { intro, outro, note as note4, log as log8 } from "@clack/prompts";
|
|
8
|
+
import chalk2 from "chalk";
|
|
9
|
+
|
|
10
|
+
// src/steps/prerequisites.ts
|
|
11
|
+
import { log } from "@clack/prompts";
|
|
12
|
+
|
|
13
|
+
// src/utils/exec.ts
|
|
14
|
+
import { execa } from "execa";
|
|
15
|
+
import { spinner } from "@clack/prompts";
|
|
16
|
+
async function runSilent(cmd, args, opts = {}) {
|
|
17
|
+
try {
|
|
18
|
+
const result = await execa(cmd, args, { cwd: opts.cwd, reject: false });
|
|
19
|
+
return result.stdout;
|
|
20
|
+
} catch {
|
|
21
|
+
return "";
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// src/steps/prerequisites.ts
|
|
26
|
+
async function checkPrerequisites() {
|
|
27
|
+
const results = await Promise.all([
|
|
28
|
+
checkNodeVersion(),
|
|
29
|
+
checkSalesforceCLI()
|
|
30
|
+
]);
|
|
31
|
+
const failed = results.filter((r) => !r.ok);
|
|
32
|
+
for (const r of results) {
|
|
33
|
+
if (r.ok) {
|
|
34
|
+
log.success(r.label);
|
|
35
|
+
} else {
|
|
36
|
+
log.error(`${r.label}${r.detail ? ` \u2014 ${r.detail}` : ""}`);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
if (failed.length > 0) {
|
|
40
|
+
throw new Error(
|
|
41
|
+
`Missing local prerequisites:
|
|
42
|
+
${failed.map((r) => ` \u2022 ${r.label}`).join("\n")}
|
|
43
|
+
|
|
44
|
+
Please install them and re-run lead-routing init.`
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
async function checkNodeVersion() {
|
|
49
|
+
const version = process.version;
|
|
50
|
+
const major = parseInt(version.slice(1), 10);
|
|
51
|
+
if (major < 20) {
|
|
52
|
+
return { ok: false, label: `Node.js ${version}`, detail: "version 20+ required" };
|
|
53
|
+
}
|
|
54
|
+
return { ok: true, label: `Node.js ${version}` };
|
|
55
|
+
}
|
|
56
|
+
async function checkSalesforceCLI() {
|
|
57
|
+
const out = await runSilent("sf", ["--version"]);
|
|
58
|
+
if (!out) {
|
|
59
|
+
return {
|
|
60
|
+
ok: false,
|
|
61
|
+
label: "Salesforce CLI (sf) \u2014 not found",
|
|
62
|
+
detail: "install from https://developer.salesforce.com/tools/salesforcecli"
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
return { ok: true, label: `Salesforce CLI \u2014 ${out.trim()}` };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// src/steps/collect-ssh-config.ts
|
|
69
|
+
import { existsSync } from "fs";
|
|
70
|
+
import { homedir } from "os";
|
|
71
|
+
import { text, password, select, note, cancel, isCancel } from "@clack/prompts";
|
|
72
|
+
function bail(value) {
|
|
73
|
+
if (isCancel(value)) {
|
|
74
|
+
cancel("Setup cancelled.");
|
|
75
|
+
process.exit(0);
|
|
76
|
+
}
|
|
77
|
+
throw new Error("Unexpected cancel");
|
|
78
|
+
}
|
|
79
|
+
async function collectSshConfig() {
|
|
80
|
+
note(
|
|
81
|
+
"The CLI will SSH into your server to deploy the full stack.\nYou will need:\n \u2022 Server hostname or IP address\n \u2022 SSH access (key file recommended, password supported)\n \u2022 Docker 24+ already installed on the server",
|
|
82
|
+
"Server connection"
|
|
83
|
+
);
|
|
84
|
+
const host = await text({
|
|
85
|
+
message: "Server hostname or IP address",
|
|
86
|
+
placeholder: "165.22.100.50 or vps.acme.com",
|
|
87
|
+
validate: (v) => !v ? "Required" : void 0
|
|
88
|
+
});
|
|
89
|
+
if (isCancel(host)) bail(host);
|
|
90
|
+
const portRaw = await text({
|
|
91
|
+
message: "SSH port",
|
|
92
|
+
placeholder: "22",
|
|
93
|
+
initialValue: "22",
|
|
94
|
+
validate: (v) => {
|
|
95
|
+
const n = parseInt(v, 10);
|
|
96
|
+
if (isNaN(n) || n < 1 || n > 65535) return "Must be a valid port (1\u201365535)";
|
|
97
|
+
}
|
|
98
|
+
});
|
|
99
|
+
if (isCancel(portRaw)) bail(portRaw);
|
|
100
|
+
const username = await text({
|
|
101
|
+
message: "SSH username",
|
|
102
|
+
placeholder: "root",
|
|
103
|
+
initialValue: "root",
|
|
104
|
+
validate: (v) => !v ? "Required" : void 0
|
|
105
|
+
});
|
|
106
|
+
if (isCancel(username)) bail(username);
|
|
107
|
+
const authMethod = await select({
|
|
108
|
+
message: "SSH authentication method",
|
|
109
|
+
options: [
|
|
110
|
+
{ value: "key", label: "SSH key file (recommended)" },
|
|
111
|
+
{ value: "password", label: "Password" }
|
|
112
|
+
]
|
|
113
|
+
});
|
|
114
|
+
if (isCancel(authMethod)) bail(authMethod);
|
|
115
|
+
let privateKeyPath;
|
|
116
|
+
let pwd;
|
|
117
|
+
if (authMethod === "key") {
|
|
118
|
+
const defaultKey = `${homedir()}/.ssh/id_rsa`;
|
|
119
|
+
const keyPath = await text({
|
|
120
|
+
message: "Path to SSH private key",
|
|
121
|
+
placeholder: defaultKey,
|
|
122
|
+
initialValue: `~/.ssh/id_rsa`,
|
|
123
|
+
validate: (v) => {
|
|
124
|
+
const resolved = v.startsWith("~") ? homedir() + v.slice(1) : v;
|
|
125
|
+
if (!existsSync(resolved)) return `Key file not found: ${resolved}`;
|
|
126
|
+
}
|
|
127
|
+
});
|
|
128
|
+
if (isCancel(keyPath)) bail(keyPath);
|
|
129
|
+
const raw = keyPath;
|
|
130
|
+
privateKeyPath = raw.startsWith("~") ? homedir() + raw.slice(1) : raw;
|
|
131
|
+
} else {
|
|
132
|
+
const p = await password({
|
|
133
|
+
message: "SSH password",
|
|
134
|
+
validate: (v) => !v ? "Required" : void 0
|
|
135
|
+
});
|
|
136
|
+
if (isCancel(p)) bail(p);
|
|
137
|
+
pwd = p;
|
|
138
|
+
}
|
|
139
|
+
const remoteDir = await text({
|
|
140
|
+
message: "Remote install directory on server",
|
|
141
|
+
placeholder: "~/lead-routing",
|
|
142
|
+
initialValue: "~/lead-routing",
|
|
143
|
+
validate: (v) => !v ? "Required" : void 0
|
|
144
|
+
});
|
|
145
|
+
if (isCancel(remoteDir)) bail(remoteDir);
|
|
146
|
+
return {
|
|
147
|
+
host,
|
|
148
|
+
port: parseInt(portRaw, 10),
|
|
149
|
+
username,
|
|
150
|
+
privateKeyPath,
|
|
151
|
+
password: pwd,
|
|
152
|
+
remoteDir
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// src/steps/collect-config.ts
|
|
157
|
+
import {
|
|
158
|
+
text as text2,
|
|
159
|
+
password as password2,
|
|
160
|
+
select as select2,
|
|
161
|
+
confirm,
|
|
162
|
+
note as note2,
|
|
163
|
+
cancel as cancel2,
|
|
164
|
+
isCancel as isCancel2
|
|
165
|
+
} from "@clack/prompts";
|
|
166
|
+
|
|
167
|
+
// src/utils/crypto.ts
|
|
168
|
+
import { randomBytes } from "crypto";
|
|
169
|
+
function generateSecret(bytes = 32) {
|
|
170
|
+
return randomBytes(bytes).toString("hex");
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// src/steps/collect-config.ts
|
|
174
|
+
function bail2(value) {
|
|
175
|
+
if (isCancel2(value)) {
|
|
176
|
+
cancel2("Setup cancelled.");
|
|
177
|
+
process.exit(0);
|
|
178
|
+
}
|
|
179
|
+
throw new Error("Unexpected cancel");
|
|
180
|
+
}
|
|
181
|
+
async function collectConfig() {
|
|
182
|
+
note2(
|
|
183
|
+
"You will need:\n \u2022 A Salesforce Connected App (Client ID + Secret) \u2014 instructions below\n \u2022 A public URL or localhost for the app\n \u2022 PostgreSQL + Redis (or let Docker manage them)",
|
|
184
|
+
"Before you begin"
|
|
185
|
+
);
|
|
186
|
+
const appUrl = await text2({
|
|
187
|
+
message: "App URL (public URL where the web app will be accessible)",
|
|
188
|
+
placeholder: "https://routing.acme.com",
|
|
189
|
+
validate: (v) => {
|
|
190
|
+
if (!v) return "Required";
|
|
191
|
+
try {
|
|
192
|
+
new URL(v);
|
|
193
|
+
} catch {
|
|
194
|
+
return "Must be a valid URL (e.g. https://routing.acme.com)";
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
});
|
|
198
|
+
if (isCancel2(appUrl)) bail2(appUrl);
|
|
199
|
+
const engineUrl = await text2({
|
|
200
|
+
message: "Engine URL (public URL Salesforce will use to route leads)",
|
|
201
|
+
placeholder: "https://engine.acme.com",
|
|
202
|
+
hint: "Subdomain: https://engine.acme.com \u2022 Same domain + port: https://acme.com:3001",
|
|
203
|
+
validate: (v) => {
|
|
204
|
+
if (!v) return "Required";
|
|
205
|
+
try {
|
|
206
|
+
const u = new URL(v);
|
|
207
|
+
if (u.protocol !== "https:") return "Must be an HTTPS URL (Salesforce requires HTTPS)";
|
|
208
|
+
} catch {
|
|
209
|
+
return "Must be a valid URL (e.g. https://engine.acme.com)";
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
});
|
|
213
|
+
if (isCancel2(engineUrl)) bail2(engineUrl);
|
|
214
|
+
const callbackUrl = `${appUrl}/api/auth/callback`;
|
|
215
|
+
note2(
|
|
216
|
+
`You need a Salesforce Connected App. If you haven't created one yet:
|
|
217
|
+
|
|
218
|
+
1. Go to Salesforce Setup \u2192 App Manager \u2192 New Connected App
|
|
219
|
+
2. Connected App Name: Lead Routing
|
|
220
|
+
3. Check "Enable OAuth Settings"
|
|
221
|
+
4. Callback URL:
|
|
222
|
+
${callbackUrl}
|
|
223
|
+
5. Selected Scopes: api \u2022 refresh_token, offline_access \u2022 openid
|
|
224
|
+
6. Check "Require Secret for Web Server Flow"
|
|
225
|
+
7. Save \u2014 wait ~2 min, then click "Manage Consumer Details"
|
|
226
|
+
8. Copy the Consumer Key (Client ID) and Consumer Secret below`,
|
|
227
|
+
"Salesforce Connected App setup"
|
|
228
|
+
);
|
|
229
|
+
const sfdcClientId = await text2({
|
|
230
|
+
message: 'Consumer Key (labelled "Client ID" in newer orgs)',
|
|
231
|
+
placeholder: "3MVG9...",
|
|
232
|
+
validate: (v) => !v ? "Required" : void 0
|
|
233
|
+
});
|
|
234
|
+
if (isCancel2(sfdcClientId)) bail2(sfdcClientId);
|
|
235
|
+
const sfdcClientSecret = await password2({
|
|
236
|
+
message: 'Consumer Secret (labelled "Client Secret" in newer orgs)',
|
|
237
|
+
validate: (v) => !v ? "Required" : void 0
|
|
238
|
+
});
|
|
239
|
+
if (isCancel2(sfdcClientSecret)) bail2(sfdcClientSecret);
|
|
240
|
+
const sfdcLoginUrlChoice = await select2({
|
|
241
|
+
message: "Salesforce environment",
|
|
242
|
+
options: [
|
|
243
|
+
{ value: "https://login.salesforce.com", label: "Production / Developer org" },
|
|
244
|
+
{ value: "https://test.salesforce.com", label: "Sandbox" }
|
|
245
|
+
]
|
|
246
|
+
});
|
|
247
|
+
if (isCancel2(sfdcLoginUrlChoice)) bail2(sfdcLoginUrlChoice);
|
|
248
|
+
const sfdcLoginUrl = sfdcLoginUrlChoice;
|
|
249
|
+
const orgAlias = await text2({
|
|
250
|
+
message: "Salesforce org alias (used by the sf CLI to identify this org)",
|
|
251
|
+
placeholder: "lead-routing",
|
|
252
|
+
initialValue: "lead-routing",
|
|
253
|
+
validate: (v) => !v ? "Required" : void 0
|
|
254
|
+
});
|
|
255
|
+
if (isCancel2(orgAlias)) bail2(orgAlias);
|
|
256
|
+
const managedDb = await confirm({
|
|
257
|
+
message: "Manage PostgreSQL with Docker? (recommended \u2014 choose No to provide your own URL)",
|
|
258
|
+
initialValue: true
|
|
259
|
+
});
|
|
260
|
+
if (isCancel2(managedDb)) bail2(managedDb);
|
|
261
|
+
let databaseUrl = "";
|
|
262
|
+
let dbPassword = generateSecret(16);
|
|
263
|
+
if (managedDb) {
|
|
264
|
+
databaseUrl = "postgresql://leadrouting:" + dbPassword + "@postgres:5432/leadrouting";
|
|
265
|
+
} else {
|
|
266
|
+
const url = await text2({
|
|
267
|
+
message: "PostgreSQL connection URL",
|
|
268
|
+
placeholder: "postgresql://user:pass@host:5432/dbname",
|
|
269
|
+
validate: (v) => {
|
|
270
|
+
if (!v) return "Required";
|
|
271
|
+
if (!v.startsWith("postgresql://") && !v.startsWith("postgres://"))
|
|
272
|
+
return "Must start with postgresql:// or postgres://";
|
|
273
|
+
}
|
|
274
|
+
});
|
|
275
|
+
if (isCancel2(url)) bail2(url);
|
|
276
|
+
databaseUrl = url;
|
|
277
|
+
dbPassword = "";
|
|
278
|
+
}
|
|
279
|
+
const managedRedis = await confirm({
|
|
280
|
+
message: "Manage Redis with Docker? (recommended \u2014 choose No to provide your own URL)",
|
|
281
|
+
initialValue: true
|
|
282
|
+
});
|
|
283
|
+
if (isCancel2(managedRedis)) bail2(managedRedis);
|
|
284
|
+
let redisUrl = "";
|
|
285
|
+
if (managedRedis) {
|
|
286
|
+
redisUrl = "redis://redis:6379";
|
|
287
|
+
} else {
|
|
288
|
+
const url = await text2({
|
|
289
|
+
message: "Redis connection URL",
|
|
290
|
+
placeholder: "redis://user:pass@host:6379",
|
|
291
|
+
validate: (v) => {
|
|
292
|
+
if (!v) return "Required";
|
|
293
|
+
if (!v.startsWith("redis://") && !v.startsWith("rediss://"))
|
|
294
|
+
return "Must start with redis:// or rediss://";
|
|
295
|
+
}
|
|
296
|
+
});
|
|
297
|
+
if (isCancel2(url)) bail2(url);
|
|
298
|
+
redisUrl = url;
|
|
299
|
+
}
|
|
300
|
+
note2("This creates the first admin user for the web app.", "Admin Account");
|
|
301
|
+
const adminEmail = await text2({
|
|
302
|
+
message: "Admin email address",
|
|
303
|
+
placeholder: "admin@acme.com",
|
|
304
|
+
validate: (v) => {
|
|
305
|
+
if (!v) return "Required";
|
|
306
|
+
if (!v.includes("@")) return "Must be a valid email";
|
|
307
|
+
}
|
|
308
|
+
});
|
|
309
|
+
if (isCancel2(adminEmail)) bail2(adminEmail);
|
|
310
|
+
const adminPassword = await password2({
|
|
311
|
+
message: "Admin password (min 8 characters)",
|
|
312
|
+
validate: (v) => {
|
|
313
|
+
if (!v) return "Required";
|
|
314
|
+
if (v.length < 8) return "Must be at least 8 characters";
|
|
315
|
+
}
|
|
316
|
+
});
|
|
317
|
+
if (isCancel2(adminPassword)) bail2(adminPassword);
|
|
318
|
+
const wantResend = await confirm({
|
|
319
|
+
message: "Configure Resend for email invites? (optional)",
|
|
320
|
+
initialValue: false
|
|
321
|
+
});
|
|
322
|
+
if (isCancel2(wantResend)) bail2(wantResend);
|
|
323
|
+
let resendApiKey = "";
|
|
324
|
+
let feedbackToEmail = "";
|
|
325
|
+
if (wantResend) {
|
|
326
|
+
const key = await text2({
|
|
327
|
+
message: "Resend API key",
|
|
328
|
+
placeholder: "re_..."
|
|
329
|
+
});
|
|
330
|
+
if (isCancel2(key)) bail2(key);
|
|
331
|
+
resendApiKey = key ?? "";
|
|
332
|
+
const email = await text2({
|
|
333
|
+
message: "Email address to receive feedback",
|
|
334
|
+
placeholder: "feedback@acme.com"
|
|
335
|
+
});
|
|
336
|
+
if (isCancel2(email)) bail2(email);
|
|
337
|
+
feedbackToEmail = email ?? "";
|
|
338
|
+
}
|
|
339
|
+
const sessionSecret = generateSecret(32);
|
|
340
|
+
const engineWebhookSecret = generateSecret(32);
|
|
341
|
+
const adminSecret = generateSecret(16);
|
|
342
|
+
return {
|
|
343
|
+
appUrl: appUrl.trim().replace(/\/+$/, ""),
|
|
344
|
+
engineUrl: engineUrl.trim().replace(/\/+$/, ""),
|
|
345
|
+
sfdcClientId,
|
|
346
|
+
sfdcClientSecret,
|
|
347
|
+
sfdcLoginUrl,
|
|
348
|
+
orgAlias,
|
|
349
|
+
managedDb,
|
|
350
|
+
databaseUrl,
|
|
351
|
+
dbPassword,
|
|
352
|
+
managedRedis,
|
|
353
|
+
redisUrl,
|
|
354
|
+
adminEmail,
|
|
355
|
+
adminPassword,
|
|
356
|
+
resendApiKey,
|
|
357
|
+
feedbackToEmail,
|
|
358
|
+
sessionSecret,
|
|
359
|
+
engineWebhookSecret,
|
|
360
|
+
adminSecret
|
|
361
|
+
};
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
// src/steps/generate-files.ts
|
|
365
|
+
import { mkdirSync, writeFileSync as writeFileSync2 } from "fs";
|
|
366
|
+
import { join as join2 } from "path";
|
|
367
|
+
import { log as log2 } from "@clack/prompts";
|
|
368
|
+
|
|
369
|
+
// src/templates/docker-compose.ts
|
|
370
|
+
function renderDockerCompose(c) {
|
|
371
|
+
const webPort = c.webPort ?? 3e3;
|
|
372
|
+
const enginePort = c.enginePort ?? 3001;
|
|
373
|
+
const dbPassword = c.dbPassword ?? "leadrouting";
|
|
374
|
+
const postgresService = c.managedDb ? `
|
|
375
|
+
postgres:
|
|
376
|
+
image: postgres:16-alpine
|
|
377
|
+
restart: unless-stopped
|
|
378
|
+
ports:
|
|
379
|
+
- "127.0.0.1:5432:5432"
|
|
380
|
+
environment:
|
|
381
|
+
POSTGRES_DB: leadrouting
|
|
382
|
+
POSTGRES_USER: leadrouting
|
|
383
|
+
POSTGRES_PASSWORD: ${dbPassword}
|
|
384
|
+
volumes:
|
|
385
|
+
- postgres_data:/var/lib/postgresql/data
|
|
386
|
+
healthcheck:
|
|
387
|
+
test: ["CMD-SHELL", "pg_isready -U leadrouting"]
|
|
388
|
+
interval: 5s
|
|
389
|
+
timeout: 5s
|
|
390
|
+
retries: 10
|
|
391
|
+
` : "";
|
|
392
|
+
const redisService = c.managedRedis ? `
|
|
393
|
+
redis:
|
|
394
|
+
image: redis:7-alpine
|
|
395
|
+
restart: unless-stopped
|
|
396
|
+
volumes:
|
|
397
|
+
- redis_data:/data
|
|
398
|
+
healthcheck:
|
|
399
|
+
test: ["CMD", "redis-cli", "ping"]
|
|
400
|
+
interval: 5s
|
|
401
|
+
timeout: 3s
|
|
402
|
+
retries: 10
|
|
403
|
+
` : "";
|
|
404
|
+
const webDependsOn = buildDependsOn(c.managedDb, c.managedRedis);
|
|
405
|
+
const engineDependsOn = buildDependsOn(c.managedDb, c.managedRedis);
|
|
406
|
+
const webService = `
|
|
407
|
+
web:
|
|
408
|
+
image: ghcr.io/atgatzby/lead-routing-web:latest
|
|
409
|
+
restart: unless-stopped
|
|
410
|
+
ports:
|
|
411
|
+
- "127.0.0.1:${webPort}:3000"
|
|
412
|
+
env_file: .env.web
|
|
413
|
+
healthcheck:
|
|
414
|
+
test: ["CMD-SHELL", "wget -qO- http://$(hostname -i):3000/api/health || exit 1"]
|
|
415
|
+
interval: 10s
|
|
416
|
+
timeout: 5s
|
|
417
|
+
retries: 6${webDependsOn}
|
|
418
|
+
`;
|
|
419
|
+
const engineService = `
|
|
420
|
+
engine:
|
|
421
|
+
image: ghcr.io/atgatzby/lead-routing-engine:latest
|
|
422
|
+
restart: unless-stopped
|
|
423
|
+
ports:
|
|
424
|
+
- "127.0.0.1:${enginePort}:3001"
|
|
425
|
+
env_file: .env.engine
|
|
426
|
+
healthcheck:
|
|
427
|
+
test: ["CMD-SHELL", "wget -qO- http://$(hostname -i):3001/health || exit 1"]
|
|
428
|
+
interval: 10s
|
|
429
|
+
timeout: 5s
|
|
430
|
+
retries: 6${engineDependsOn}
|
|
431
|
+
`;
|
|
432
|
+
const caddyService = `
|
|
433
|
+
caddy:
|
|
434
|
+
image: caddy:2-alpine
|
|
435
|
+
restart: unless-stopped
|
|
436
|
+
ports:
|
|
437
|
+
- "80:80"
|
|
438
|
+
- "443:443"
|
|
439
|
+
- "443:443/udp"
|
|
440
|
+
volumes:
|
|
441
|
+
- ./Caddyfile:/etc/caddy/Caddyfile:ro
|
|
442
|
+
- caddy_data:/data
|
|
443
|
+
- caddy_config:/config
|
|
444
|
+
depends_on:
|
|
445
|
+
- web
|
|
446
|
+
- engine
|
|
447
|
+
`;
|
|
448
|
+
const volumes = buildVolumes(c.managedDb, c.managedRedis);
|
|
449
|
+
return [
|
|
450
|
+
`# Lead Routing \u2014 Docker Compose`,
|
|
451
|
+
`# Generated by lead-routing CLI on ${(/* @__PURE__ */ new Date()).toISOString()}`,
|
|
452
|
+
`# Do not edit manually \u2014 re-run \`lead-routing init\` to regenerate.`,
|
|
453
|
+
``,
|
|
454
|
+
`services:`,
|
|
455
|
+
postgresService.trimEnd(),
|
|
456
|
+
redisService.trimEnd(),
|
|
457
|
+
webService.trimEnd(),
|
|
458
|
+
engineService.trimEnd(),
|
|
459
|
+
caddyService.trimEnd(),
|
|
460
|
+
volumes
|
|
461
|
+
].filter(Boolean).join("\n");
|
|
462
|
+
}
|
|
463
|
+
function buildDependsOn(managedDb, managedRedis) {
|
|
464
|
+
const deps = [];
|
|
465
|
+
if (managedDb) deps.push("postgres");
|
|
466
|
+
if (managedRedis) deps.push("redis");
|
|
467
|
+
if (deps.length === 0) return "";
|
|
468
|
+
return "\n depends_on:\n" + deps.map((d) => ` ${d}:
|
|
469
|
+
condition: service_healthy`).join("\n");
|
|
470
|
+
}
|
|
471
|
+
function buildVolumes(managedDb, managedRedis) {
|
|
472
|
+
const vols = [];
|
|
473
|
+
if (managedDb) vols.push(" postgres_data:");
|
|
474
|
+
if (managedRedis) vols.push(" redis_data:");
|
|
475
|
+
vols.push(" caddy_data:");
|
|
476
|
+
vols.push(" caddy_config:");
|
|
477
|
+
return "\nvolumes:\n" + vols.join("\n");
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
// src/templates/env-web.ts
|
|
481
|
+
function renderEnvWeb(c) {
|
|
482
|
+
return [
|
|
483
|
+
`# Lead Routing \u2014 Web App Environment`,
|
|
484
|
+
`# Generated by lead-routing CLI on ${(/* @__PURE__ */ new Date()).toISOString()}`,
|
|
485
|
+
``,
|
|
486
|
+
`# App`,
|
|
487
|
+
`APP_URL=${c.appUrl}`,
|
|
488
|
+
`NODE_ENV=production`,
|
|
489
|
+
``,
|
|
490
|
+
`# Engine`,
|
|
491
|
+
`ENGINE_URL=${c.engineUrl}`,
|
|
492
|
+
`ENGINE_WEBHOOK_SECRET=${c.engineWebhookSecret}`,
|
|
493
|
+
``,
|
|
494
|
+
`# Database`,
|
|
495
|
+
`DATABASE_URL=${c.databaseUrl}`,
|
|
496
|
+
``,
|
|
497
|
+
`# Redis`,
|
|
498
|
+
`REDIS_URL=${c.redisUrl}`,
|
|
499
|
+
``,
|
|
500
|
+
`# Salesforce OAuth`,
|
|
501
|
+
`SFDC_CLIENT_ID=${c.sfdcClientId}`,
|
|
502
|
+
`SFDC_CLIENT_SECRET=${c.sfdcClientSecret}`,
|
|
503
|
+
`SFDC_LOGIN_URL=${c.sfdcLoginUrl}`,
|
|
504
|
+
`SFDC_REDIRECT_URI=${c.appUrl.replace(/\/+$/, "")}/api/auth/sfdc/callback`,
|
|
505
|
+
``,
|
|
506
|
+
`# Session`,
|
|
507
|
+
`SESSION_SECRET=${c.sessionSecret}`,
|
|
508
|
+
``,
|
|
509
|
+
`# Admin`,
|
|
510
|
+
`ADMIN_SECRET=${c.adminSecret}`,
|
|
511
|
+
``,
|
|
512
|
+
`# Email (optional)`,
|
|
513
|
+
`RESEND_API_KEY=${c.resendApiKey ?? ""}`,
|
|
514
|
+
`FEEDBACK_TO_EMAIL=${c.feedbackToEmail ?? ""}`
|
|
515
|
+
].join("\n");
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
// src/templates/env-engine.ts
|
|
519
|
+
function renderEnvEngine(c) {
|
|
520
|
+
return [
|
|
521
|
+
`# Lead Routing \u2014 Engine Environment`,
|
|
522
|
+
`# Generated by lead-routing CLI on ${(/* @__PURE__ */ new Date()).toISOString()}`,
|
|
523
|
+
``,
|
|
524
|
+
`# Server`,
|
|
525
|
+
`ENGINE_PORT=${c.enginePort ?? 3001}`,
|
|
526
|
+
`LOG_LEVEL=${c.logLevel ?? "info"}`,
|
|
527
|
+
`NODE_ENV=production`,
|
|
528
|
+
``,
|
|
529
|
+
`# Database`,
|
|
530
|
+
`DATABASE_URL=${c.databaseUrl}`,
|
|
531
|
+
``,
|
|
532
|
+
`# Redis`,
|
|
533
|
+
`REDIS_URL=${c.redisUrl}`,
|
|
534
|
+
``,
|
|
535
|
+
`# Salesforce OAuth`,
|
|
536
|
+
`SFDC_CLIENT_ID=${c.sfdcClientId}`,
|
|
537
|
+
`SFDC_CLIENT_SECRET=${c.sfdcClientSecret}`,
|
|
538
|
+
`SFDC_LOGIN_URL=${c.sfdcLoginUrl}`,
|
|
539
|
+
``,
|
|
540
|
+
`# Webhook`,
|
|
541
|
+
`ENGINE_WEBHOOK_SECRET=${c.engineWebhookSecret}`
|
|
542
|
+
].join("\n");
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
// src/templates/caddy.ts
|
|
546
|
+
function renderCaddyfile(appUrl, engineUrl) {
|
|
547
|
+
const appHost = new URL(appUrl).hostname;
|
|
548
|
+
const engineParsed = new URL(engineUrl);
|
|
549
|
+
const engineHost = engineParsed.hostname;
|
|
550
|
+
const enginePort = engineParsed.port;
|
|
551
|
+
const isSameDomain = engineHost === appHost;
|
|
552
|
+
if (isSameDomain && enginePort) {
|
|
553
|
+
return [
|
|
554
|
+
`# Lead Routing \u2014 Caddyfile`,
|
|
555
|
+
`# Generated by lead-routing CLI`,
|
|
556
|
+
`# Caddy auto-provisions SSL certificates via Let's Encrypt`,
|
|
557
|
+
``,
|
|
558
|
+
`${appHost} {`,
|
|
559
|
+
` reverse_proxy web:3000 {`,
|
|
560
|
+
` health_uri /api/health`,
|
|
561
|
+
` health_interval 15s`,
|
|
562
|
+
` }`,
|
|
563
|
+
`}`,
|
|
564
|
+
``,
|
|
565
|
+
`${appHost}:${enginePort} {`,
|
|
566
|
+
` reverse_proxy engine:3001 {`,
|
|
567
|
+
` health_uri /health`,
|
|
568
|
+
` health_interval 15s`,
|
|
569
|
+
` }`,
|
|
570
|
+
`}`
|
|
571
|
+
].join("\n");
|
|
572
|
+
}
|
|
573
|
+
return [
|
|
574
|
+
`# Lead Routing \u2014 Caddyfile`,
|
|
575
|
+
`# Generated by lead-routing CLI`,
|
|
576
|
+
`# Caddy auto-provisions SSL certificates via Let's Encrypt`,
|
|
577
|
+
``,
|
|
578
|
+
`${appHost} {`,
|
|
579
|
+
` reverse_proxy web:3000 {`,
|
|
580
|
+
` health_uri /api/health`,
|
|
581
|
+
` health_interval 15s`,
|
|
582
|
+
` }`,
|
|
583
|
+
`}`,
|
|
584
|
+
``,
|
|
585
|
+
`${engineHost} {`,
|
|
586
|
+
` reverse_proxy engine:3001 {`,
|
|
587
|
+
` health_uri /health`,
|
|
588
|
+
` health_interval 15s`,
|
|
589
|
+
` }`,
|
|
590
|
+
`}`
|
|
591
|
+
].join("\n");
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
// src/utils/config.ts
|
|
595
|
+
import { readFileSync, writeFileSync, existsSync as existsSync2 } from "fs";
|
|
596
|
+
import { join } from "path";
|
|
597
|
+
function getConfigPath(dir) {
|
|
598
|
+
return join(dir, "lead-routing.json");
|
|
599
|
+
}
|
|
600
|
+
function readConfig(dir) {
|
|
601
|
+
const path2 = getConfigPath(dir);
|
|
602
|
+
if (!existsSync2(path2)) return null;
|
|
603
|
+
try {
|
|
604
|
+
return JSON.parse(readFileSync(path2, "utf8"));
|
|
605
|
+
} catch {
|
|
606
|
+
return null;
|
|
607
|
+
}
|
|
608
|
+
}
|
|
609
|
+
function writeConfig(dir, config2) {
|
|
610
|
+
writeFileSync(getConfigPath(dir), JSON.stringify(config2, null, 2), "utf8");
|
|
611
|
+
}
|
|
612
|
+
function findInstallDir(startDir = process.cwd()) {
|
|
613
|
+
const candidate = join(startDir, "lead-routing.json");
|
|
614
|
+
if (existsSync2(candidate)) return startDir;
|
|
615
|
+
const nested = join(startDir, "lead-routing", "lead-routing.json");
|
|
616
|
+
if (existsSync2(nested)) return join(startDir, "lead-routing");
|
|
617
|
+
return null;
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
// src/steps/generate-files.ts
|
|
621
|
+
function generateFiles(cfg, sshCfg) {
|
|
622
|
+
const dir = join2(process.cwd(), "lead-routing");
|
|
623
|
+
mkdirSync(dir, { recursive: true });
|
|
624
|
+
const dockerEngineUrl = `http://engine:3001`;
|
|
625
|
+
const composeContent = renderDockerCompose({
|
|
626
|
+
managedDb: cfg.managedDb,
|
|
627
|
+
managedRedis: cfg.managedRedis,
|
|
628
|
+
dbPassword: cfg.dbPassword
|
|
629
|
+
});
|
|
630
|
+
const composeFile = join2(dir, "docker-compose.yml");
|
|
631
|
+
writeFileSync2(composeFile, composeContent, "utf8");
|
|
632
|
+
log2.success("Generated docker-compose.yml");
|
|
633
|
+
const caddyfileContent = renderCaddyfile(cfg.appUrl, cfg.engineUrl);
|
|
634
|
+
writeFileSync2(join2(dir, "Caddyfile"), caddyfileContent, "utf8");
|
|
635
|
+
log2.success("Generated Caddyfile");
|
|
636
|
+
const envWebContent = renderEnvWeb({
|
|
637
|
+
appUrl: cfg.appUrl,
|
|
638
|
+
engineUrl: dockerEngineUrl,
|
|
639
|
+
databaseUrl: cfg.databaseUrl,
|
|
640
|
+
redisUrl: cfg.redisUrl,
|
|
641
|
+
sfdcClientId: cfg.sfdcClientId,
|
|
642
|
+
sfdcClientSecret: cfg.sfdcClientSecret,
|
|
643
|
+
sfdcLoginUrl: cfg.sfdcLoginUrl,
|
|
644
|
+
sessionSecret: cfg.sessionSecret,
|
|
645
|
+
engineWebhookSecret: cfg.engineWebhookSecret,
|
|
646
|
+
adminSecret: cfg.adminSecret,
|
|
647
|
+
resendApiKey: cfg.resendApiKey || void 0,
|
|
648
|
+
feedbackToEmail: cfg.feedbackToEmail || void 0
|
|
649
|
+
});
|
|
650
|
+
const envWeb = join2(dir, ".env.web");
|
|
651
|
+
writeFileSync2(envWeb, envWebContent, "utf8");
|
|
652
|
+
log2.success("Generated .env.web");
|
|
653
|
+
const envEngineContent = renderEnvEngine({
|
|
654
|
+
databaseUrl: cfg.databaseUrl,
|
|
655
|
+
redisUrl: cfg.redisUrl,
|
|
656
|
+
sfdcClientId: cfg.sfdcClientId,
|
|
657
|
+
sfdcClientSecret: cfg.sfdcClientSecret,
|
|
658
|
+
sfdcLoginUrl: cfg.sfdcLoginUrl,
|
|
659
|
+
engineWebhookSecret: cfg.engineWebhookSecret
|
|
660
|
+
});
|
|
661
|
+
const envEngine = join2(dir, ".env.engine");
|
|
662
|
+
writeFileSync2(envEngine, envEngineContent, "utf8");
|
|
663
|
+
log2.success("Generated .env.engine");
|
|
664
|
+
writeConfig(dir, {
|
|
665
|
+
appUrl: cfg.appUrl,
|
|
666
|
+
engineUrl: cfg.engineUrl,
|
|
667
|
+
installDir: dir,
|
|
668
|
+
remoteDir: sshCfg.remoteDir,
|
|
669
|
+
ssh: {
|
|
670
|
+
host: sshCfg.host,
|
|
671
|
+
port: sshCfg.port,
|
|
672
|
+
username: sshCfg.username,
|
|
673
|
+
privateKeyPath: sshCfg.privateKeyPath
|
|
674
|
+
// password intentionally not stored
|
|
675
|
+
},
|
|
676
|
+
dockerManaged: {
|
|
677
|
+
db: cfg.managedDb,
|
|
678
|
+
redis: cfg.managedRedis
|
|
679
|
+
},
|
|
680
|
+
// Stored so `lead-routing sfdc deploy` can re-authenticate without re-prompting
|
|
681
|
+
sfdcClientId: cfg.sfdcClientId,
|
|
682
|
+
sfdcLoginUrl: cfg.sfdcLoginUrl,
|
|
683
|
+
installedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
684
|
+
version: "0.1.0"
|
|
685
|
+
});
|
|
686
|
+
log2.success("Generated lead-routing.json");
|
|
687
|
+
return { dir, composeFile, envWeb, envEngine, adminSecret: cfg.adminSecret };
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
// src/steps/check-remote-prerequisites.ts
|
|
691
|
+
import { log as log3 } from "@clack/prompts";
|
|
692
|
+
async function checkRemotePrerequisites(ssh) {
|
|
693
|
+
const results = await Promise.all([
|
|
694
|
+
checkRemoteDocker(ssh),
|
|
695
|
+
checkRemoteDockerCompose(ssh),
|
|
696
|
+
checkRemotePort(ssh, 80),
|
|
697
|
+
checkRemotePort(ssh, 443)
|
|
698
|
+
]);
|
|
699
|
+
const failed = results.filter((r) => !r.ok && !r.warn);
|
|
700
|
+
const warnings = results.filter((r) => !r.ok && r.warn);
|
|
701
|
+
for (const r of results) {
|
|
702
|
+
if (r.ok) {
|
|
703
|
+
log3.success(r.label);
|
|
704
|
+
} else if (r.warn) {
|
|
705
|
+
log3.warn(r.label);
|
|
706
|
+
} else {
|
|
707
|
+
log3.error(r.label);
|
|
708
|
+
}
|
|
709
|
+
}
|
|
710
|
+
if (warnings.length > 0) {
|
|
711
|
+
log3.warn("Non-blocking warnings above \u2014 setup will continue.");
|
|
712
|
+
}
|
|
713
|
+
if (failed.length > 0) {
|
|
714
|
+
throw new Error(
|
|
715
|
+
`Remote server is missing required software:
|
|
716
|
+
` + failed.map((r) => ` \u2022 ${r.label}`).join("\n") + `
|
|
717
|
+
|
|
718
|
+
Install the missing software on your server and re-run lead-routing init.`
|
|
719
|
+
);
|
|
720
|
+
}
|
|
721
|
+
}
|
|
722
|
+
async function checkRemoteDocker(ssh) {
|
|
723
|
+
const { stdout, code } = await ssh.execSilent("docker --version");
|
|
724
|
+
if (code !== 0 || !stdout) {
|
|
725
|
+
return {
|
|
726
|
+
ok: false,
|
|
727
|
+
label: "Docker \u2014 not found on server (install Docker Engine 24+)"
|
|
728
|
+
};
|
|
729
|
+
}
|
|
730
|
+
const match = stdout.match(/Docker version (\d+)/);
|
|
731
|
+
if (match && parseInt(match[1], 10) < 24) {
|
|
732
|
+
return { ok: false, label: `Docker ${stdout.trim()} \u2014 version 24+ required` };
|
|
733
|
+
}
|
|
734
|
+
return { ok: true, label: `Docker \u2014 ${stdout.trim()}` };
|
|
735
|
+
}
|
|
736
|
+
async function checkRemoteDockerCompose(ssh) {
|
|
737
|
+
const { stdout, code } = await ssh.execSilent("docker compose version");
|
|
738
|
+
if (code !== 0 || !stdout) {
|
|
739
|
+
return {
|
|
740
|
+
ok: false,
|
|
741
|
+
label: "Docker Compose \u2014 not found on server (update Docker to include Compose v2)"
|
|
742
|
+
};
|
|
743
|
+
}
|
|
744
|
+
return { ok: true, label: `Docker Compose \u2014 ${stdout.trim()}` };
|
|
745
|
+
}
|
|
746
|
+
async function checkRemotePort(ssh, port) {
|
|
747
|
+
const { stdout } = await ssh.execSilent(
|
|
748
|
+
`ss -tlnp 2>/dev/null | grep ':${port} ' || netstat -tlnp 2>/dev/null | grep ':${port} ' || echo "free"`
|
|
749
|
+
);
|
|
750
|
+
const isBound = stdout.trim() !== "free" && stdout.trim() !== "";
|
|
751
|
+
if (isBound) {
|
|
752
|
+
return {
|
|
753
|
+
ok: false,
|
|
754
|
+
warn: true,
|
|
755
|
+
label: `Port ${port} \u2014 already in use on server (Caddy needs it for HTTPS \u2014 ensure nothing else is binding it)`
|
|
756
|
+
};
|
|
757
|
+
}
|
|
758
|
+
return { ok: true, label: `Port ${port} \u2014 available` };
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
// src/steps/upload-files.ts
|
|
762
|
+
import { join as join3 } from "path";
|
|
763
|
+
import { spinner as spinner2 } from "@clack/prompts";
|
|
764
|
+
async function uploadFiles(ssh, localDir, remoteDir) {
|
|
765
|
+
const s = spinner2();
|
|
766
|
+
s.start("Uploading config files to server");
|
|
767
|
+
try {
|
|
768
|
+
await ssh.mkdir(remoteDir);
|
|
769
|
+
const filenames = [
|
|
770
|
+
"docker-compose.yml",
|
|
771
|
+
"Caddyfile",
|
|
772
|
+
".env.web",
|
|
773
|
+
".env.engine",
|
|
774
|
+
"lead-routing.json"
|
|
775
|
+
];
|
|
776
|
+
await ssh.upload(
|
|
777
|
+
filenames.map((f) => ({
|
|
778
|
+
local: join3(localDir, f),
|
|
779
|
+
remote: `${remoteDir}/${f}`
|
|
780
|
+
}))
|
|
781
|
+
);
|
|
782
|
+
s.stop(`Config files uploaded to ${remoteDir}`);
|
|
783
|
+
} catch (err) {
|
|
784
|
+
s.stop("File upload failed");
|
|
785
|
+
throw err;
|
|
786
|
+
}
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
// src/steps/start-services.ts
|
|
790
|
+
import { spinner as spinner3, log as log4 } from "@clack/prompts";
|
|
791
|
+
async function startServices(ssh, remoteDir) {
|
|
792
|
+
await wipeStalePostgresVolume(ssh, remoteDir);
|
|
793
|
+
await pullImages(ssh, remoteDir);
|
|
794
|
+
await startContainers(ssh, remoteDir);
|
|
795
|
+
await waitForPostgres(ssh, remoteDir);
|
|
796
|
+
}
|
|
797
|
+
async function wipeStalePostgresVolume(ssh, remoteDir) {
|
|
798
|
+
const dirName = remoteDir.split("/").filter(Boolean).pop() ?? "lead-routing";
|
|
799
|
+
const volumeName = `${dirName}_postgres_data`;
|
|
800
|
+
const { code } = await ssh.execSilent(`docker volume inspect ${volumeName}`);
|
|
801
|
+
if (code !== 0) {
|
|
802
|
+
return;
|
|
803
|
+
}
|
|
804
|
+
const s = spinner3();
|
|
805
|
+
s.start("Removing existing database volume for clean install");
|
|
806
|
+
try {
|
|
807
|
+
await ssh.exec("docker compose down -v --remove-orphans", remoteDir);
|
|
808
|
+
s.stop("Old volumes removed \u2014 database will be initialised fresh");
|
|
809
|
+
} catch {
|
|
810
|
+
s.stop("Could not remove old volumes \u2014 proceeding anyway");
|
|
811
|
+
log4.warn(
|
|
812
|
+
`If migrations fail with "authentication error", remove the postgres_data volume manually:
|
|
813
|
+
ssh into your server and run: docker volume rm ${volumeName}`
|
|
814
|
+
);
|
|
815
|
+
}
|
|
816
|
+
}
|
|
817
|
+
async function pullImages(ssh, remoteDir) {
|
|
818
|
+
const s = spinner3();
|
|
819
|
+
s.start("Pulling Docker images on server (this may take a few minutes)");
|
|
820
|
+
try {
|
|
821
|
+
await ssh.exec("docker compose pull", remoteDir);
|
|
822
|
+
s.stop("Images pulled successfully");
|
|
823
|
+
} catch {
|
|
824
|
+
s.stop("Could not pull images from registry \u2014 using local images if available");
|
|
825
|
+
log4.warn(
|
|
826
|
+
"Registry pull failed. If images are available on the server locally, setup will continue."
|
|
827
|
+
);
|
|
828
|
+
}
|
|
829
|
+
}
|
|
830
|
+
async function startContainers(ssh, remoteDir) {
|
|
831
|
+
const s = spinner3();
|
|
832
|
+
s.start("Starting services");
|
|
833
|
+
try {
|
|
834
|
+
await ssh.exec("docker compose up -d --remove-orphans", remoteDir);
|
|
835
|
+
s.stop("Services started");
|
|
836
|
+
} catch (err) {
|
|
837
|
+
s.stop("Failed to start services");
|
|
838
|
+
throw err;
|
|
839
|
+
}
|
|
840
|
+
}
|
|
841
|
+
async function waitForPostgres(ssh, remoteDir) {
|
|
842
|
+
const s = spinner3();
|
|
843
|
+
s.start("Waiting for PostgreSQL to be ready");
|
|
844
|
+
const maxAttempts = 24;
|
|
845
|
+
let containerReady = false;
|
|
846
|
+
for (let i = 0; i < maxAttempts; i++) {
|
|
847
|
+
const { code } = await ssh.execSilent(
|
|
848
|
+
"docker compose exec -T postgres pg_isready -U leadrouting",
|
|
849
|
+
remoteDir
|
|
850
|
+
);
|
|
851
|
+
if (code === 0) {
|
|
852
|
+
containerReady = true;
|
|
853
|
+
break;
|
|
854
|
+
}
|
|
855
|
+
s.message(`Waiting for PostgreSQL (${i + 1}/${maxAttempts})`);
|
|
856
|
+
await sleep(2500);
|
|
857
|
+
}
|
|
858
|
+
if (!containerReady) {
|
|
859
|
+
s.stop("PostgreSQL readiness check timed out \u2014 continuing anyway");
|
|
860
|
+
log4.warn("PostgreSQL may not be fully ready. If migrations fail, try `lead-routing deploy`.");
|
|
861
|
+
return;
|
|
862
|
+
}
|
|
863
|
+
for (let j = 0; j < 8; j++) {
|
|
864
|
+
const { code } = await ssh.execSilent(
|
|
865
|
+
"nc -z 127.0.0.1 5432 2>/dev/null || bash -c 'echo > /dev/tcp/127.0.0.1/5432' 2>/dev/null"
|
|
866
|
+
);
|
|
867
|
+
if (code === 0) {
|
|
868
|
+
s.stop("PostgreSQL is ready");
|
|
869
|
+
return;
|
|
870
|
+
}
|
|
871
|
+
s.message(`Waiting for PostgreSQL host port (${j + 1}/8)`);
|
|
872
|
+
await sleep(1e3);
|
|
873
|
+
}
|
|
874
|
+
s.stop("PostgreSQL is ready");
|
|
875
|
+
log4.warn("Host TCP port check timed out \u2014 tunnel may have issues. Proceeding anyway.");
|
|
876
|
+
}
|
|
877
|
+
function sleep(ms) {
|
|
878
|
+
return new Promise((resolve2) => setTimeout(resolve2, ms));
|
|
879
|
+
}
|
|
880
|
+
|
|
881
|
+
// src/steps/run-migrations.ts
|
|
882
|
+
import * as fs from "fs";
|
|
883
|
+
import * as path from "path";
|
|
884
|
+
import * as crypto from "crypto";
|
|
885
|
+
import { fileURLToPath } from "url";
|
|
886
|
+
import { execa as execa2 } from "execa";
|
|
887
|
+
import { spinner as spinner4 } from "@clack/prompts";
|
|
888
|
+
var __filename = fileURLToPath(import.meta.url);
|
|
889
|
+
var __dirname = path.dirname(__filename);
|
|
890
|
+
function readEnvVar(envFile, key) {
|
|
891
|
+
const content = fs.readFileSync(envFile, "utf8");
|
|
892
|
+
const match = content.match(new RegExp(`^${key}=(.+)$`, "m"));
|
|
893
|
+
if (!match) throw new Error(`${key} not found in ${envFile}`);
|
|
894
|
+
return match[1].trim().replace(/^["']|["']$/g, "");
|
|
895
|
+
}
|
|
896
|
+
function getTunneledDbUrl(localDir, localPort) {
|
|
897
|
+
const rawUrl = readEnvVar(path.join(localDir, ".env.web"), "DATABASE_URL");
|
|
898
|
+
const parsed = new URL(rawUrl);
|
|
899
|
+
parsed.hostname = "localhost";
|
|
900
|
+
parsed.port = String(localPort);
|
|
901
|
+
return parsed.toString();
|
|
902
|
+
}
|
|
903
|
+
function findPrismaBin() {
|
|
904
|
+
const candidates = [
|
|
905
|
+
path.join(__dirname, "../node_modules/.bin/prisma"),
|
|
906
|
+
path.join(__dirname, "../node_modules/prisma/bin/prisma.js"),
|
|
907
|
+
path.resolve("packages/db/node_modules/.bin/prisma"),
|
|
908
|
+
path.resolve("node_modules/.bin/prisma"),
|
|
909
|
+
path.resolve("node_modules/.pnpm/node_modules/.bin/prisma")
|
|
910
|
+
];
|
|
911
|
+
const found = candidates.find(fs.existsSync);
|
|
912
|
+
if (!found) throw new Error("Prisma binary not found \u2014 CLI may need to be reinstalled.");
|
|
913
|
+
return found;
|
|
914
|
+
}
|
|
915
|
+
async function runMigrations(ssh, localDir, adminEmail, adminPassword) {
|
|
916
|
+
const s = spinner4();
|
|
917
|
+
s.start("Opening secure tunnel to database");
|
|
918
|
+
let tunnelClose;
|
|
919
|
+
try {
|
|
920
|
+
const { localPort, close } = await ssh.tunnel(5432);
|
|
921
|
+
tunnelClose = close;
|
|
922
|
+
s.stop(`Database tunnel open (local port ${localPort})`);
|
|
923
|
+
await applyMigrations(localDir, localPort);
|
|
924
|
+
await seedAdminUser(localDir, localPort, adminEmail, adminPassword);
|
|
925
|
+
} finally {
|
|
926
|
+
tunnelClose?.();
|
|
927
|
+
}
|
|
928
|
+
}
|
|
929
|
+
async function applyMigrations(localDir, localPort) {
|
|
930
|
+
const s = spinner4();
|
|
931
|
+
s.start("Running database migrations");
|
|
932
|
+
try {
|
|
933
|
+
const DATABASE_URL = getTunneledDbUrl(localDir, localPort);
|
|
934
|
+
const prismaBin = findPrismaBin();
|
|
935
|
+
const bundledSchema = path.join(__dirname, "prisma/schema.prisma");
|
|
936
|
+
const monoSchema = path.resolve("packages/db/prisma/schema.prisma");
|
|
937
|
+
const schemaPath = fs.existsSync(bundledSchema) ? bundledSchema : monoSchema;
|
|
938
|
+
await execa2(prismaBin, ["migrate", "deploy", "--schema", schemaPath], {
|
|
939
|
+
env: { ...process.env, DATABASE_URL }
|
|
940
|
+
});
|
|
941
|
+
s.stop("Database migrations applied");
|
|
942
|
+
} catch (err) {
|
|
943
|
+
s.stop("Migrations failed");
|
|
944
|
+
throw err;
|
|
945
|
+
}
|
|
946
|
+
}
|
|
947
|
+
async function seedAdminUser(localDir, localPort, adminEmail, adminPassword) {
|
|
948
|
+
const s = spinner4();
|
|
949
|
+
s.start("Creating admin user");
|
|
950
|
+
try {
|
|
951
|
+
const DATABASE_URL = getTunneledDbUrl(localDir, localPort);
|
|
952
|
+
const webhookSecret = readEnvVar(path.join(localDir, ".env.engine"), "ENGINE_WEBHOOK_SECRET");
|
|
953
|
+
const salt = crypto.randomBytes(16).toString("hex");
|
|
954
|
+
const pbkdf2Hash = crypto.pbkdf2Sync(adminPassword, salt, 31e4, 32, "sha256").toString("hex");
|
|
955
|
+
const passwordHash = `${salt}:${pbkdf2Hash}`;
|
|
956
|
+
const safeEmail = adminEmail.replace(/'/g, "''");
|
|
957
|
+
const safeWebhookSecret = webhookSecret.replace(/'/g, "''");
|
|
958
|
+
const sql = `
|
|
959
|
+
-- Create initial organisation if none exists
|
|
960
|
+
INSERT INTO organizations (id, "webhookSecret", "createdAt", "updatedAt")
|
|
961
|
+
SELECT gen_random_uuid(), '${safeWebhookSecret}', NOW(), NOW()
|
|
962
|
+
WHERE NOT EXISTS (SELECT 1 FROM organizations);
|
|
963
|
+
|
|
964
|
+
-- Create admin AppUser under the first org (idempotent)
|
|
965
|
+
INSERT INTO app_users (id, "orgId", email, name, "passwordHash", role, "isActive", "createdAt", "updatedAt")
|
|
966
|
+
SELECT gen_random_uuid(), o.id, '${safeEmail}', 'Admin', '${passwordHash}', 'ADMIN', true, NOW(), NOW()
|
|
967
|
+
FROM organizations o
|
|
968
|
+
LIMIT 1
|
|
969
|
+
ON CONFLICT ("orgId", email) DO NOTHING;
|
|
970
|
+
`;
|
|
971
|
+
const prismaBin = findPrismaBin();
|
|
972
|
+
await execa2(prismaBin, ["db", "execute", "--stdin", "--url", DATABASE_URL], { input: sql });
|
|
973
|
+
s.stop("Admin user ready");
|
|
974
|
+
} catch (err) {
|
|
975
|
+
s.stop("Seed failed");
|
|
976
|
+
throw err;
|
|
977
|
+
}
|
|
978
|
+
}
|
|
979
|
+
|
|
980
|
+
// src/steps/verify-health.ts
|
|
981
|
+
import { spinner as spinner5, log as log5 } from "@clack/prompts";
|
|
982
|
+
async function verifyHealth(appUrl, engineUrl) {
|
|
983
|
+
const checks = [
|
|
984
|
+
{ service: "Web app", url: `${appUrl}/api/health` },
|
|
985
|
+
{ service: "Routing engine", url: `${engineUrl}/health` }
|
|
986
|
+
];
|
|
987
|
+
const results = await Promise.all(checks.map(({ service, url }) => pollHealth(service, url)));
|
|
988
|
+
for (const r of results) {
|
|
989
|
+
if (r.ok) {
|
|
990
|
+
log5.success(`${r.service} \u2014 ${r.url}`);
|
|
991
|
+
} else {
|
|
992
|
+
log5.warn(`${r.service} not responding yet \u2014 ${r.detail}`);
|
|
993
|
+
}
|
|
994
|
+
}
|
|
995
|
+
}
|
|
996
|
+
async function pollHealth(service, url, maxAttempts = 24, intervalMs = 5e3) {
|
|
997
|
+
const s = spinner5();
|
|
998
|
+
s.start(`Waiting for ${service}`);
|
|
999
|
+
for (let i = 0; i < maxAttempts; i++) {
|
|
1000
|
+
try {
|
|
1001
|
+
const res = await fetch(url, { signal: AbortSignal.timeout(4e3) });
|
|
1002
|
+
if (res.ok) {
|
|
1003
|
+
s.stop(`${service} is up`);
|
|
1004
|
+
return { service, url, ok: true, detail: `HTTP ${res.status}` };
|
|
1005
|
+
}
|
|
1006
|
+
s.message(`${service} \u2014 HTTP ${res.status}, retrying (${i + 1}/${maxAttempts})`);
|
|
1007
|
+
} catch (err) {
|
|
1008
|
+
const detail = err instanceof Error ? err.message : String(err);
|
|
1009
|
+
s.message(`${service} \u2014 ${detail}, retrying (${i + 1}/${maxAttempts})`);
|
|
1010
|
+
}
|
|
1011
|
+
await sleep2(intervalMs);
|
|
1012
|
+
}
|
|
1013
|
+
s.stop(`${service} \u2014 did not respond after ${maxAttempts} attempts`);
|
|
1014
|
+
return {
|
|
1015
|
+
service,
|
|
1016
|
+
url,
|
|
1017
|
+
ok: false,
|
|
1018
|
+
detail: `timed out after ${maxAttempts * intervalMs / 1e3}s`
|
|
1019
|
+
};
|
|
1020
|
+
}
|
|
1021
|
+
function sleep2(ms) {
|
|
1022
|
+
return new Promise((resolve2) => setTimeout(resolve2, ms));
|
|
1023
|
+
}
|
|
1024
|
+
|
|
1025
|
+
// src/steps/sfdc-deploy-inline.ts
|
|
1026
|
+
import { readFileSync as readFileSync3, writeFileSync as writeFileSync3, existsSync as existsSync4, cpSync, rmSync } from "fs";
|
|
1027
|
+
import { join as join5, dirname as dirname2 } from "path";
|
|
1028
|
+
import { tmpdir } from "os";
|
|
1029
|
+
import { fileURLToPath as fileURLToPath2 } from "url";
|
|
1030
|
+
import { spinner as spinner6, log as log6 } from "@clack/prompts";
|
|
1031
|
+
import { execa as execa3 } from "execa";
|
|
1032
|
+
var __dirname2 = dirname2(fileURLToPath2(import.meta.url));
|
|
1033
|
+
function patchXml(content, tag, value) {
|
|
1034
|
+
const re = new RegExp(`(<${tag}>)[^<]*(</\\s*${tag}>)`, "g");
|
|
1035
|
+
return content.replace(re, `$1${value}$2`);
|
|
1036
|
+
}
|
|
1037
|
+
async function sfdcDeployInline(params) {
|
|
1038
|
+
const { appUrl, engineUrl, orgAlias, installDir } = params;
|
|
1039
|
+
const s = spinner6();
|
|
1040
|
+
const { code: authCheck } = await execa3(
|
|
1041
|
+
"sf",
|
|
1042
|
+
["org", "display", "--target-org", orgAlias, "--json"],
|
|
1043
|
+
{ reject: false }
|
|
1044
|
+
);
|
|
1045
|
+
const alreadyAuthed = authCheck === 0;
|
|
1046
|
+
if (alreadyAuthed) {
|
|
1047
|
+
log6.success("Using existing Salesforce authentication");
|
|
1048
|
+
} else {
|
|
1049
|
+
await loginViaAppBridge(appUrl, orgAlias);
|
|
1050
|
+
}
|
|
1051
|
+
s.start("Copying Salesforce package\u2026");
|
|
1052
|
+
const bundledPkg = join5(__dirname2, "..", "sfdc-package");
|
|
1053
|
+
const destPkg = join5(installDir ?? tmpdir(), "lead-routing-sfdc-package");
|
|
1054
|
+
if (!existsSync4(bundledPkg)) {
|
|
1055
|
+
s.stop("sfdc-package not found in CLI bundle");
|
|
1056
|
+
throw new Error(
|
|
1057
|
+
`Expected bundle at: ${bundledPkg}
|
|
1058
|
+
The CLI may need to be reinstalled: npm i -g @lead-routing/cli`
|
|
1059
|
+
);
|
|
1060
|
+
}
|
|
1061
|
+
if (existsSync4(destPkg)) rmSync(destPkg, { recursive: true, force: true });
|
|
1062
|
+
cpSync(bundledPkg, destPkg, { recursive: true });
|
|
1063
|
+
s.stop("Package copied");
|
|
1064
|
+
const ncPath = join5(
|
|
1065
|
+
destPkg,
|
|
1066
|
+
"force-app",
|
|
1067
|
+
"main",
|
|
1068
|
+
"default",
|
|
1069
|
+
"namedCredentials",
|
|
1070
|
+
"RoutingEngine.namedCredential-meta.xml"
|
|
1071
|
+
);
|
|
1072
|
+
if (existsSync4(ncPath)) {
|
|
1073
|
+
const nc = patchXml(readFileSync3(ncPath, "utf8"), "endpoint", engineUrl);
|
|
1074
|
+
writeFileSync3(ncPath, nc, "utf8");
|
|
1075
|
+
}
|
|
1076
|
+
const rssEnginePath = join5(
|
|
1077
|
+
destPkg,
|
|
1078
|
+
"force-app",
|
|
1079
|
+
"main",
|
|
1080
|
+
"default",
|
|
1081
|
+
"remoteSiteSettings",
|
|
1082
|
+
"LeadRouterEngine.remoteSite-meta.xml"
|
|
1083
|
+
);
|
|
1084
|
+
if (existsSync4(rssEnginePath)) {
|
|
1085
|
+
let rss = patchXml(readFileSync3(rssEnginePath, "utf8"), "url", engineUrl);
|
|
1086
|
+
rss = patchXml(rss, "description", "Lead Router Engine endpoint");
|
|
1087
|
+
writeFileSync3(rssEnginePath, rss, "utf8");
|
|
1088
|
+
}
|
|
1089
|
+
const rssAppPath = join5(
|
|
1090
|
+
destPkg,
|
|
1091
|
+
"force-app",
|
|
1092
|
+
"main",
|
|
1093
|
+
"default",
|
|
1094
|
+
"remoteSiteSettings",
|
|
1095
|
+
"LeadRouterApp.remoteSite-meta.xml"
|
|
1096
|
+
);
|
|
1097
|
+
if (existsSync4(rssAppPath)) {
|
|
1098
|
+
let rss = patchXml(readFileSync3(rssAppPath, "utf8"), "url", appUrl);
|
|
1099
|
+
rss = patchXml(rss, "description", "Lead Router App URL");
|
|
1100
|
+
writeFileSync3(rssAppPath, rss, "utf8");
|
|
1101
|
+
}
|
|
1102
|
+
log6.success("Remote Site Settings patched");
|
|
1103
|
+
s.start("Deploying Salesforce package (this may take ~2 min)\u2026");
|
|
1104
|
+
try {
|
|
1105
|
+
await execa3(
|
|
1106
|
+
"sf",
|
|
1107
|
+
["project", "deploy", "start", "--target-org", orgAlias, "--source-dir", "force-app"],
|
|
1108
|
+
{ cwd: destPkg, stdio: "inherit" }
|
|
1109
|
+
);
|
|
1110
|
+
s.stop("Package deployed");
|
|
1111
|
+
} catch (err) {
|
|
1112
|
+
s.stop("Deployment failed");
|
|
1113
|
+
throw new Error(
|
|
1114
|
+
`sf project deploy failed: ${String(err)}
|
|
1115
|
+
|
|
1116
|
+
Retry manually:
|
|
1117
|
+
cd ${destPkg}
|
|
1118
|
+
sf project deploy start --target-org ${orgAlias} --source-dir force-app`
|
|
1119
|
+
);
|
|
1120
|
+
}
|
|
1121
|
+
s.start("Assigning LeadRouterAdmin permission set\u2026");
|
|
1122
|
+
try {
|
|
1123
|
+
await execa3(
|
|
1124
|
+
"sf",
|
|
1125
|
+
["org", "assign", "permset", "--name", "LeadRouterAdmin", "--target-org", orgAlias],
|
|
1126
|
+
{ stdio: "inherit" }
|
|
1127
|
+
);
|
|
1128
|
+
s.stop("Permission set assigned \u2014 Lead Router Setup will appear in the App Launcher");
|
|
1129
|
+
} catch (err) {
|
|
1130
|
+
const msg = String(err);
|
|
1131
|
+
if (msg.includes("Duplicate PermissionSetAssignment")) {
|
|
1132
|
+
s.stop("Permission set already assigned");
|
|
1133
|
+
} else {
|
|
1134
|
+
s.stop("Permission set assignment failed (non-fatal)");
|
|
1135
|
+
log6.warn(msg);
|
|
1136
|
+
log6.info(
|
|
1137
|
+
"Grant access manually:\n Salesforce Setup \u2192 Users \u2192 Permission Sets \u2192 Lead Router Admin \u2192 Manage Assignments"
|
|
1138
|
+
);
|
|
1139
|
+
}
|
|
1140
|
+
}
|
|
1141
|
+
s.start("Writing org settings to Routing_Settings__c\u2026");
|
|
1142
|
+
try {
|
|
1143
|
+
let existingId;
|
|
1144
|
+
try {
|
|
1145
|
+
const qr = await execa3("sf", [
|
|
1146
|
+
"data",
|
|
1147
|
+
"query",
|
|
1148
|
+
"--target-org",
|
|
1149
|
+
orgAlias,
|
|
1150
|
+
"--query",
|
|
1151
|
+
"SELECT Id FROM Routing_Settings__c LIMIT 1",
|
|
1152
|
+
"--json"
|
|
1153
|
+
]);
|
|
1154
|
+
const parsed = JSON.parse(qr.stdout);
|
|
1155
|
+
existingId = parsed?.result?.records?.[0]?.Id;
|
|
1156
|
+
} catch {
|
|
1157
|
+
}
|
|
1158
|
+
if (existingId) {
|
|
1159
|
+
await execa3("sf", [
|
|
1160
|
+
"data",
|
|
1161
|
+
"update",
|
|
1162
|
+
"record",
|
|
1163
|
+
"--target-org",
|
|
1164
|
+
orgAlias,
|
|
1165
|
+
"--sobject",
|
|
1166
|
+
"Routing_Settings__c",
|
|
1167
|
+
"--record-id",
|
|
1168
|
+
existingId,
|
|
1169
|
+
"--values",
|
|
1170
|
+
`App_Url__c='${appUrl}' Engine_Endpoint__c='${engineUrl}'`
|
|
1171
|
+
], { stdio: "inherit" });
|
|
1172
|
+
} else {
|
|
1173
|
+
await execa3("sf", [
|
|
1174
|
+
"data",
|
|
1175
|
+
"create",
|
|
1176
|
+
"record",
|
|
1177
|
+
"--target-org",
|
|
1178
|
+
orgAlias,
|
|
1179
|
+
"--sobject",
|
|
1180
|
+
"Routing_Settings__c",
|
|
1181
|
+
"--values",
|
|
1182
|
+
`App_Url__c='${appUrl}' Engine_Endpoint__c='${engineUrl}'`
|
|
1183
|
+
], { stdio: "inherit" });
|
|
1184
|
+
}
|
|
1185
|
+
s.stop("Org settings written");
|
|
1186
|
+
} catch (err) {
|
|
1187
|
+
s.stop("Org settings write failed (non-fatal)");
|
|
1188
|
+
log6.warn(String(err));
|
|
1189
|
+
log6.info("Set manually: Salesforce \u2192 Custom Settings \u2192 Routing Settings \u2192 Manage");
|
|
1190
|
+
}
|
|
1191
|
+
}
|
|
1192
|
+
async function loginViaAppBridge(rawAppUrl, orgAlias) {
|
|
1193
|
+
const appUrl = rawAppUrl.replace(/\/+$/, "");
|
|
1194
|
+
const s = spinner6();
|
|
1195
|
+
s.start("Starting Salesforce authentication via your Lead Router app\u2026");
|
|
1196
|
+
let sessionId;
|
|
1197
|
+
let authUrl;
|
|
1198
|
+
try {
|
|
1199
|
+
const res = await fetch(`${appUrl}/api/cli-auth/request`, { method: "POST" });
|
|
1200
|
+
if (!res.ok) {
|
|
1201
|
+
s.stop("Failed to start auth session");
|
|
1202
|
+
throw new Error(
|
|
1203
|
+
`Could not reach ${appUrl}/api/cli-auth/request (HTTP ${res.status}).
|
|
1204
|
+
Make sure the web app is running and accessible.`
|
|
1205
|
+
);
|
|
1206
|
+
}
|
|
1207
|
+
const data = await res.json();
|
|
1208
|
+
sessionId = data.sessionId;
|
|
1209
|
+
authUrl = data.authUrl;
|
|
1210
|
+
} catch (err) {
|
|
1211
|
+
s.stop("Could not reach Lead Router app");
|
|
1212
|
+
throw new Error(
|
|
1213
|
+
`Failed to connect to ${appUrl}: ${String(err)}
|
|
1214
|
+
Ensure the app is running and the URL is correct.`
|
|
1215
|
+
);
|
|
1216
|
+
}
|
|
1217
|
+
s.stop("Auth session started");
|
|
1218
|
+
log6.info(`Open this URL in your browser to authenticate with Salesforce:
|
|
1219
|
+
|
|
1220
|
+
${authUrl}
|
|
1221
|
+
`);
|
|
1222
|
+
log6.info('If Chrome shows a "Dangerous site" warning with no proceed option, paste the URL into Safari or Firefox.');
|
|
1223
|
+
const opener = process.platform === "win32" ? "start" : process.platform === "darwin" ? "open" : "xdg-open";
|
|
1224
|
+
await execa3(opener, [authUrl], { reject: false }).catch(() => {
|
|
1225
|
+
});
|
|
1226
|
+
s.start("Waiting for Salesforce authentication in browser\u2026");
|
|
1227
|
+
const maxPolls = 150;
|
|
1228
|
+
let accessToken;
|
|
1229
|
+
let instanceUrl;
|
|
1230
|
+
for (let i = 0; i < maxPolls; i++) {
|
|
1231
|
+
await new Promise((r) => setTimeout(r, 2e3));
|
|
1232
|
+
try {
|
|
1233
|
+
const pollRes = await fetch(`${appUrl}/api/cli-auth/poll/${sessionId}`);
|
|
1234
|
+
if (pollRes.status === 410) {
|
|
1235
|
+
s.stop("Auth session expired");
|
|
1236
|
+
throw new Error("CLI auth session expired. Please re-run the command.");
|
|
1237
|
+
}
|
|
1238
|
+
const data = await pollRes.json();
|
|
1239
|
+
if (data.status === "ok") {
|
|
1240
|
+
accessToken = data.accessToken;
|
|
1241
|
+
instanceUrl = data.instanceUrl;
|
|
1242
|
+
break;
|
|
1243
|
+
}
|
|
1244
|
+
} catch (err) {
|
|
1245
|
+
if (String(err).includes("session expired")) throw err;
|
|
1246
|
+
}
|
|
1247
|
+
}
|
|
1248
|
+
if (!accessToken || !instanceUrl) {
|
|
1249
|
+
s.stop("Timed out");
|
|
1250
|
+
throw new Error(
|
|
1251
|
+
"Timed out waiting for Salesforce authentication (5 minutes).\nPlease re-run the command and complete login within 5 minutes."
|
|
1252
|
+
);
|
|
1253
|
+
}
|
|
1254
|
+
s.stop("Authenticated with Salesforce");
|
|
1255
|
+
try {
|
|
1256
|
+
await execa3(
|
|
1257
|
+
"sf",
|
|
1258
|
+
["org", "login", "access-token", "--instance-url", instanceUrl, "--alias", orgAlias, "--no-prompt"],
|
|
1259
|
+
{ input: accessToken + "\n" }
|
|
1260
|
+
);
|
|
1261
|
+
log6.success(`Salesforce org saved as "${orgAlias}"`);
|
|
1262
|
+
} catch (err) {
|
|
1263
|
+
log6.warn(`Could not store sf CLI credentials: ${String(err)}`);
|
|
1264
|
+
log6.info("Re-authenticate manually if deploy commands fail: sf org login web");
|
|
1265
|
+
}
|
|
1266
|
+
}
|
|
1267
|
+
|
|
1268
|
+
// src/steps/app-launcher-guide.ts
|
|
1269
|
+
import { note as note3, confirm as confirm2, isCancel as isCancel3, log as log7 } from "@clack/prompts";
|
|
1270
|
+
import chalk from "chalk";
|
|
1271
|
+
async function guideAppLauncherSetup(appUrl) {
|
|
1272
|
+
note3(
|
|
1273
|
+
`Complete the following steps in Salesforce now:
|
|
1274
|
+
|
|
1275
|
+
${chalk.cyan("1.")} Open ${chalk.bold("App Launcher")} (grid icon, top-left in Salesforce)
|
|
1276
|
+
${chalk.cyan("2.")} Search for ${chalk.white('"Lead Router Setup"')} and click it
|
|
1277
|
+
${chalk.cyan("3.")} Click ${chalk.white('"Connect to Lead Router"')}
|
|
1278
|
+
\u2192 You will be redirected to ${chalk.dim(appUrl)} and back
|
|
1279
|
+
\u2192 Authorize the OAuth connection when prompted
|
|
1280
|
+
|
|
1281
|
+
${chalk.cyan("4.")} ${chalk.bold("Step 1")} \u2014 wait for the ${chalk.green('"Connected"')} checkmark (~5 sec)
|
|
1282
|
+
${chalk.cyan("5.")} ${chalk.bold("Step 2")} \u2014 click ${chalk.white("Activate")} to enable Lead triggers
|
|
1283
|
+
${chalk.cyan("6.")} ${chalk.bold("Step 3")} \u2014 click ${chalk.white("Sync Fields")} to index your Lead field schema
|
|
1284
|
+
${chalk.cyan("7.")} ${chalk.bold("Step 4")} \u2014 click ${chalk.white("Send Test")} to fire a test routing event
|
|
1285
|
+
\u2192 ${chalk.dim('"Test successful"')} or ${chalk.dim('"No matching rule"')} are both valid
|
|
1286
|
+
|
|
1287
|
+
` + chalk.dim("Keep this terminal open while you complete the wizard."),
|
|
1288
|
+
"Complete Salesforce setup"
|
|
1289
|
+
);
|
|
1290
|
+
const done = await confirm2({
|
|
1291
|
+
message: "Have you completed the App Launcher wizard?",
|
|
1292
|
+
initialValue: false
|
|
1293
|
+
});
|
|
1294
|
+
if (isCancel3(done)) {
|
|
1295
|
+
log7.warn(
|
|
1296
|
+
"Wizard skipped. Run `lead-routing sfdc deploy` to retry the Salesforce setup."
|
|
1297
|
+
);
|
|
1298
|
+
return;
|
|
1299
|
+
}
|
|
1300
|
+
if (!done) {
|
|
1301
|
+
log7.warn(
|
|
1302
|
+
`No problem \u2014 complete it at your own pace.
|
|
1303
|
+
Open App Launcher \u2192 Lead Router Setup \u2192 Connect to Lead Router
|
|
1304
|
+
Dashboard: ${appUrl}`
|
|
1305
|
+
);
|
|
1306
|
+
} else {
|
|
1307
|
+
log7.success("Salesforce setup complete");
|
|
1308
|
+
}
|
|
1309
|
+
}
|
|
1310
|
+
|
|
1311
|
+
// src/utils/ssh.ts
|
|
1312
|
+
import net from "net";
|
|
1313
|
+
import { NodeSSH } from "node-ssh";
|
|
1314
|
+
var SshConnection = class {
|
|
1315
|
+
ssh = new NodeSSH();
|
|
1316
|
+
_connected = false;
|
|
1317
|
+
async connect(config2) {
|
|
1318
|
+
const opts = {
|
|
1319
|
+
host: config2.host,
|
|
1320
|
+
port: config2.port,
|
|
1321
|
+
username: config2.username,
|
|
1322
|
+
// Reduce connection timeout to fail fast on bad creds
|
|
1323
|
+
readyTimeout: 15e3
|
|
1324
|
+
};
|
|
1325
|
+
if (config2.privateKeyPath) {
|
|
1326
|
+
opts.privateKeyPath = config2.privateKeyPath;
|
|
1327
|
+
} else if (config2.password) {
|
|
1328
|
+
opts.password = config2.password;
|
|
1329
|
+
}
|
|
1330
|
+
await this.ssh.connect(opts);
|
|
1331
|
+
this._connected = true;
|
|
1332
|
+
}
|
|
1333
|
+
get isConnected() {
|
|
1334
|
+
return this._connected;
|
|
1335
|
+
}
|
|
1336
|
+
/**
|
|
1337
|
+
* Run a command remotely. Throws if exit code is non-zero.
|
|
1338
|
+
*/
|
|
1339
|
+
async exec(cmd, cwd) {
|
|
1340
|
+
const result = await this.ssh.execCommand(cmd, cwd ? { cwd } : {});
|
|
1341
|
+
if (result.code !== 0) {
|
|
1342
|
+
throw new Error(
|
|
1343
|
+
`Remote command failed (exit ${result.code}): ${cmd}
|
|
1344
|
+
${result.stderr || result.stdout}`
|
|
1345
|
+
);
|
|
1346
|
+
}
|
|
1347
|
+
return { stdout: result.stdout, stderr: result.stderr };
|
|
1348
|
+
}
|
|
1349
|
+
/**
|
|
1350
|
+
* Run a command remotely without throwing — returns code + output.
|
|
1351
|
+
*/
|
|
1352
|
+
async execSilent(cmd, cwd) {
|
|
1353
|
+
const result = await this.ssh.execCommand(cmd, cwd ? { cwd } : {});
|
|
1354
|
+
return { stdout: result.stdout, stderr: result.stderr, code: result.code ?? 1 };
|
|
1355
|
+
}
|
|
1356
|
+
/**
|
|
1357
|
+
* Create a remote directory (including parents).
|
|
1358
|
+
*/
|
|
1359
|
+
async mkdir(remotePath) {
|
|
1360
|
+
await this.exec(`mkdir -p ${remotePath}`);
|
|
1361
|
+
}
|
|
1362
|
+
/**
|
|
1363
|
+
* Upload local files to the remote server via SFTP.
|
|
1364
|
+
*/
|
|
1365
|
+
async upload(files) {
|
|
1366
|
+
await this.ssh.putFiles(files);
|
|
1367
|
+
}
|
|
1368
|
+
/**
|
|
1369
|
+
* Resolve ~ in a remote path by querying $HOME on the server.
|
|
1370
|
+
*/
|
|
1371
|
+
async resolveHome(remotePath) {
|
|
1372
|
+
if (!remotePath.startsWith("~")) return remotePath;
|
|
1373
|
+
const { stdout } = await this.exec("echo $HOME");
|
|
1374
|
+
return stdout.trim() + remotePath.slice(1);
|
|
1375
|
+
}
|
|
1376
|
+
/**
|
|
1377
|
+
* Open an SSH port-forward tunnel.
|
|
1378
|
+
* Creates a local TCP server that pipes connections through to
|
|
1379
|
+
* localhost:{remotePort} on the remote machine.
|
|
1380
|
+
*
|
|
1381
|
+
* Returns the local port and a close() function.
|
|
1382
|
+
* Call close() when migrations are done.
|
|
1383
|
+
*/
|
|
1384
|
+
async tunnel(remotePort) {
|
|
1385
|
+
const sshClient = this.ssh.connection;
|
|
1386
|
+
if (!sshClient) throw new Error("SSH not connected \u2014 cannot open tunnel");
|
|
1387
|
+
const server = net.createServer((socket) => {
|
|
1388
|
+
;
|
|
1389
|
+
sshClient.forwardOut(
|
|
1390
|
+
"127.0.0.1",
|
|
1391
|
+
0,
|
|
1392
|
+
"localhost",
|
|
1393
|
+
remotePort,
|
|
1394
|
+
(err, stream) => {
|
|
1395
|
+
if (err) {
|
|
1396
|
+
socket.destroy();
|
|
1397
|
+
return;
|
|
1398
|
+
}
|
|
1399
|
+
socket.pipe(stream);
|
|
1400
|
+
stream.pipe(socket);
|
|
1401
|
+
socket.on("close", () => stream.destroy());
|
|
1402
|
+
stream.on("close", () => socket.destroy());
|
|
1403
|
+
}
|
|
1404
|
+
);
|
|
1405
|
+
});
|
|
1406
|
+
return new Promise((resolve2, reject) => {
|
|
1407
|
+
server.listen(0, "127.0.0.1", () => {
|
|
1408
|
+
const { port } = server.address();
|
|
1409
|
+
resolve2({
|
|
1410
|
+
localPort: port,
|
|
1411
|
+
close: () => server.close()
|
|
1412
|
+
});
|
|
1413
|
+
});
|
|
1414
|
+
server.on("error", reject);
|
|
1415
|
+
});
|
|
1416
|
+
}
|
|
1417
|
+
async disconnect() {
|
|
1418
|
+
if (this._connected) {
|
|
1419
|
+
this.ssh.dispose();
|
|
1420
|
+
this._connected = false;
|
|
1421
|
+
}
|
|
1422
|
+
}
|
|
1423
|
+
};
|
|
1424
|
+
|
|
1425
|
+
// src/commands/init.ts
|
|
1426
|
+
async function runInit(options = {}) {
|
|
1427
|
+
const dryRun = options.dryRun ?? false;
|
|
1428
|
+
console.log();
|
|
1429
|
+
intro(
|
|
1430
|
+
chalk2.bold.cyan("Lead Routing \u2014 Self-Hosted Setup") + (dryRun ? chalk2.yellow(" [dry run]") : "")
|
|
1431
|
+
);
|
|
1432
|
+
const ssh = new SshConnection();
|
|
1433
|
+
try {
|
|
1434
|
+
log8.step("Step 1/9 Checking local prerequisites");
|
|
1435
|
+
await checkPrerequisites();
|
|
1436
|
+
log8.step("Step 2/9 Server connection");
|
|
1437
|
+
const sshCfg = await collectSshConfig();
|
|
1438
|
+
log8.step("Step 3/9 Configuration");
|
|
1439
|
+
const cfg = await collectConfig();
|
|
1440
|
+
log8.step("Step 4/9 Generating config files");
|
|
1441
|
+
const { dir, adminSecret } = generateFiles(cfg, sshCfg);
|
|
1442
|
+
note4(
|
|
1443
|
+
`Local config directory: ${chalk2.cyan(dir)}
|
|
1444
|
+
Files created: docker-compose.yml, Caddyfile, .env.web, .env.engine, lead-routing.json`,
|
|
1445
|
+
"Files"
|
|
1446
|
+
);
|
|
1447
|
+
if (dryRun) {
|
|
1448
|
+
outro(
|
|
1449
|
+
chalk2.yellow("Dry run complete \u2014 no connection made, no services started.") + `
|
|
1450
|
+
|
|
1451
|
+
Config files written to: ${chalk2.cyan(dir)}
|
|
1452
|
+
|
|
1453
|
+
When ready, run ${chalk2.cyan("lead-routing init")} (without --dry-run) to deploy.`
|
|
1454
|
+
);
|
|
1455
|
+
return;
|
|
1456
|
+
}
|
|
1457
|
+
log8.step("Step 5/9 Connecting to server");
|
|
1458
|
+
await ssh.connect(sshCfg);
|
|
1459
|
+
const remoteDir = await ssh.resolveHome(sshCfg.remoteDir);
|
|
1460
|
+
await checkRemotePrerequisites(ssh);
|
|
1461
|
+
await uploadFiles(ssh, dir, remoteDir);
|
|
1462
|
+
log8.step("Step 6/9 Starting services");
|
|
1463
|
+
await startServices(ssh, remoteDir);
|
|
1464
|
+
log8.step("Step 7/9 Database migrations");
|
|
1465
|
+
await runMigrations(ssh, dir, cfg.adminEmail, cfg.adminPassword);
|
|
1466
|
+
log8.step("Step 8/9 Verifying health");
|
|
1467
|
+
await verifyHealth(cfg.appUrl, cfg.engineUrl);
|
|
1468
|
+
log8.step("Step 9/9 Deploying Salesforce package");
|
|
1469
|
+
await sfdcDeployInline({
|
|
1470
|
+
appUrl: cfg.appUrl,
|
|
1471
|
+
engineUrl: cfg.engineUrl,
|
|
1472
|
+
orgAlias: cfg.orgAlias,
|
|
1473
|
+
sfdcClientId: cfg.sfdcClientId,
|
|
1474
|
+
sfdcLoginUrl: cfg.sfdcLoginUrl,
|
|
1475
|
+
installDir: dir
|
|
1476
|
+
});
|
|
1477
|
+
await guideAppLauncherSetup(cfg.appUrl);
|
|
1478
|
+
outro(
|
|
1479
|
+
chalk2.green("\u2714 You're live!") + `
|
|
1480
|
+
|
|
1481
|
+
Dashboard: ${chalk2.cyan(cfg.appUrl)}
|
|
1482
|
+
Routing engine: ${chalk2.cyan(cfg.engineUrl)}
|
|
1483
|
+
|
|
1484
|
+
Admin email: ${chalk2.white(cfg.adminEmail)}
|
|
1485
|
+
Admin secret: ${chalk2.yellow(adminSecret)}
|
|
1486
|
+
${chalk2.dim("run `lead-routing config show` to retrieve later")}
|
|
1487
|
+
|
|
1488
|
+
` + chalk2.bold(" Next steps:\n") + ` ${chalk2.cyan("1.")} Open ${chalk2.cyan(cfg.appUrl)} and log in
|
|
1489
|
+
${chalk2.cyan("2.")} Create your first routing rule to start routing leads
|
|
1490
|
+
|
|
1491
|
+
Run ${chalk2.cyan("lead-routing doctor")} to check service health at any time.
|
|
1492
|
+
Run ${chalk2.cyan("lead-routing deploy")} to update to a new version.`
|
|
1493
|
+
);
|
|
1494
|
+
} catch (err) {
|
|
1495
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
1496
|
+
log8.error(`Setup failed: ${message}`);
|
|
1497
|
+
process.exit(1);
|
|
1498
|
+
} finally {
|
|
1499
|
+
await ssh.disconnect();
|
|
1500
|
+
}
|
|
1501
|
+
}
|
|
1502
|
+
|
|
1503
|
+
// src/commands/deploy.ts
|
|
1504
|
+
import { writeFileSync as writeFileSync4, unlinkSync } from "fs";
|
|
1505
|
+
import { join as join6 } from "path";
|
|
1506
|
+
import { tmpdir as tmpdir2 } from "os";
|
|
1507
|
+
import { intro as intro2, outro as outro2, log as log9, password as promptPassword } from "@clack/prompts";
|
|
1508
|
+
import chalk3 from "chalk";
|
|
1509
|
+
async function runDeploy() {
|
|
1510
|
+
console.log();
|
|
1511
|
+
intro2(chalk3.bold.cyan("Lead Routing \u2014 Deploy"));
|
|
1512
|
+
const dir = findInstallDir();
|
|
1513
|
+
if (!dir) {
|
|
1514
|
+
log9.error(
|
|
1515
|
+
"No lead-routing.json found. Run `lead-routing init` first, or run this command from your install directory."
|
|
1516
|
+
);
|
|
1517
|
+
process.exit(1);
|
|
1518
|
+
}
|
|
1519
|
+
const cfg = readConfig(dir);
|
|
1520
|
+
const ssh = new SshConnection();
|
|
1521
|
+
let sshPassword;
|
|
1522
|
+
if (!cfg.ssh.privateKeyPath) {
|
|
1523
|
+
const pw = await promptPassword({
|
|
1524
|
+
message: `SSH password for ${cfg.ssh.username}@${cfg.ssh.host}`
|
|
1525
|
+
});
|
|
1526
|
+
if (typeof pw === "symbol") process.exit(0);
|
|
1527
|
+
sshPassword = pw;
|
|
1528
|
+
}
|
|
1529
|
+
try {
|
|
1530
|
+
await ssh.connect({
|
|
1531
|
+
host: cfg.ssh.host,
|
|
1532
|
+
port: cfg.ssh.port,
|
|
1533
|
+
username: cfg.ssh.username,
|
|
1534
|
+
privateKeyPath: cfg.ssh.privateKeyPath,
|
|
1535
|
+
password: sshPassword,
|
|
1536
|
+
remoteDir: cfg.remoteDir
|
|
1537
|
+
});
|
|
1538
|
+
log9.success(`Connected to ${cfg.ssh.host}`);
|
|
1539
|
+
} catch (err) {
|
|
1540
|
+
log9.error(`SSH connection failed: ${String(err)}`);
|
|
1541
|
+
process.exit(1);
|
|
1542
|
+
}
|
|
1543
|
+
try {
|
|
1544
|
+
const remoteDir = await ssh.resolveHome(cfg.remoteDir);
|
|
1545
|
+
log9.step("Syncing Caddyfile");
|
|
1546
|
+
const caddyContent = renderCaddyfile(cfg.appUrl, cfg.engineUrl);
|
|
1547
|
+
const tmpCaddy = join6(tmpdir2(), "lead-routing-Caddyfile");
|
|
1548
|
+
writeFileSync4(tmpCaddy, caddyContent, "utf8");
|
|
1549
|
+
await ssh.upload([{ local: tmpCaddy, remote: `${remoteDir}/Caddyfile` }]);
|
|
1550
|
+
unlinkSync(tmpCaddy);
|
|
1551
|
+
await ssh.exec("docker compose restart caddy", remoteDir);
|
|
1552
|
+
log9.success("Caddyfile synced \u2014 waiting for TLS cert (~30s)");
|
|
1553
|
+
log9.step("Pulling latest Docker images");
|
|
1554
|
+
await ssh.exec("docker compose pull", remoteDir);
|
|
1555
|
+
log9.success("Images pulled");
|
|
1556
|
+
log9.step("Restarting services");
|
|
1557
|
+
await ssh.exec("docker compose up -d --remove-orphans", remoteDir);
|
|
1558
|
+
log9.success("Services restarted");
|
|
1559
|
+
log9.step("Running database migrations");
|
|
1560
|
+
await runMigrations(ssh, dir, "", "");
|
|
1561
|
+
outro2(
|
|
1562
|
+
chalk3.green("\u2714 Deployment complete!") + `
|
|
1563
|
+
|
|
1564
|
+
${chalk3.cyan(cfg.appUrl)}`
|
|
1565
|
+
);
|
|
1566
|
+
} catch (err) {
|
|
1567
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
1568
|
+
log9.error(`Deploy failed: ${message}`);
|
|
1569
|
+
process.exit(1);
|
|
1570
|
+
} finally {
|
|
1571
|
+
await ssh.disconnect();
|
|
1572
|
+
}
|
|
1573
|
+
}
|
|
1574
|
+
|
|
1575
|
+
// src/commands/doctor.ts
|
|
1576
|
+
import { intro as intro3, outro as outro3, log as log10 } from "@clack/prompts";
|
|
1577
|
+
import chalk4 from "chalk";
|
|
1578
|
+
import { execa as execa4 } from "execa";
|
|
1579
|
+
async function runDoctor() {
|
|
1580
|
+
console.log();
|
|
1581
|
+
intro3(chalk4.bold.cyan("Lead Routing \u2014 Health Check"));
|
|
1582
|
+
const dir = findInstallDir();
|
|
1583
|
+
if (!dir) {
|
|
1584
|
+
log10.error("No lead-routing.json found. Run `lead-routing init` first.");
|
|
1585
|
+
process.exit(1);
|
|
1586
|
+
}
|
|
1587
|
+
const cfg = readConfig(dir);
|
|
1588
|
+
const checks = [];
|
|
1589
|
+
checks.push(await checkDockerDaemon());
|
|
1590
|
+
const containers = ["web", "engine"];
|
|
1591
|
+
if (cfg.dockerManaged.db) containers.push("postgres");
|
|
1592
|
+
if (cfg.dockerManaged.redis) containers.push("redis");
|
|
1593
|
+
for (const name of containers) {
|
|
1594
|
+
checks.push(await checkContainer(name, dir));
|
|
1595
|
+
}
|
|
1596
|
+
checks.push(await checkEndpoint("Web app", `${cfg.appUrl}/api/health`));
|
|
1597
|
+
checks.push(await checkEndpoint("Routing engine", `${cfg.engineUrl}/health`));
|
|
1598
|
+
console.log();
|
|
1599
|
+
for (const c of checks) {
|
|
1600
|
+
const icon = c.pass ? chalk4.green("\u2714") : chalk4.red("\u2717");
|
|
1601
|
+
const label = c.pass ? chalk4.white(c.label) : chalk4.red(c.label);
|
|
1602
|
+
const detail = c.detail ? chalk4.dim(` \u2014 ${c.detail}`) : "";
|
|
1603
|
+
console.log(` ${icon} ${label}${detail}`);
|
|
1604
|
+
}
|
|
1605
|
+
console.log();
|
|
1606
|
+
const failed = checks.filter((c) => !c.pass);
|
|
1607
|
+
if (failed.length === 0) {
|
|
1608
|
+
outro3(chalk4.green("All checks passed"));
|
|
1609
|
+
} else {
|
|
1610
|
+
outro3(chalk4.yellow(`${failed.length} check(s) failed`));
|
|
1611
|
+
process.exit(1);
|
|
1612
|
+
}
|
|
1613
|
+
}
|
|
1614
|
+
async function checkDockerDaemon() {
|
|
1615
|
+
try {
|
|
1616
|
+
await execa4("docker", ["info"], { reject: true });
|
|
1617
|
+
return { label: "Docker daemon", pass: true };
|
|
1618
|
+
} catch {
|
|
1619
|
+
return { label: "Docker daemon", pass: false, detail: "not running" };
|
|
1620
|
+
}
|
|
1621
|
+
}
|
|
1622
|
+
async function checkContainer(name, dir) {
|
|
1623
|
+
try {
|
|
1624
|
+
const result = await execa4(
|
|
1625
|
+
"docker",
|
|
1626
|
+
["compose", "ps", "--format", "json", name],
|
|
1627
|
+
{ cwd: dir, reject: false }
|
|
1628
|
+
);
|
|
1629
|
+
const output = result.stdout.trim();
|
|
1630
|
+
if (!output) {
|
|
1631
|
+
return { label: `Container: ${name}`, pass: false, detail: "not found" };
|
|
1632
|
+
}
|
|
1633
|
+
const rows = output.split("\n").map((l) => {
|
|
1634
|
+
try {
|
|
1635
|
+
return JSON.parse(l);
|
|
1636
|
+
} catch {
|
|
1637
|
+
return null;
|
|
1638
|
+
}
|
|
1639
|
+
}).filter(Boolean);
|
|
1640
|
+
const running = rows.some(
|
|
1641
|
+
(r) => r.State === "running" || r.Status && r.Status.toLowerCase().includes("up")
|
|
1642
|
+
);
|
|
1643
|
+
return {
|
|
1644
|
+
label: `Container: ${name}`,
|
|
1645
|
+
pass: running,
|
|
1646
|
+
detail: running ? "running" : "not running"
|
|
1647
|
+
};
|
|
1648
|
+
} catch {
|
|
1649
|
+
return { label: `Container: ${name}`, pass: false, detail: "error checking status" };
|
|
1650
|
+
}
|
|
1651
|
+
}
|
|
1652
|
+
async function checkEndpoint(label, url) {
|
|
1653
|
+
try {
|
|
1654
|
+
const res = await fetch(url, { signal: AbortSignal.timeout(5e3) });
|
|
1655
|
+
return {
|
|
1656
|
+
label: `Health: ${label}`,
|
|
1657
|
+
pass: res.ok,
|
|
1658
|
+
detail: `HTTP ${res.status}`
|
|
1659
|
+
};
|
|
1660
|
+
} catch (err) {
|
|
1661
|
+
const detail = err instanceof Error ? err.message : String(err);
|
|
1662
|
+
return { label: `Health: ${label}`, pass: false, detail };
|
|
1663
|
+
}
|
|
1664
|
+
}
|
|
1665
|
+
|
|
1666
|
+
// src/commands/logs.ts
|
|
1667
|
+
import { log as log11 } from "@clack/prompts";
|
|
1668
|
+
import { execa as execa5 } from "execa";
|
|
1669
|
+
var VALID_SERVICES = ["web", "engine", "postgres", "redis"];
|
|
1670
|
+
async function runLogs(service = "engine") {
|
|
1671
|
+
if (!VALID_SERVICES.includes(service)) {
|
|
1672
|
+
log11.error(`Unknown service "${service}". Valid options: ${VALID_SERVICES.join(", ")}`);
|
|
1673
|
+
process.exit(1);
|
|
1674
|
+
}
|
|
1675
|
+
const dir = findInstallDir();
|
|
1676
|
+
if (!dir) {
|
|
1677
|
+
log11.error("No lead-routing.json found. Run `lead-routing init` first.");
|
|
1678
|
+
process.exit(1);
|
|
1679
|
+
}
|
|
1680
|
+
console.log(`
|
|
1681
|
+
Streaming logs for ${service} (Ctrl+C to stop)...
|
|
1682
|
+
`);
|
|
1683
|
+
const child = execa5("docker", ["compose", "logs", "-f", "--tail=100", service], {
|
|
1684
|
+
cwd: dir,
|
|
1685
|
+
stdio: "inherit",
|
|
1686
|
+
reject: false
|
|
1687
|
+
});
|
|
1688
|
+
await child;
|
|
1689
|
+
}
|
|
1690
|
+
|
|
1691
|
+
// src/commands/status.ts
|
|
1692
|
+
import { log as log12 } from "@clack/prompts";
|
|
1693
|
+
import { execa as execa6 } from "execa";
|
|
1694
|
+
async function runStatus() {
|
|
1695
|
+
const dir = findInstallDir();
|
|
1696
|
+
if (!dir) {
|
|
1697
|
+
log12.error("No lead-routing.json found. Run `lead-routing init` first.");
|
|
1698
|
+
process.exit(1);
|
|
1699
|
+
}
|
|
1700
|
+
const result = await execa6("docker", ["compose", "ps"], {
|
|
1701
|
+
cwd: dir,
|
|
1702
|
+
stdio: "inherit",
|
|
1703
|
+
reject: false
|
|
1704
|
+
});
|
|
1705
|
+
if (result.exitCode !== 0) {
|
|
1706
|
+
log12.error("Failed to get container status. Is Docker running?");
|
|
1707
|
+
process.exit(1);
|
|
1708
|
+
}
|
|
1709
|
+
}
|
|
1710
|
+
|
|
1711
|
+
// src/commands/config.ts
|
|
1712
|
+
import { readFileSync as readFileSync4, writeFileSync as writeFileSync5, existsSync as existsSync5 } from "fs";
|
|
1713
|
+
import { join as join7 } from "path";
|
|
1714
|
+
import { intro as intro4, outro as outro4, text as text3, password as password3, spinner as spinner7, log as log13 } from "@clack/prompts";
|
|
1715
|
+
import chalk5 from "chalk";
|
|
1716
|
+
import { execa as execa7 } from "execa";
|
|
1717
|
+
function parseEnv(filePath) {
|
|
1718
|
+
const map = /* @__PURE__ */ new Map();
|
|
1719
|
+
if (!existsSync5(filePath)) return map;
|
|
1720
|
+
for (const line of readFileSync4(filePath, "utf8").split("\n")) {
|
|
1721
|
+
const trimmed = line.trim();
|
|
1722
|
+
if (!trimmed || trimmed.startsWith("#")) continue;
|
|
1723
|
+
const eq = trimmed.indexOf("=");
|
|
1724
|
+
if (eq === -1) continue;
|
|
1725
|
+
map.set(trimmed.slice(0, eq), trimmed.slice(eq + 1));
|
|
1726
|
+
}
|
|
1727
|
+
return map;
|
|
1728
|
+
}
|
|
1729
|
+
function writeEnv(filePath, updates) {
|
|
1730
|
+
const lines = existsSync5(filePath) ? readFileSync4(filePath, "utf8").split("\n") : [];
|
|
1731
|
+
const updated = /* @__PURE__ */ new Set();
|
|
1732
|
+
const result = lines.map((line) => {
|
|
1733
|
+
const trimmed = line.trim();
|
|
1734
|
+
if (!trimmed || trimmed.startsWith("#")) return line;
|
|
1735
|
+
const eq = trimmed.indexOf("=");
|
|
1736
|
+
if (eq === -1) return line;
|
|
1737
|
+
const key = trimmed.slice(0, eq);
|
|
1738
|
+
if (key in updates) {
|
|
1739
|
+
updated.add(key);
|
|
1740
|
+
return `${key}=${updates[key]}`;
|
|
1741
|
+
}
|
|
1742
|
+
return line;
|
|
1743
|
+
});
|
|
1744
|
+
for (const [key, val] of Object.entries(updates)) {
|
|
1745
|
+
if (!updated.has(key)) result.push(`${key}=${val}`);
|
|
1746
|
+
}
|
|
1747
|
+
writeFileSync5(filePath, result.join("\n"), "utf8");
|
|
1748
|
+
}
|
|
1749
|
+
async function runConfigSfdc() {
|
|
1750
|
+
intro4("Lead Routing \u2014 Update Salesforce Credentials");
|
|
1751
|
+
const dir = findInstallDir();
|
|
1752
|
+
if (!dir) {
|
|
1753
|
+
log13.error("No lead-routing installation found in the current directory.");
|
|
1754
|
+
log13.info("Run `lead-routing init` first, or cd into your installation directory.");
|
|
1755
|
+
process.exit(1);
|
|
1756
|
+
}
|
|
1757
|
+
const envWeb = join7(dir, ".env.web");
|
|
1758
|
+
const envEngine = join7(dir, ".env.engine");
|
|
1759
|
+
const currentWeb = parseEnv(envWeb);
|
|
1760
|
+
const currentClientId = currentWeb.get("SFDC_CLIENT_ID") ?? "";
|
|
1761
|
+
const currentLoginUrl = currentWeb.get("SFDC_LOGIN_URL") ?? "https://login.salesforce.com";
|
|
1762
|
+
const currentAppUrl = currentWeb.get("APP_URL") ?? "";
|
|
1763
|
+
const callbackUrl = `${currentAppUrl}/api/auth/callback`;
|
|
1764
|
+
log13.info(
|
|
1765
|
+
`Paste the credentials from your Salesforce Connected App.
|
|
1766
|
+
Callback URL for your Connected App: ${callbackUrl}`
|
|
1767
|
+
);
|
|
1768
|
+
const clientId = await text3({
|
|
1769
|
+
message: "Consumer Key (Client ID)",
|
|
1770
|
+
initialValue: currentClientId,
|
|
1771
|
+
validate: (v) => !v ? "Required" : void 0
|
|
1772
|
+
});
|
|
1773
|
+
if (clientId === null || typeof clientId === "symbol") {
|
|
1774
|
+
process.exit(0);
|
|
1775
|
+
}
|
|
1776
|
+
const clientSecret = await password3({
|
|
1777
|
+
message: "Consumer Secret (Client Secret)",
|
|
1778
|
+
validate: (v) => !v ? "Required" : void 0
|
|
1779
|
+
});
|
|
1780
|
+
if (clientSecret === null || typeof clientSecret === "symbol") {
|
|
1781
|
+
process.exit(0);
|
|
1782
|
+
}
|
|
1783
|
+
const updates = {
|
|
1784
|
+
SFDC_CLIENT_ID: clientId,
|
|
1785
|
+
SFDC_CLIENT_SECRET: clientSecret
|
|
1786
|
+
};
|
|
1787
|
+
writeEnv(envWeb, updates);
|
|
1788
|
+
writeEnv(envEngine, updates);
|
|
1789
|
+
log13.success("Updated .env.web and .env.engine");
|
|
1790
|
+
const s = spinner7();
|
|
1791
|
+
s.start("Restarting web and engine containers\u2026");
|
|
1792
|
+
try {
|
|
1793
|
+
await execa7("docker", ["compose", "up", "-d", "--force-recreate", "web", "engine"], {
|
|
1794
|
+
cwd: dir
|
|
1795
|
+
});
|
|
1796
|
+
s.stop("Containers restarted");
|
|
1797
|
+
} catch (err) {
|
|
1798
|
+
s.stop("Restart failed \u2014 run `docker compose up -d --force-recreate web engine` manually");
|
|
1799
|
+
log13.warn(String(err));
|
|
1800
|
+
}
|
|
1801
|
+
outro4(
|
|
1802
|
+
"Salesforce credentials updated!\n\nNext: go to the web app \u2192 Settings \u2192 Connect Salesforce to refresh your OAuth tokens."
|
|
1803
|
+
);
|
|
1804
|
+
}
|
|
1805
|
+
function runConfigShow() {
|
|
1806
|
+
const dir = findInstallDir();
|
|
1807
|
+
if (!dir) {
|
|
1808
|
+
console.error("No lead-routing installation found in the current directory.");
|
|
1809
|
+
process.exit(1);
|
|
1810
|
+
}
|
|
1811
|
+
const envWeb = join7(dir, ".env.web");
|
|
1812
|
+
const cfg = parseEnv(envWeb);
|
|
1813
|
+
const adminSecret = cfg.get("ADMIN_SECRET") ?? "(not found)";
|
|
1814
|
+
const appUrl = cfg.get("APP_URL") ?? "(not found)";
|
|
1815
|
+
const sfdcClientId = cfg.get("SFDC_CLIENT_ID") ?? "(not found)";
|
|
1816
|
+
console.log();
|
|
1817
|
+
console.log(chalk5.bold("Lead Routing \u2014 Installation Config"));
|
|
1818
|
+
console.log();
|
|
1819
|
+
console.log(` Admin panel: ${chalk5.cyan(appUrl + "/admin")}`);
|
|
1820
|
+
console.log(` Admin secret: ${chalk5.yellow(adminSecret)}`);
|
|
1821
|
+
console.log();
|
|
1822
|
+
console.log(` SFDC Client ID: ${chalk5.white(sfdcClientId)}`);
|
|
1823
|
+
console.log();
|
|
1824
|
+
}
|
|
1825
|
+
|
|
1826
|
+
// src/commands/sfdc.ts
|
|
1827
|
+
import { intro as intro5, outro as outro5, text as text4, spinner as spinner8, log as log14 } from "@clack/prompts";
|
|
1828
|
+
import chalk6 from "chalk";
|
|
1829
|
+
import { execa as execa8 } from "execa";
|
|
1830
|
+
async function runSfdcDeploy() {
|
|
1831
|
+
intro5("Lead Routing \u2014 Deploy Salesforce Package");
|
|
1832
|
+
let appUrl;
|
|
1833
|
+
let engineUrl;
|
|
1834
|
+
const dir = findInstallDir();
|
|
1835
|
+
const config2 = dir ? readConfig(dir) : null;
|
|
1836
|
+
if (config2?.appUrl && config2?.engineUrl) {
|
|
1837
|
+
appUrl = config2.appUrl;
|
|
1838
|
+
engineUrl = config2.engineUrl;
|
|
1839
|
+
log14.info(`Using config from ${dir}/lead-routing.json`);
|
|
1840
|
+
} else {
|
|
1841
|
+
log14.warn("No lead-routing.json found \u2014 enter the URLs from your installation.");
|
|
1842
|
+
const rawApp = await text4({
|
|
1843
|
+
message: "App URL (e.g. https://leads.acme.com)",
|
|
1844
|
+
validate: (v) => !v ? "Required" : void 0
|
|
1845
|
+
});
|
|
1846
|
+
if (typeof rawApp === "symbol") process.exit(0);
|
|
1847
|
+
appUrl = rawApp.trim();
|
|
1848
|
+
const rawEngine = await text4({
|
|
1849
|
+
message: "Engine URL (e.g. https://engine.acme.com or https://acme.com:3001)",
|
|
1850
|
+
validate: (v) => !v ? "Required" : void 0
|
|
1851
|
+
});
|
|
1852
|
+
if (typeof rawEngine === "symbol") process.exit(0);
|
|
1853
|
+
engineUrl = rawEngine.trim();
|
|
1854
|
+
}
|
|
1855
|
+
const s = spinner8();
|
|
1856
|
+
s.start("Checking Salesforce CLI\u2026");
|
|
1857
|
+
try {
|
|
1858
|
+
await execa8("sf", ["--version"], { all: true });
|
|
1859
|
+
s.stop("Salesforce CLI found");
|
|
1860
|
+
} catch {
|
|
1861
|
+
s.stop("Salesforce CLI (sf) not found");
|
|
1862
|
+
log14.error(
|
|
1863
|
+
"Install the Salesforce CLI and re-run this command:\n https://developer.salesforce.com/tools/salesforcecli"
|
|
1864
|
+
);
|
|
1865
|
+
process.exit(1);
|
|
1866
|
+
}
|
|
1867
|
+
const alias = await text4({
|
|
1868
|
+
message: "Salesforce org alias (used to log in)",
|
|
1869
|
+
placeholder: "lead-routing",
|
|
1870
|
+
initialValue: "lead-routing",
|
|
1871
|
+
validate: (v) => !v ? "Required" : void 0
|
|
1872
|
+
});
|
|
1873
|
+
if (typeof alias === "symbol") process.exit(0);
|
|
1874
|
+
try {
|
|
1875
|
+
await sfdcDeployInline({
|
|
1876
|
+
appUrl,
|
|
1877
|
+
engineUrl,
|
|
1878
|
+
orgAlias: alias,
|
|
1879
|
+
// Read from config if available; alreadyAuthed check will skip login if already logged in
|
|
1880
|
+
sfdcClientId: config2?.sfdcClientId ?? "",
|
|
1881
|
+
sfdcLoginUrl: config2?.sfdcLoginUrl ?? "https://login.salesforce.com",
|
|
1882
|
+
installDir: dir ?? void 0
|
|
1883
|
+
});
|
|
1884
|
+
} catch (err) {
|
|
1885
|
+
log14.error(err instanceof Error ? err.message : String(err));
|
|
1886
|
+
process.exit(1);
|
|
1887
|
+
}
|
|
1888
|
+
outro5(
|
|
1889
|
+
chalk6.green("\u2714 Salesforce package deployed!") + `
|
|
1890
|
+
|
|
1891
|
+
Next steps:
|
|
1892
|
+
1. In Salesforce, open App Launcher \u2192 search "Lead Router Setup"
|
|
1893
|
+
2. Click "Connect to Lead Router" to authorise the OAuth connection
|
|
1894
|
+
3. Follow the 4-step wizard to activate triggers and sync field schema
|
|
1895
|
+
|
|
1896
|
+
Your Lead Router dashboard: ${chalk6.cyan(appUrl)}`
|
|
1897
|
+
);
|
|
1898
|
+
}
|
|
1899
|
+
|
|
1900
|
+
// src/index.ts
|
|
1901
|
+
var program = new Command();
|
|
1902
|
+
program.name("lead-routing").description("Self-hosted Lead Routing \u2014 scaffold, deploy, and manage your installation").version("0.1.0");
|
|
1903
|
+
program.command("init").description("Interactive setup wizard \u2014 configure and deploy the full Lead Routing stack").option("--dry-run", "Run the wizard and generate config files without starting Docker services").action((opts) => runInit({ dryRun: opts.dryRun }));
|
|
1904
|
+
program.command("deploy").description("Pull latest images, restart services, and run any pending migrations").action(runDeploy);
|
|
1905
|
+
program.command("doctor").description("Check the health of all services in your installation").action(runDoctor);
|
|
1906
|
+
program.command("logs [service]").description("Stream logs from a service (web, engine, postgres, redis). Defaults to engine.").action((service) => runLogs(service));
|
|
1907
|
+
program.command("status").description("Show the running state of all Docker containers").action(runStatus);
|
|
1908
|
+
var config = program.command("config").description("Update configuration values in a live installation");
|
|
1909
|
+
config.command("show").description("Print key config values for this installation (admin secret, app URL, SFDC client ID)").action(runConfigShow);
|
|
1910
|
+
config.command("sfdc").description("Update Salesforce Connected App credentials (Consumer Key + Secret)").action(runConfigSfdc);
|
|
1911
|
+
var sfdc = program.command("sfdc").description("Manage the Salesforce package for this installation");
|
|
1912
|
+
sfdc.command("deploy").description("Deploy (or redeploy) the Lead Router Salesforce package to your Salesforce org").action(runSfdcDeploy);
|
|
1913
|
+
program.parseAsync(process.argv).catch((err) => {
|
|
1914
|
+
console.error(err instanceof Error ? err.message : String(err));
|
|
1915
|
+
process.exit(1);
|
|
1916
|
+
});
|