salesprompter-cli 0.1.4 → 0.1.6

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/README.md CHANGED
@@ -61,6 +61,7 @@ salesprompter leads:score --icp ./data/deel-icp.json --in ./data/deel-enriched.j
61
61
  Use the same commands, but prefer machine-readable output:
62
62
 
63
63
  ```bash
64
+ salesprompter --json icp:vendor --vendor deel --market dach
64
65
  salesprompter --json icp:vendor --vendor deel --market dach --out ./data/deel-icp.json
65
66
  salesprompter --json leads:lookup:bq --icp ./data/deel-icp.json --limit 100 --lead-out ./data/deel-leads.json
66
67
  ```
@@ -113,12 +114,12 @@ Global output flags:
113
114
  The CLI stores a local session file at `~/.config/salesprompter/auth-session.json` (or `SALESPROMPTER_CONFIG_DIR`).
114
115
 
115
116
  ```bash
116
- # Production path: generate a short-lived CLI token in the Salesprompter app
117
- salesprompter auth:login --token "$SALESPROMPTER_TOKEN" --api-url "https://salesprompter.ai"
118
-
119
- # Optional: use device flow only if your Salesprompter app exposes it
117
+ # Preferred path: browser/device login
120
118
  salesprompter auth:login
121
119
 
120
+ # Fallback path: generate a short-lived CLI token in the Salesprompter app
121
+ salesprompter auth:login --token "$SALESPROMPTER_TOKEN" --api-url "https://salesprompter.ai"
122
+
122
123
  # Verify active identity with backend
123
124
  salesprompter auth:whoami --verify
124
125
 
@@ -137,9 +138,11 @@ Environment variables:
137
138
 
138
139
  App compatibility:
139
140
 
140
- - Current Salesprompter production uses app-issued CLI tokens.
141
- - If your app exposes `/api/cli/auth/device/start` and `/api/cli/auth/device/poll`, `salesprompter auth:login` will use device flow.
142
- - If device auth is not enabled, create a CLI token from the app endpoint `POST /api/cli/auth/token` and use:
141
+ - Salesprompter app should expose `/api/cli/auth/device/start`, `/api/cli/auth/device/poll`, and `/api/cli/auth/me`.
142
+ - `salesprompter auth:login` uses browser/device login and prints the verification URL plus code before polling.
143
+ - `POST /api/cli/auth/token` remains the fallback path when browser/device login is disabled or unavailable.
144
+
145
+ Fallback command:
143
146
 
144
147
  ```bash
145
148
  salesprompter auth:login --token "<token-from-app>" --api-url "https://salesprompter.ai"
package/dist/auth.js CHANGED
@@ -1,3 +1,5 @@
1
+ import { randomBytes } from "node:crypto";
2
+ import { createServer } from "node:http";
1
3
  import os from "node:os";
2
4
  import path from "node:path";
3
5
  import { access, mkdir, readFile, rm, writeFile } from "node:fs/promises";
@@ -106,6 +108,12 @@ function buildDeviceFlowUnavailableMessage(apiBaseUrl) {
106
108
  `Generate a CLI token in the app and run \`salesprompter auth:login --token <token> --api-url "${apiBaseUrl}"\`.`
107
109
  ].join(" ");
108
110
  }
111
+ function isBrowserConnectUnavailableError(message) {
112
+ if (/request failed \((401|403|404|405|500|501|502|503|504)\)/.test(message)) {
113
+ return true;
114
+ }
115
+ return message.includes("invalid localhost callback response");
116
+ }
109
117
  async function hasSessionFile() {
110
118
  try {
111
119
  await access(getSessionPath());
@@ -202,6 +210,99 @@ export async function loginWithToken(token, apiBaseUrl) {
202
210
  await writeAuthSession(session);
203
211
  return session;
204
212
  }
213
+ export async function loginWithBrowserConnect(options) {
214
+ const apiBaseUrl = normalizeApiBaseUrl(options?.apiBaseUrl);
215
+ const timeoutSeconds = options?.timeoutSeconds ?? DEFAULT_DEVICE_TIMEOUT_SECONDS;
216
+ const state = randomBytes(16).toString("hex");
217
+ let resolveToken;
218
+ let rejectToken;
219
+ const tokenPromise = new Promise((resolve, reject) => {
220
+ resolveToken = resolve;
221
+ rejectToken = reject;
222
+ });
223
+ const server = createServer((request, response) => {
224
+ try {
225
+ const requestUrl = new URL(request.url ?? "/", "http://127.0.0.1");
226
+ if (requestUrl.pathname !== "/callback") {
227
+ response.statusCode = 404;
228
+ response.end("Not found");
229
+ return;
230
+ }
231
+ const accessToken = requestUrl.searchParams.get("access_token") ?? "";
232
+ const responseState = requestUrl.searchParams.get("state") ?? "";
233
+ if (accessToken.trim().length === 0 || responseState.trim().length === 0) {
234
+ response.statusCode = 400;
235
+ response.end("Invalid login response");
236
+ return;
237
+ }
238
+ response.statusCode = 200;
239
+ response.setHeader("Content-Type", "text/html; charset=utf-8");
240
+ response.end("<!doctype html><html><body><p>Salesprompter CLI login complete. You can return to the terminal.</p></body></html>");
241
+ resolveToken?.({ accessToken, state: responseState });
242
+ }
243
+ catch (error) {
244
+ response.statusCode = 500;
245
+ response.end("Login failed");
246
+ rejectToken?.(error);
247
+ }
248
+ });
249
+ server.once("error", (error) => {
250
+ rejectToken?.(error);
251
+ });
252
+ await new Promise((resolve, reject) => {
253
+ server.listen(0, "127.0.0.1", () => resolve());
254
+ server.once("error", reject);
255
+ });
256
+ const address = server.address();
257
+ if (!address || typeof address === "string") {
258
+ server.close();
259
+ throw new Error("failed to bind localhost callback server");
260
+ }
261
+ const redirectUri = `http://127.0.0.1:${address.port}/callback`;
262
+ const closeServer = async () => await new Promise((resolve) => {
263
+ server.close(() => resolve());
264
+ });
265
+ const browserUrl = new URL(`${apiBaseUrl}/api/cli/auth/connect`);
266
+ browserUrl.searchParams.set("redirect_uri", redirectUri);
267
+ browserUrl.searchParams.set("state", state);
268
+ let preflightResponse;
269
+ try {
270
+ preflightResponse = await fetch(browserUrl, {
271
+ method: "GET",
272
+ redirect: "manual",
273
+ headers: {
274
+ "X-Salesprompter-Client": CLIENT_HEADER
275
+ }
276
+ });
277
+ }
278
+ catch (error) {
279
+ await closeServer();
280
+ throw error;
281
+ }
282
+ if (preflightResponse.status < 300 || preflightResponse.status >= 400) {
283
+ await closeServer();
284
+ throw new Error(`request failed (${preflightResponse.status}) for ${browserUrl}`);
285
+ }
286
+ await options?.onConnectStart?.({
287
+ browserUrl: browserUrl.toString(),
288
+ redirectUri
289
+ });
290
+ const timeoutHandle = setTimeout(() => {
291
+ rejectToken?.(new Error("timed out waiting for browser login"));
292
+ }, Math.max(timeoutSeconds, 30) * 1000);
293
+ let result;
294
+ try {
295
+ result = await tokenPromise;
296
+ }
297
+ finally {
298
+ clearTimeout(timeoutHandle);
299
+ await closeServer();
300
+ }
301
+ if (result.state !== state) {
302
+ throw new Error("invalid localhost callback response");
303
+ }
304
+ return await loginWithToken(result.accessToken, apiBaseUrl);
305
+ }
205
306
  export async function loginWithDeviceFlow(options) {
206
307
  const apiBaseUrl = normalizeApiBaseUrl(options?.apiBaseUrl);
207
308
  const timeoutSeconds = options?.timeoutSeconds ?? DEFAULT_DEVICE_TIMEOUT_SECONDS;
@@ -227,6 +328,12 @@ export async function loginWithDeviceFlow(options) {
227
328
  if (verificationUrl === undefined) {
228
329
  throw new Error("device start response missing verification url");
229
330
  }
331
+ await options?.onDeviceStart?.({
332
+ verificationUrl,
333
+ userCode: start.userCode,
334
+ intervalSeconds: start.intervalSeconds,
335
+ expiresInSeconds: start.expiresInSeconds
336
+ });
230
337
  const pollIntervalMs = (start.intervalSeconds ?? DEFAULT_DEVICE_POLL_INTERVAL_SECONDS) * 1000;
231
338
  const deadline = Date.now() + Math.max(timeoutSeconds, 30) * 1000;
232
339
  while (Date.now() < deadline) {
package/dist/cli.js CHANGED
@@ -1,8 +1,9 @@
1
1
  #!/usr/bin/env node
2
+ import { spawn } from "node:child_process";
2
3
  import { createRequire } from "node:module";
3
4
  import { Command } from "commander";
4
5
  import { z } from "zod";
5
- import { clearAuthSession, loginWithDeviceFlow, loginWithToken, requireAuthSession, shouldBypassAuth, verifySession } from "./auth.js";
6
+ import { clearAuthSession, loginWithBrowserConnect, loginWithDeviceFlow, loginWithToken, requireAuthSession, shouldBypassAuth, verifySession } from "./auth.js";
6
7
  import { buildBigQueryLeadLookupSql, executeBigQuerySql, normalizeBigQueryLeadRows, runBigQueryQuery, runBigQueryRows } from "./bigquery.js";
7
8
  import { AccountProfileSchema, EnrichedLeadSchema, IcpSchema, LeadSchema, ScoredLeadSchema, SyncTargetSchema } from "./domain.js";
8
9
  import { auditDomainDecisions, buildDomainfinderBacklogQueries, buildDomainfinderCandidatesSql, buildDomainfinderInputSql, buildDomainfinderWritebackSql, buildExistingDomainRepairSql, buildExistingDomainAuditQueries, compareDomainSelectionStrategies, selectBestDomains } from "./domainfinder.js";
@@ -35,6 +36,59 @@ function applyGlobalOutputOptions(actionCommand) {
35
36
  runtimeOutputOptions.json = Boolean(globalOptions.json);
36
37
  runtimeOutputOptions.quiet = Boolean(globalOptions.quiet);
37
38
  }
39
+ function openUrlInBrowser(url) {
40
+ if (!process.stderr.isTTY) {
41
+ return false;
42
+ }
43
+ const launch = (command, args) => {
44
+ try {
45
+ const child = spawn(command, args, { detached: true, stdio: "ignore" });
46
+ child.on("error", () => undefined);
47
+ child.unref();
48
+ return true;
49
+ }
50
+ catch {
51
+ return false;
52
+ }
53
+ };
54
+ try {
55
+ if (process.platform === "darwin") {
56
+ return launch("open", [url]);
57
+ }
58
+ if (process.platform === "win32") {
59
+ return launch("cmd", ["/c", "start", "", url]);
60
+ }
61
+ return launch("xdg-open", [url]);
62
+ }
63
+ catch {
64
+ return false;
65
+ }
66
+ }
67
+ function writeDeviceLoginInstructions(info) {
68
+ if (runtimeOutputOptions.json || runtimeOutputOptions.quiet) {
69
+ return;
70
+ }
71
+ process.stderr.write("Starting device login flow. Complete login in the browser.\n");
72
+ process.stderr.write(`Open this URL: ${info.verificationUrl}\n`);
73
+ process.stderr.write(`Enter this code if prompted: ${info.userCode}\n`);
74
+ if (info.expiresInSeconds !== undefined) {
75
+ const expiresInMinutes = Math.max(1, Math.ceil(info.expiresInSeconds / 60));
76
+ process.stderr.write(`Code expires in about ${expiresInMinutes} minute${expiresInMinutes === 1 ? "" : "s"}.\n`);
77
+ }
78
+ if (openUrlInBrowser(info.verificationUrl)) {
79
+ process.stderr.write("Opened the browser for you.\n");
80
+ }
81
+ }
82
+ function writeBrowserLoginInstructions(info) {
83
+ if (runtimeOutputOptions.json || runtimeOutputOptions.quiet) {
84
+ return;
85
+ }
86
+ process.stderr.write("Starting browser login flow. Complete login in the browser.\n");
87
+ process.stderr.write(`Open this URL: ${info.browserUrl}\n`);
88
+ if (openUrlInBrowser(info.browserUrl)) {
89
+ process.stderr.write("Opened the browser for you.\n");
90
+ }
91
+ }
38
92
  function buildCliError(error) {
39
93
  if (error instanceof z.ZodError) {
40
94
  return {
@@ -193,12 +247,35 @@ program
193
247
  return;
194
248
  }
195
249
  const startedAt = new Date().toISOString();
196
- if (!runtimeOutputOptions.json && !runtimeOutputOptions.quiet) {
197
- process.stderr.write("Starting device login flow. Complete login in the browser.\n");
250
+ try {
251
+ const session = await loginWithBrowserConnect({
252
+ apiBaseUrl: options.apiUrl,
253
+ timeoutSeconds,
254
+ onConnectStart: writeBrowserLoginInstructions
255
+ });
256
+ printOutput({
257
+ status: "ok",
258
+ method: "browser",
259
+ startedAt,
260
+ apiBaseUrl: session.apiBaseUrl,
261
+ user: session.user,
262
+ expiresAt: session.expiresAt ?? null
263
+ });
264
+ return;
265
+ }
266
+ catch (error) {
267
+ const message = error instanceof Error ? error.message : String(error);
268
+ if (!message.includes("timed out waiting for browser login") && !message.includes("invalid localhost callback response") && !message.includes("request failed")) {
269
+ throw error;
270
+ }
271
+ if (!/request failed \((401|403|404|405|500|501|502|503|504)\)/.test(message)) {
272
+ throw error;
273
+ }
198
274
  }
199
275
  const result = await loginWithDeviceFlow({
200
276
  apiBaseUrl: options.apiUrl,
201
- timeoutSeconds
277
+ timeoutSeconds,
278
+ onDeviceStart: writeDeviceLoginInstructions
202
279
  });
203
280
  printOutput({
204
281
  status: "ok",
@@ -311,12 +388,14 @@ program
311
388
  .description("Create a vendor-specific ICP template.")
312
389
  .requiredOption("--vendor <vendor>", "Vendor template name, currently: deel")
313
390
  .option("--market <market>", "global|europe|dach", "dach")
314
- .requiredOption("--out <path>", "Output file path")
391
+ .option("--out <path>", "Optional output file path")
315
392
  .action(async (options) => {
316
393
  const vendor = z.enum(["deel"]).parse(options.vendor);
317
394
  const market = z.enum(["global", "europe", "dach"]).parse(options.market);
318
395
  const icp = buildVendorIcp(vendor, market);
319
- await writeJsonFile(options.out, icp);
396
+ if (options.out) {
397
+ await writeJsonFile(options.out, icp);
398
+ }
320
399
  printOutput({ status: "ok", icp });
321
400
  });
322
401
  program
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "salesprompter-cli",
3
- "version": "0.1.4",
3
+ "version": "0.1.6",
4
4
  "description": "JSON-first sales prospecting CLI for ICP definition, lead generation, enrichment, scoring, and CRM/outreach sync.",
5
5
  "type": "module",
6
6
  "bin": {