@lead-routing/cli 0.6.1 → 0.6.3

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
@@ -9,7 +9,7 @@ import { readFileSync as readFileSync4, writeFileSync as writeFileSync4 } from "
9
9
  import { exec } from "child_process";
10
10
  import { platform } from "os";
11
11
  import { join as join5 } from "path";
12
- import { intro, outro, note as note3, log as log7, confirm, cancel as cancel3, isCancel as isCancel3, password as promptPassword } from "@clack/prompts";
12
+ import { intro, outro, note as note3, log as log7, confirm, cancel as cancel3, isCancel as isCancel3, password as promptPassword, select, text as text3 } from "@clack/prompts";
13
13
  import chalk2 from "chalk";
14
14
 
15
15
  // src/steps/prerequisites.ts
@@ -491,6 +491,8 @@ function writeConfig(dir, config2) {
491
491
  function findInstallDir(startDir = process.cwd()) {
492
492
  const candidate = join(startDir, "lead-routing.json");
493
493
  if (existsSync2(candidate)) return startDir;
494
+ const dev = join(startDir, "lead-routing-dev", "lead-routing.json");
495
+ if (existsSync2(dev)) return join(startDir, "lead-routing-dev");
494
496
  const nested = join(startDir, "lead-routing", "lead-routing.json");
495
497
  if (existsSync2(nested)) return join(startDir, "lead-routing");
496
498
  return null;
@@ -1206,16 +1208,108 @@ async function runInit(options = {}) {
1206
1208
  let auth;
1207
1209
  try {
1208
1210
  auth = await requireAuth();
1209
- } catch (err) {
1210
- log7.error(err instanceof Error ? err.message : "Authentication required");
1211
- note3(
1212
- `Run one of the following:
1213
-
1214
- ${chalk2.cyan("lead-routing signup")} Create a new account
1215
- ${chalk2.cyan("lead-routing login")} Log in to existing account`,
1216
- "Account Required"
1217
- );
1218
- process.exit(1);
1211
+ } catch {
1212
+ const action = await select({
1213
+ message: "You need an account to continue. What would you like to do?",
1214
+ options: [
1215
+ { value: "signup", label: "Create a new account" },
1216
+ { value: "login", label: "Log in to existing account" }
1217
+ ]
1218
+ });
1219
+ if (isCancel3(action)) {
1220
+ cancel3("Setup cancelled.");
1221
+ process.exit(0);
1222
+ }
1223
+ if (action === "signup") {
1224
+ const firstName = await text3({ message: "First name", placeholder: "John", validate: (v) => v.trim() ? void 0 : "Required" });
1225
+ if (isCancel3(firstName)) {
1226
+ cancel3("Setup cancelled.");
1227
+ process.exit(0);
1228
+ }
1229
+ const lastName = await text3({ message: "Last name", placeholder: "Smith", validate: (v) => v.trim() ? void 0 : "Required" });
1230
+ if (isCancel3(lastName)) {
1231
+ cancel3("Setup cancelled.");
1232
+ process.exit(0);
1233
+ }
1234
+ const signupEmail = await text3({ message: "Email", placeholder: "john@acme.com", validate: (v) => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(v.trim()) ? void 0 : "Invalid email" });
1235
+ if (isCancel3(signupEmail)) {
1236
+ cancel3("Setup cancelled.");
1237
+ process.exit(0);
1238
+ }
1239
+ const signupPw = await promptPassword({ message: "Password (min 8 characters)", validate: (v) => v.length >= 8 ? void 0 : "Must be at least 8 characters" });
1240
+ if (isCancel3(signupPw)) {
1241
+ cancel3("Setup cancelled.");
1242
+ process.exit(0);
1243
+ }
1244
+ const confirmPw = await promptPassword({ message: "Confirm password", validate: (v) => v === signupPw ? void 0 : "Passwords do not match" });
1245
+ if (isCancel3(confirmPw)) {
1246
+ cancel3("Setup cancelled.");
1247
+ process.exit(0);
1248
+ }
1249
+ log7.step("Creating account...");
1250
+ try {
1251
+ await apiSignup({ firstName: firstName.trim(), lastName: lastName.trim(), email: signupEmail.trim(), password: signupPw });
1252
+ log7.success("Account created!");
1253
+ } catch (err) {
1254
+ log7.error(err instanceof Error ? err.message : "Signup failed");
1255
+ process.exit(1);
1256
+ }
1257
+ note3(
1258
+ `Check your email (${signupEmail.trim()}) for a verification link.
1259
+ After verifying, press Enter to continue.`,
1260
+ "Verify Email"
1261
+ );
1262
+ await text3({ message: "Press Enter once you've verified your email...", defaultValue: "" });
1263
+ log7.step("Logging in...");
1264
+ try {
1265
+ const { token, customer } = await apiLogin(signupEmail.trim(), signupPw);
1266
+ if (!customer.emailVerified) {
1267
+ log7.warn("Email not verified yet.");
1268
+ const resend = await confirm({ message: "Resend verification email?" });
1269
+ if (resend && !isCancel3(resend)) {
1270
+ await apiResendVerification(token).catch(() => {
1271
+ });
1272
+ log7.info("Verification email sent. Verify and re-run `lead-routing init`.");
1273
+ }
1274
+ process.exit(1);
1275
+ }
1276
+ saveCredentials({ token, customer, storedAt: (/* @__PURE__ */ new Date()).toISOString() });
1277
+ auth = { token, customer, storedAt: (/* @__PURE__ */ new Date()).toISOString() };
1278
+ } catch (err) {
1279
+ log7.error(err instanceof Error ? err.message : "Login failed");
1280
+ process.exit(1);
1281
+ }
1282
+ } else {
1283
+ const loginEmail = await text3({ message: "Email", placeholder: "john@acme.com" });
1284
+ if (isCancel3(loginEmail)) {
1285
+ cancel3("Setup cancelled.");
1286
+ process.exit(0);
1287
+ }
1288
+ const loginPw = await promptPassword({ message: "Password" });
1289
+ if (isCancel3(loginPw)) {
1290
+ cancel3("Setup cancelled.");
1291
+ process.exit(0);
1292
+ }
1293
+ log7.step("Authenticating...");
1294
+ try {
1295
+ const { token, customer } = await apiLogin(loginEmail.trim(), loginPw);
1296
+ if (!customer.emailVerified) {
1297
+ log7.warn("Email not verified yet.");
1298
+ const resend = await confirm({ message: "Resend verification email?" });
1299
+ if (resend && !isCancel3(resend)) {
1300
+ await apiResendVerification(token).catch(() => {
1301
+ });
1302
+ log7.info("Verification email sent. Verify and re-run `lead-routing init`.");
1303
+ }
1304
+ process.exit(1);
1305
+ }
1306
+ saveCredentials({ token, customer, storedAt: (/* @__PURE__ */ new Date()).toISOString() });
1307
+ auth = { token, customer, storedAt: (/* @__PURE__ */ new Date()).toISOString() };
1308
+ } catch (err) {
1309
+ log7.error(err instanceof Error ? err.message : "Login failed");
1310
+ process.exit(1);
1311
+ }
1312
+ }
1219
1313
  }
1220
1314
  log7.success(`Logged in as ${auth.customer.firstName} ${auth.customer.lastName} \u2014 ${formatTierBadge(auth.customer.tier)}`);
1221
1315
  try {
@@ -1589,8 +1683,10 @@ function runConfigShow() {
1589
1683
  }
1590
1684
 
1591
1685
  // src/commands/sfdc.ts
1592
- import { intro as intro5, outro as outro5, text as text3, log as log15 } from "@clack/prompts";
1593
- import chalk7 from "chalk";
1686
+ import { intro as intro5, outro as outro5, text as text4, note as note4, confirm as confirm2, log as log14, isCancel as isCancel4, cancel as cancel4 } from "@clack/prompts";
1687
+ import chalk6 from "chalk";
1688
+ import { exec as exec2 } from "child_process";
1689
+ import { platform as platform2 } from "os";
1594
1690
 
1595
1691
  // src/steps/sfdc-deploy-inline.ts
1596
1692
  import { readFileSync as readFileSync7, writeFileSync as writeFileSync7, existsSync as existsSync5, cpSync, rmSync } from "fs";
@@ -1719,9 +1815,9 @@ Content-Type: application/zip\r
1719
1815
  body
1720
1816
  });
1721
1817
  if (!res.ok) {
1722
- const text6 = await res.text();
1818
+ const text8 = await res.text();
1723
1819
  throw new Error(
1724
- `Metadata deploy request failed (${res.status}): ${text6}`
1820
+ `Metadata deploy request failed (${res.status}): ${text8}`
1725
1821
  );
1726
1822
  }
1727
1823
  const result = await res.json();
@@ -1738,9 +1834,9 @@ Content-Type: application/zip\r
1738
1834
  const url = `${this.baseUrl}/metadata/deployRequest/${deployId}?includeDetails=true`;
1739
1835
  const res = await fetch(url, { headers: this.headers() });
1740
1836
  if (!res.ok) {
1741
- const text6 = await res.text();
1837
+ const text8 = await res.text();
1742
1838
  throw new Error(
1743
- `Deploy status check failed (${res.status}): ${text6}`
1839
+ `Deploy status check failed (${res.status}): ${text8}`
1744
1840
  );
1745
1841
  }
1746
1842
  const data = await res.json();
@@ -2090,50 +2186,12 @@ Ensure the app is running and the URL is correct.`
2090
2186
  return { accessToken, instanceUrl };
2091
2187
  }
2092
2188
 
2093
- // src/steps/app-launcher-guide.ts
2094
- import { note as note4, confirm as confirm2, isCancel as isCancel4, log as log14 } from "@clack/prompts";
2095
- import chalk6 from "chalk";
2096
- async function guideAppLauncherSetup(appUrl) {
2097
- note4(
2098
- `Complete the following steps in Salesforce now:
2099
-
2100
- ${chalk6.cyan("1.")} Open ${chalk6.bold("App Launcher")} (grid icon, top-left in Salesforce)
2101
- ${chalk6.cyan("2.")} Search for ${chalk6.white('"Lead Router Setup"')} and click it
2102
- ${chalk6.cyan("3.")} Click ${chalk6.white('"Connect to Lead Router"')}
2103
- \u2192 You will be redirected to ${chalk6.dim(appUrl)} and back
2104
- \u2192 Authorize the OAuth connection when prompted
2105
-
2106
- ${chalk6.cyan("4.")} ${chalk6.bold("Step 1")} \u2014 wait for the ${chalk6.green('"Connected"')} checkmark (~5 sec)
2107
- ${chalk6.cyan("5.")} ${chalk6.bold("Step 2")} \u2014 click ${chalk6.white("Activate")} to enable Lead triggers
2108
- ${chalk6.cyan("6.")} ${chalk6.bold("Step 3")} \u2014 click ${chalk6.white("Sync Fields")} to index your Lead field schema
2109
- ${chalk6.cyan("7.")} ${chalk6.bold("Step 4")} \u2014 click ${chalk6.white("Send Test")} to fire a test routing event
2110
- \u2192 ${chalk6.dim('"Test successful"')} or ${chalk6.dim('"No matching rule"')} are both valid
2111
-
2112
- ` + chalk6.dim("Keep this terminal open while you complete the wizard."),
2113
- "Complete Salesforce setup"
2114
- );
2115
- const done = await confirm2({
2116
- message: "Have you completed the App Launcher wizard?",
2117
- initialValue: false
2118
- });
2119
- if (isCancel4(done)) {
2120
- log14.warn(
2121
- "Wizard skipped. Run `lead-routing sfdc deploy` to retry the Salesforce setup."
2122
- );
2123
- return;
2124
- }
2125
- if (!done) {
2126
- log14.warn(
2127
- `No problem \u2014 complete it at your own pace.
2128
- Open App Launcher \u2192 Lead Router Setup \u2192 Connect to Lead Router
2129
- Dashboard: ${appUrl}`
2130
- );
2131
- } else {
2132
- log14.success("Salesforce setup complete");
2133
- }
2134
- }
2135
-
2136
2189
  // src/commands/sfdc.ts
2190
+ var MANAGED_PACKAGE_INSTALL_URL2 = "https://login.salesforce.com/packaging/installPackage.apexp?p0=04tgL000000CTnp";
2191
+ function openBrowser2(url) {
2192
+ const cmd = platform2() === "darwin" ? "open" : "xdg-open";
2193
+ exec2(`${cmd} ${JSON.stringify(url)}`);
2194
+ }
2137
2195
  async function runSfdcDeploy() {
2138
2196
  intro5("Lead Routing \u2014 Deploy Salesforce Package");
2139
2197
  let appUrl;
@@ -2143,23 +2201,46 @@ async function runSfdcDeploy() {
2143
2201
  if (config2?.appUrl && config2?.engineUrl) {
2144
2202
  appUrl = config2.appUrl;
2145
2203
  engineUrl = config2.engineUrl;
2146
- log15.info(`Using config from ${dir}/lead-routing.json`);
2204
+ log14.info(`Using config from ${dir}/lead-routing.json`);
2147
2205
  } else {
2148
- log15.warn("No lead-routing.json found \u2014 enter the URLs from your installation.");
2149
- const rawApp = await text3({
2206
+ log14.warn("No lead-routing.json found \u2014 enter the URLs from your installation.");
2207
+ const rawApp = await text4({
2150
2208
  message: "App URL (e.g. https://leads.acme.com)",
2151
2209
  validate: (v) => !v ? "Required" : void 0
2152
2210
  });
2153
2211
  if (typeof rawApp === "symbol") process.exit(0);
2154
2212
  appUrl = rawApp.trim();
2155
- const rawEngine = await text3({
2213
+ const rawEngine = await text4({
2156
2214
  message: "Engine URL (e.g. https://engine.acme.com or https://acme.com:3001)",
2157
2215
  validate: (v) => !v ? "Required" : void 0
2158
2216
  });
2159
2217
  if (typeof rawEngine === "symbol") process.exit(0);
2160
2218
  engineUrl = rawEngine.trim();
2161
2219
  }
2162
- const alias = await text3({
2220
+ note4(
2221
+ `The Lead Router managed package installs the required Connected App,
2222
+ triggers, and custom objects in your Salesforce org.
2223
+
2224
+ Install URL: ${chalk6.cyan(MANAGED_PACKAGE_INSTALL_URL2)}`,
2225
+ "Salesforce Package"
2226
+ );
2227
+ log14.info("Opening install URL in your browser...");
2228
+ openBrowser2(MANAGED_PACKAGE_INSTALL_URL2);
2229
+ log14.info(chalk6.dim("If the browser didn't open, visit the URL above manually."));
2230
+ const installed = await confirm2({
2231
+ message: 'Have you installed the package? (Click "Install for All Users" in Salesforce)',
2232
+ initialValue: false
2233
+ });
2234
+ if (isCancel4(installed)) {
2235
+ cancel4("Setup cancelled.");
2236
+ process.exit(0);
2237
+ }
2238
+ if (!installed) {
2239
+ log14.warn("You can install the package later \u2014 routing will not work until it is installed.");
2240
+ } else {
2241
+ log14.success("Salesforce package installed");
2242
+ }
2243
+ const alias = await text4({
2163
2244
  message: "Salesforce org alias (used to log in)",
2164
2245
  placeholder: "lead-routing",
2165
2246
  initialValue: "lead-routing",
@@ -2175,49 +2256,48 @@ async function runSfdcDeploy() {
2175
2256
  webhookSecret: config2?.engineWebhookSecret
2176
2257
  });
2177
2258
  } catch (err) {
2178
- log15.error(err instanceof Error ? err.message : String(err));
2259
+ log14.error(err instanceof Error ? err.message : String(err));
2179
2260
  process.exit(1);
2180
2261
  }
2181
- await guideAppLauncherSetup(appUrl);
2182
2262
  outro5(
2183
- chalk7.green("\u2714 Salesforce package deployed!") + `
2263
+ chalk6.green("\u2714 Salesforce package deployed!") + `
2184
2264
 
2185
- Your Lead Router dashboard: ${chalk7.cyan(appUrl)}`
2265
+ Your Lead Router dashboard: ${chalk6.cyan(appUrl)}`
2186
2266
  );
2187
2267
  }
2188
2268
 
2189
2269
  // src/commands/uninstall.ts
2190
2270
  import { rmSync as rmSync2, existsSync as existsSync6 } from "fs";
2191
- import { intro as intro6, outro as outro6, log as log16, confirm as confirm3, password as promptPassword3, isCancel as isCancel5 } from "@clack/prompts";
2192
- import chalk8 from "chalk";
2271
+ import { intro as intro6, outro as outro6, log as log15, confirm as confirm3, password as promptPassword3, isCancel as isCancel5 } from "@clack/prompts";
2272
+ import chalk7 from "chalk";
2193
2273
  async function runUninstall() {
2194
2274
  console.log();
2195
- intro6(chalk8.bold.red("Lead Routing \u2014 Uninstall"));
2275
+ intro6(chalk7.bold.red("Lead Routing \u2014 Uninstall"));
2196
2276
  const dir = findInstallDir();
2197
2277
  if (!dir) {
2198
- log16.error(
2278
+ log15.error(
2199
2279
  "No lead-routing.json found. Run this command from your install directory."
2200
2280
  );
2201
2281
  process.exit(1);
2202
2282
  }
2203
2283
  const cfg = readConfig(dir);
2204
2284
  if (!cfg.ssh) {
2205
- log16.error(
2285
+ log15.error(
2206
2286
  "This lead-routing.json was created with an older CLI version and has no SSH config. Please manually SSH into your server and run:\n\n cd ~/lead-routing && docker compose down -v && cd ~ && rm -rf ~/lead-routing"
2207
2287
  );
2208
2288
  process.exit(1);
2209
2289
  }
2210
- log16.warn(chalk8.red("This will permanently destroy:"));
2211
- log16.warn(` \u2022 Remote: ${cfg.ssh.username}@${cfg.ssh.host}:${cfg.remoteDir}`);
2212
- log16.warn(" \u2514\u2500 All containers, Postgres data, Redis data, config files");
2213
- log16.warn(` \u2022 Local: ${dir}/`);
2214
- log16.warn(" \u2514\u2500 docker-compose.yml, .env.web, .env.engine, Caddyfile, lead-routing.json");
2290
+ log15.warn(chalk7.red("This will permanently destroy:"));
2291
+ log15.warn(` \u2022 Remote: ${cfg.ssh.username}@${cfg.ssh.host}:${cfg.remoteDir}`);
2292
+ log15.warn(" \u2514\u2500 All containers, Postgres data, Redis data, config files");
2293
+ log15.warn(` \u2022 Local: ${dir}/`);
2294
+ log15.warn(" \u2514\u2500 docker-compose.yml, .env.web, .env.engine, Caddyfile, lead-routing.json");
2215
2295
  const confirmed = await confirm3({
2216
- message: chalk8.bold("Are you sure you want to uninstall? This cannot be undone."),
2296
+ message: chalk7.bold("Are you sure you want to uninstall? This cannot be undone."),
2217
2297
  initialValue: false
2218
2298
  });
2219
2299
  if (isCancel5(confirmed) || !confirmed) {
2220
- log16.info("Uninstall cancelled.");
2300
+ log15.info("Uninstall cancelled.");
2221
2301
  process.exit(0);
2222
2302
  }
2223
2303
  const ssh = new SshConnection();
@@ -2238,47 +2318,47 @@ async function runUninstall() {
2238
2318
  password: sshPassword,
2239
2319
  remoteDir: cfg.remoteDir
2240
2320
  });
2241
- log16.success(`Connected to ${cfg.ssh.host}`);
2321
+ log15.success(`Connected to ${cfg.ssh.host}`);
2242
2322
  } catch (err) {
2243
- log16.error(`SSH connection failed: ${String(err)}`);
2323
+ log15.error(`SSH connection failed: ${String(err)}`);
2244
2324
  process.exit(1);
2245
2325
  }
2246
2326
  try {
2247
2327
  const remoteDir = await ssh.resolveHome(cfg.remoteDir);
2248
- log16.step("Stopping containers and removing volumes");
2328
+ log15.step("Stopping containers and removing volumes");
2249
2329
  const { code } = await ssh.execSilent("docker compose down -v", remoteDir);
2250
2330
  if (code !== 0) {
2251
- log16.warn("docker compose down reported an error \u2014 directory may already be partially removed");
2331
+ log15.warn("docker compose down reported an error \u2014 directory may already be partially removed");
2252
2332
  } else {
2253
- log16.success("Containers and volumes removed");
2333
+ log15.success("Containers and volumes removed");
2254
2334
  }
2255
- log16.step(`Removing remote directory ${remoteDir}`);
2335
+ log15.step(`Removing remote directory ${remoteDir}`);
2256
2336
  await ssh.exec(`rm -rf ${remoteDir}`);
2257
- log16.success("Remote directory removed");
2337
+ log15.success("Remote directory removed");
2258
2338
  } catch (err) {
2259
2339
  const message = err instanceof Error ? err.message : String(err);
2260
- log16.error(`Remote cleanup failed: ${message}`);
2340
+ log15.error(`Remote cleanup failed: ${message}`);
2261
2341
  process.exit(1);
2262
2342
  } finally {
2263
2343
  await ssh.disconnect();
2264
2344
  }
2265
- log16.step("Removing local config directory");
2345
+ log15.step("Removing local config directory");
2266
2346
  if (existsSync6(dir)) {
2267
2347
  rmSync2(dir, { recursive: true, force: true });
2268
- log16.success(`Removed ${dir}`);
2348
+ log15.success(`Removed ${dir}`);
2269
2349
  }
2270
2350
  outro6(
2271
- chalk8.green("\u2714 Uninstall complete.") + `
2351
+ chalk7.green("\u2714 Uninstall complete.") + `
2272
2352
 
2273
- Run ${chalk8.cyan("npx @lead-routing/cli init")} to start fresh.`
2353
+ Run ${chalk7.cyan("npx @lead-routing/cli init")} to start fresh.`
2274
2354
  );
2275
2355
  }
2276
2356
 
2277
2357
  // src/commands/signup.ts
2278
2358
  import * as p from "@clack/prompts";
2279
- import chalk9 from "chalk";
2359
+ import chalk8 from "chalk";
2280
2360
  async function runSignup() {
2281
- p.intro(chalk9.bgBlue.white(" lead-routing signup "));
2361
+ p.intro(chalk8.bgBlue.white(" lead-routing signup "));
2282
2362
  const firstName = await p.text({ message: "First name", placeholder: "John", validate: (v) => v.trim() ? void 0 : "Required" });
2283
2363
  if (p.isCancel(firstName)) process.exit(0);
2284
2364
  const lastName = await p.text({ message: "Last name", placeholder: "Smith", validate: (v) => v.trim() ? void 0 : "Required" });
@@ -2287,10 +2367,10 @@ async function runSignup() {
2287
2367
  if (p.isCancel(email)) process.exit(0);
2288
2368
  const password5 = await p.password({ message: "Password (min 8 characters)", validate: (v) => v.length >= 8 ? void 0 : "Must be at least 8 characters" });
2289
2369
  if (p.isCancel(password5)) process.exit(0);
2290
- const confirm5 = await p.password({ message: "Confirm password", validate: (v) => v === password5 ? void 0 : "Passwords do not match" });
2291
- if (p.isCancel(confirm5)) process.exit(0);
2292
- const spinner8 = p.spinner();
2293
- spinner8.start("Creating account...");
2370
+ const confirm6 = await p.password({ message: "Confirm password", validate: (v) => v === password5 ? void 0 : "Passwords do not match" });
2371
+ if (p.isCancel(confirm6)) process.exit(0);
2372
+ const spinner9 = p.spinner();
2373
+ spinner9.start("Creating account...");
2294
2374
  try {
2295
2375
  const message = await apiSignup({
2296
2376
  firstName: firstName.trim(),
@@ -2298,20 +2378,20 @@ async function runSignup() {
2298
2378
  email: email.trim(),
2299
2379
  password: password5
2300
2380
  });
2301
- spinner8.stop("Account created!");
2381
+ spinner9.stop("Account created!");
2302
2382
  p.note(
2303
2383
  `Check your email (${email.trim()}) for a verification link.
2304
2384
 
2305
2385
  After verifying, run:
2306
2386
 
2307
- ${chalk9.cyan("lead-routing login")}`,
2387
+ ${chalk8.cyan("lead-routing login")}`,
2308
2388
  "Next Steps"
2309
2389
  );
2310
2390
  } catch (err) {
2311
2391
  const msg = err instanceof Error ? err.message : "Signup failed";
2312
- spinner8.stop(msg);
2392
+ spinner9.stop(msg);
2313
2393
  if (msg.includes("already")) {
2314
- p.log.info(`Try ${chalk9.cyan("lead-routing login")} instead.`);
2394
+ p.log.info(`Try ${chalk8.cyan("lead-routing login")} instead.`);
2315
2395
  }
2316
2396
  process.exit(1);
2317
2397
  }
@@ -2320,19 +2400,19 @@ After verifying, run:
2320
2400
 
2321
2401
  // src/commands/login.ts
2322
2402
  import * as p2 from "@clack/prompts";
2323
- import chalk10 from "chalk";
2403
+ import chalk9 from "chalk";
2324
2404
  async function runLogin() {
2325
- p2.intro(chalk10.bgBlue.white(" lead-routing login "));
2405
+ p2.intro(chalk9.bgBlue.white(" lead-routing login "));
2326
2406
  const email = await p2.text({ message: "Email", placeholder: "john@acme.com" });
2327
2407
  if (p2.isCancel(email)) process.exit(0);
2328
2408
  const password5 = await p2.password({ message: "Password" });
2329
2409
  if (p2.isCancel(password5)) process.exit(0);
2330
- const spinner8 = p2.spinner();
2331
- spinner8.start("Authenticating...");
2410
+ const spinner9 = p2.spinner();
2411
+ spinner9.start("Authenticating...");
2332
2412
  try {
2333
2413
  const { token, customer } = await apiLogin(email.trim(), password5);
2334
2414
  if (!customer.emailVerified) {
2335
- spinner8.stop("Email not verified");
2415
+ spinner9.stop("Email not verified");
2336
2416
  p2.log.warn(`Your email (${customer.email}) hasn't been verified yet.`);
2337
2417
  const resend = await p2.confirm({ message: "Resend verification email?" });
2338
2418
  if (resend && !p2.isCancel(resend)) {
@@ -2345,19 +2425,421 @@ async function runLogin() {
2345
2425
  }
2346
2426
  p2.note(`Verify your email, then run:
2347
2427
 
2348
- ${chalk10.cyan("lead-routing login")}`, "Next Steps");
2428
+ ${chalk9.cyan("lead-routing login")}`, "Next Steps");
2349
2429
  process.exit(1);
2350
2430
  }
2351
2431
  saveCredentials({ token, customer, storedAt: (/* @__PURE__ */ new Date()).toISOString() });
2352
- spinner8.stop(`Logged in as ${customer.firstName} ${customer.lastName} \u2014 ${formatTierBadge(customer.tier)}`);
2432
+ spinner9.stop(`Logged in as ${customer.firstName} ${customer.lastName} \u2014 ${formatTierBadge(customer.tier)}`);
2353
2433
  p2.note(`Credentials saved to ~/.lead-routing/credentials.json`, "Saved");
2354
2434
  } catch (err) {
2355
- spinner8.stop(err instanceof Error ? err.message : "Login failed");
2435
+ spinner9.stop(err instanceof Error ? err.message : "Login failed");
2356
2436
  process.exit(1);
2357
2437
  }
2358
2438
  p2.outro("Done!");
2359
2439
  }
2360
2440
 
2441
+ // src/commands/dev.ts
2442
+ import {
2443
+ intro as intro9,
2444
+ outro as outro9,
2445
+ spinner as spinner8,
2446
+ text as text7,
2447
+ password as promptPassword4,
2448
+ note as note7,
2449
+ log as log18,
2450
+ isCancel as isCancel8,
2451
+ cancel as cancel5,
2452
+ confirm as confirm5
2453
+ } from "@clack/prompts";
2454
+ import { execa as execa4 } from "execa";
2455
+ import { mkdirSync as mkdirSync3, writeFileSync as writeFileSync8, readFileSync as readFileSync8, existsSync as existsSync7 } from "fs";
2456
+ import { join as join10 } from "path";
2457
+ function getDevDir() {
2458
+ return join10(process.cwd(), "lead-routing-dev");
2459
+ }
2460
+ function renderDevDockerCompose(dbPassword, redisPassword) {
2461
+ return `# Lead Routing \u2014 Local Dev Docker Compose
2462
+ # Generated by lead-routing dev \u2014 do not edit manually.
2463
+
2464
+ services:
2465
+
2466
+ postgres:
2467
+ image: postgres:16-alpine
2468
+ restart: unless-stopped
2469
+ ports:
2470
+ - "127.0.0.1:5432:5432"
2471
+ environment:
2472
+ POSTGRES_DB: leadrouting
2473
+ POSTGRES_USER: leadrouting
2474
+ POSTGRES_PASSWORD: ${dbPassword}
2475
+ volumes:
2476
+ - postgres_data:/var/lib/postgresql/data
2477
+ healthcheck:
2478
+ test: ["CMD-SHELL", "pg_isready -U leadrouting"]
2479
+ interval: 5s
2480
+ timeout: 5s
2481
+ retries: 10
2482
+
2483
+ redis:
2484
+ image: redis:7-alpine
2485
+ restart: unless-stopped
2486
+ command: redis-server --requirepass ${redisPassword}
2487
+ volumes:
2488
+ - redis_data:/data
2489
+ healthcheck:
2490
+ test: ["CMD-SHELL", "redis-cli -a ${redisPassword} ping | grep PONG"]
2491
+ interval: 5s
2492
+ timeout: 3s
2493
+ retries: 10
2494
+
2495
+ web:
2496
+ image: ghcr.io/atgatzby/lead-routing-web:latest
2497
+ restart: unless-stopped
2498
+ ports:
2499
+ - "127.0.0.1:3000:3000"
2500
+ env_file: .env.web
2501
+ healthcheck:
2502
+ test: ["CMD-SHELL", "wget -qO- http://$(hostname -i):3000/api/health || exit 1"]
2503
+ interval: 10s
2504
+ timeout: 5s
2505
+ retries: 6
2506
+ depends_on:
2507
+ postgres:
2508
+ condition: service_healthy
2509
+ redis:
2510
+ condition: service_healthy
2511
+
2512
+ engine:
2513
+ image: ghcr.io/atgatzby/lead-routing-engine:latest
2514
+ restart: unless-stopped
2515
+ ports:
2516
+ - "127.0.0.1:3001:3001"
2517
+ env_file: .env.engine
2518
+ healthcheck:
2519
+ test: ["CMD-SHELL", "wget -qO- http://$(hostname -i):3001/health || exit 1"]
2520
+ interval: 10s
2521
+ timeout: 5s
2522
+ retries: 6
2523
+ depends_on:
2524
+ postgres:
2525
+ condition: service_healthy
2526
+ redis:
2527
+ condition: service_healthy
2528
+
2529
+ volumes:
2530
+ postgres_data:
2531
+ redis_data:
2532
+ `;
2533
+ }
2534
+ function bail3(value) {
2535
+ if (isCancel8(value)) {
2536
+ cancel5("Setup cancelled.");
2537
+ process.exit(0);
2538
+ }
2539
+ throw new Error("Unexpected cancel");
2540
+ }
2541
+ async function pollHealth2(url, attempts = 30, intervalMs = 3e3) {
2542
+ for (let i = 0; i < attempts; i++) {
2543
+ try {
2544
+ const res = await fetch(url);
2545
+ if (res.ok) return true;
2546
+ } catch {
2547
+ }
2548
+ await new Promise((r) => setTimeout(r, intervalMs));
2549
+ }
2550
+ return false;
2551
+ }
2552
+ async function runDev(opts = {}) {
2553
+ if (opts.sfdc) {
2554
+ const devDir2 = getDevDir();
2555
+ const configFile2 = join10(devDir2, "dev.json");
2556
+ if (!existsSync7(configFile2)) {
2557
+ console.error("No dev environment found. Run `lead-routing dev` first.");
2558
+ process.exit(1);
2559
+ }
2560
+ intro9("Lead Routing \u2014 Update Salesforce Credentials");
2561
+ note7(
2562
+ "You need a Salesforce Connected App with callback URL:\n http://localhost:3000/api/auth/sfdc/callback",
2563
+ "Before you begin"
2564
+ );
2565
+ const clientId = await text7({
2566
+ message: "Consumer Key (Client ID)",
2567
+ validate: (v) => !v ? "Required" : void 0
2568
+ });
2569
+ if (isCancel8(clientId)) {
2570
+ cancel5("Cancelled.");
2571
+ process.exit(0);
2572
+ }
2573
+ const clientSecret = await promptPassword4({ message: "Consumer Secret" });
2574
+ if (isCancel8(clientSecret)) {
2575
+ cancel5("Cancelled.");
2576
+ process.exit(0);
2577
+ }
2578
+ const loginUrl = await text7({
2579
+ message: "Login URL",
2580
+ initialValue: "https://login.salesforce.com",
2581
+ validate: (v) => !v ? "Required" : void 0
2582
+ });
2583
+ if (isCancel8(loginUrl)) {
2584
+ cancel5("Cancelled.");
2585
+ process.exit(0);
2586
+ }
2587
+ const envPath = join10(devDir2, ".env.web");
2588
+ const lines = readFileSync8(envPath, "utf8").split("\n");
2589
+ const updates = {
2590
+ SFDC_CLIENT_ID: clientId.trim(),
2591
+ SFDC_CLIENT_SECRET: clientSecret.trim(),
2592
+ SFDC_LOGIN_URL: loginUrl.trim(),
2593
+ SFDC_REDIRECT_URI: "http://localhost:3000/api/auth/sfdc/callback"
2594
+ };
2595
+ const updated = /* @__PURE__ */ new Set();
2596
+ const result = lines.map((line) => {
2597
+ const eq = line.indexOf("=");
2598
+ if (eq === -1) return line;
2599
+ const key = line.slice(0, eq);
2600
+ if (key in updates) {
2601
+ updated.add(key);
2602
+ return `${key}=${updates[key]}`;
2603
+ }
2604
+ return line;
2605
+ });
2606
+ for (const [k, v] of Object.entries(updates)) {
2607
+ if (!updated.has(k)) result.push(`${k}=${v}`);
2608
+ }
2609
+ writeFileSync8(envPath, result.join("\n"), "utf8");
2610
+ const cfg2 = JSON.parse(readFileSync8(configFile2, "utf8"));
2611
+ cfg2.sfdcClientId = clientId.trim();
2612
+ cfg2.sfdcClientSecret = clientSecret.trim();
2613
+ cfg2.sfdcLoginUrl = loginUrl.trim();
2614
+ writeFileSync8(configFile2, JSON.stringify(cfg2, null, 2), "utf8");
2615
+ log18.success("Credentials saved");
2616
+ const s = spinner8();
2617
+ s.start("Restarting web container...");
2618
+ await execa4("docker", ["compose", "restart", "web"], { cwd: devDir2, stdio: "ignore" });
2619
+ s.stop("Web container restarted");
2620
+ outro9("Done! Connect Salesforce at http://localhost:3000/integrations/salesforce");
2621
+ return;
2622
+ }
2623
+ if (opts.logs !== void 0) {
2624
+ const service = opts.logs || "engine";
2625
+ const devDir2 = getDevDir();
2626
+ if (!existsSync7(join10(devDir2, "docker-compose.yml"))) {
2627
+ console.error("No dev environment found. Run `lead-routing dev` first.");
2628
+ process.exit(1);
2629
+ }
2630
+ const valid = ["web", "engine", "postgres", "redis"];
2631
+ if (!valid.includes(service)) {
2632
+ console.error(`Unknown service "${service}". Valid: ${valid.join(", ")}`);
2633
+ process.exit(1);
2634
+ }
2635
+ await execa4("docker", ["compose", "logs", "-f", "--tail=100", service], {
2636
+ cwd: devDir2,
2637
+ stdio: "inherit"
2638
+ });
2639
+ return;
2640
+ }
2641
+ intro9("Lead Routing \u2014 Local Dev");
2642
+ try {
2643
+ await execa4("docker", ["info"], { stdio: "ignore" });
2644
+ } catch {
2645
+ log18.error("Docker is not running. Please start Docker Desktop and try again.");
2646
+ process.exit(1);
2647
+ }
2648
+ const devDir = getDevDir();
2649
+ const configFile = join10(devDir, "dev.json");
2650
+ const isFirstRun = !existsSync7(configFile);
2651
+ let cfg;
2652
+ let adminPassword = "";
2653
+ if (!isFirstRun && !opts.reset) {
2654
+ cfg = JSON.parse(readFileSync8(configFile, "utf8"));
2655
+ log18.info("Existing dev environment found \u2014 starting services...");
2656
+ } else {
2657
+ if (opts.reset && !isFirstRun) {
2658
+ const ok = await confirm5({
2659
+ message: "This will wipe all local dev data (database, redis). Continue?"
2660
+ });
2661
+ if (isCancel8(ok) || !ok) {
2662
+ cancel5("Reset cancelled.");
2663
+ process.exit(0);
2664
+ }
2665
+ const s = spinner8();
2666
+ s.start("Stopping and removing volumes...");
2667
+ try {
2668
+ await execa4("docker", ["compose", "down", "-v"], { cwd: devDir, stdio: "ignore" });
2669
+ } catch {
2670
+ }
2671
+ s.stop("Volumes removed");
2672
+ }
2673
+ note7(
2674
+ "Sets up a full Lead Routing stack locally using Docker.\nNo SSH, no VPS, no manual steps.",
2675
+ "First-time setup"
2676
+ );
2677
+ const adminEmail = await text7({
2678
+ message: "Admin email",
2679
+ placeholder: "you@company.com",
2680
+ validate: (v) => {
2681
+ if (!v) return "Required";
2682
+ if (!v.includes("@")) return "Must be a valid email";
2683
+ }
2684
+ });
2685
+ if (isCancel8(adminEmail)) bail3(adminEmail);
2686
+ const pw = await promptPassword4({
2687
+ message: "Admin password (min 8 characters)",
2688
+ validate: (v) => {
2689
+ if (!v) return "Required";
2690
+ if (v.length < 8) return "Must be at least 8 characters";
2691
+ }
2692
+ });
2693
+ if (isCancel8(pw)) bail3(pw);
2694
+ adminPassword = pw;
2695
+ note7(
2696
+ "Optional \u2014 needed to connect Salesforce.\nPress Enter to skip; add credentials later via `lead-routing config sfdc`.",
2697
+ "Salesforce Connected App"
2698
+ );
2699
+ const sfdcClientId = await text7({
2700
+ message: "Consumer Key (Client ID)",
2701
+ placeholder: "Leave blank to skip"
2702
+ });
2703
+ if (isCancel8(sfdcClientId)) bail3(sfdcClientId);
2704
+ let sfdcClientSecret = "";
2705
+ if (sfdcClientId?.trim()) {
2706
+ const s = await promptPassword4({ message: "Consumer Secret" });
2707
+ if (isCancel8(s)) bail3(s);
2708
+ sfdcClientSecret = s.trim();
2709
+ }
2710
+ const tunnelUrl = await text7({
2711
+ message: "Engine tunnel URL (for Salesforce webhooks)",
2712
+ placeholder: "Leave blank \u2014 use `ssh -R 80:localhost:3001 localhost.run` to get one"
2713
+ });
2714
+ if (isCancel8(tunnelUrl)) bail3(tunnelUrl);
2715
+ cfg = {
2716
+ adminEmail: adminEmail.trim(),
2717
+ dbPassword: generateSecret(16),
2718
+ redisPassword: generateSecret(16),
2719
+ sessionSecret: generateSecret(32),
2720
+ engineWebhookSecret: generateSecret(32),
2721
+ internalApiKey: generateSecret(32),
2722
+ sfdcClientId: sfdcClientId?.trim() ?? "",
2723
+ sfdcClientSecret,
2724
+ sfdcLoginUrl: "https://login.salesforce.com",
2725
+ engineTunnelUrl: tunnelUrl?.trim() ?? ""
2726
+ };
2727
+ mkdirSync3(devDir, { recursive: true });
2728
+ const dbUrl = `postgresql://leadrouting:${cfg.dbPassword}@postgres:5432/leadrouting`;
2729
+ const redisUrl = `redis://:${cfg.redisPassword}@redis:6379`;
2730
+ const publicEngineUrl = cfg.engineTunnelUrl || "http://localhost:3001";
2731
+ writeFileSync8(
2732
+ join10(devDir, "docker-compose.yml"),
2733
+ renderDevDockerCompose(cfg.dbPassword, cfg.redisPassword),
2734
+ "utf8"
2735
+ );
2736
+ const envWebBase = renderEnvWeb({
2737
+ appUrl: "http://localhost:3000",
2738
+ engineUrl: "http://engine:3001",
2739
+ publicEngineUrl,
2740
+ databaseUrl: dbUrl,
2741
+ redisUrl,
2742
+ sessionSecret: cfg.sessionSecret,
2743
+ engineWebhookSecret: cfg.engineWebhookSecret,
2744
+ adminEmail: cfg.adminEmail,
2745
+ adminPassword,
2746
+ internalApiKey: cfg.internalApiKey,
2747
+ licenseKey: void 0,
2748
+ licenseTier: "pro"
2749
+ });
2750
+ writeFileSync8(
2751
+ join10(devDir, ".env.web"),
2752
+ envWebBase + `
2753
+ # Salesforce Connected App
2754
+ SFDC_CLIENT_ID=${cfg.sfdcClientId}
2755
+ SFDC_CLIENT_SECRET=${cfg.sfdcClientSecret}
2756
+ SFDC_REDIRECT_URI=http://localhost:3000/api/auth/sfdc/callback
2757
+ SFDC_LOGIN_URL=${cfg.sfdcLoginUrl}
2758
+ `,
2759
+ "utf8"
2760
+ );
2761
+ writeFileSync8(
2762
+ join10(devDir, ".env.engine"),
2763
+ renderEnvEngine({
2764
+ databaseUrl: dbUrl,
2765
+ redisUrl,
2766
+ engineWebhookSecret: cfg.engineWebhookSecret,
2767
+ internalApiKey: cfg.internalApiKey,
2768
+ licenseKey: void 0,
2769
+ licenseTier: "pro"
2770
+ }),
2771
+ "utf8"
2772
+ );
2773
+ writeFileSync8(configFile, JSON.stringify(cfg, null, 2), "utf8");
2774
+ writeConfig(devDir, {
2775
+ appUrl: "http://localhost:3000",
2776
+ engineUrl: publicEngineUrl,
2777
+ installDir: devDir,
2778
+ remoteDir: devDir,
2779
+ ssh: { host: "localhost", port: 22, username: "local" },
2780
+ dockerManaged: { db: true, redis: true },
2781
+ engineWebhookSecret: cfg.engineWebhookSecret,
2782
+ licenseTier: "pro",
2783
+ installedAt: (/* @__PURE__ */ new Date()).toISOString(),
2784
+ version: "0.0.0"
2785
+ });
2786
+ log18.success("Generated config files in ./lead-routing-dev/");
2787
+ }
2788
+ const pullS = spinner8();
2789
+ pullS.start("Pulling latest images...");
2790
+ try {
2791
+ await execa4("docker", ["compose", "pull"], { cwd: devDir, stdio: "ignore" });
2792
+ pullS.stop("Images up to date");
2793
+ } catch {
2794
+ pullS.stop("Using cached images");
2795
+ }
2796
+ const startS = spinner8();
2797
+ startS.start("Starting services...");
2798
+ try {
2799
+ await execa4("docker", ["compose", "up", "-d", "--remove-orphans"], {
2800
+ cwd: devDir,
2801
+ stdio: "ignore"
2802
+ });
2803
+ startS.stop("Services started");
2804
+ } catch (err) {
2805
+ startS.stop("Failed to start services");
2806
+ log18.error(err instanceof Error ? err.message : String(err));
2807
+ process.exit(1);
2808
+ }
2809
+ const healthS = spinner8();
2810
+ healthS.start("Waiting for web app...");
2811
+ const webOk = await pollHealth2("http://localhost:3000/api/health");
2812
+ if (!webOk) {
2813
+ healthS.stop("Web app did not start in time");
2814
+ log18.warn("Run `lead-routing dev logs web` to diagnose.");
2815
+ process.exit(1);
2816
+ }
2817
+ healthS.stop("Web app ready");
2818
+ const engineS = spinner8();
2819
+ engineS.start("Waiting for engine...");
2820
+ const engineOk = await pollHealth2("http://localhost:3001/health");
2821
+ engineS.stop(engineOk ? "Engine ready" : "Engine slow to start (check: lead-routing dev logs engine)");
2822
+ note7(
2823
+ ` Web: http://localhost:3000
2824
+ Engine: http://localhost:3001
2825
+
2826
+ Login: ${cfg.adminEmail}`,
2827
+ "\u2713 Lead Routing running locally"
2828
+ );
2829
+ if (cfg.sfdcClientId) {
2830
+ if (cfg.engineTunnelUrl) {
2831
+ log18.info("Next: run `lead-routing sfdc deploy` to deploy the Salesforce package (same as prod)");
2832
+ } else {
2833
+ log18.warn(
2834
+ "No engine tunnel URL set \u2014 Salesforce cannot reach localhost:3001.\nTo get a tunnel: ssh -R 80:localhost:3001 localhost.run\nThen re-run: lead-routing dev --reset (to update the tunnel URL)\nThen deploy: lead-routing sfdc deploy"
2835
+ );
2836
+ }
2837
+ } else {
2838
+ log18.info("No Salesforce creds set. Add via: lead-routing dev --sfdc");
2839
+ }
2840
+ outro9("Run `lead-routing dev logs` to stream logs \u2022 `lead-routing dev --reset` to wipe and restart");
2841
+ }
2842
+
2361
2843
  // src/index.ts
2362
2844
  var program = new Command();
2363
2845
  program.name("lead-routing").description("Self-hosted Lead Routing \u2014 scaffold, deploy, and manage your installation").version("0.1.13");
@@ -2381,6 +2863,7 @@ config.command("sfdc").description("Update Salesforce Connected App credentials
2381
2863
  var sfdc = program.command("sfdc").description("Manage the Salesforce package for this installation");
2382
2864
  sfdc.command("deploy").description("Deploy (or redeploy) the Lead Router Salesforce package to your Salesforce org").action(runSfdcDeploy);
2383
2865
  program.command("uninstall").description("Stop all containers, remove all data, and delete the remote installation").action(runUninstall);
2866
+ program.command("dev").description("Start a local dev environment (Docker-based, no SSH required)").option("--reset", "Wipe all local data and start fresh").option("--logs [service]", "Stream logs from a service (web, engine, postgres, redis)").option("--sfdc", "Update Salesforce Connected App credentials").action((opts) => runDev(opts));
2384
2867
  program.command("signup").description("Create a new Lead Routing account").action(runSignup);
2385
2868
  program.command("login").description("Log in to your Lead Routing account").action(runLogin);
2386
2869
  program.parseAsync(process.argv).catch((err) => {
@@ -0,0 +1,2 @@
1
+ -- AlterEnum
2
+ ALTER TYPE "SfdcObjectType" ADD VALUE 'USER';
@@ -14,6 +14,7 @@ enum SfdcObjectType {
14
14
  LEAD
15
15
  CONTACT
16
16
  ACCOUNT
17
+ USER
17
18
  }
18
19
 
19
20
  enum TriggerEvent {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lead-routing/cli",
3
- "version": "0.6.1",
3
+ "version": "0.6.3",
4
4
  "description": "Self-hosted deployment CLI for Lead Routing",
5
5
  "homepage": "https://github.com/lead-routing/lead-routing",
6
6
  "keywords": [