@dench.com/cli 2.1.1 → 2.1.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.
@@ -1055,12 +1055,13 @@ export async function aviatoEnrichPerson(
1055
1055
  body: AviatoEnrichPersonBody,
1056
1056
  options: EnrichmentGatewayOptions = {},
1057
1057
  ): Promise<Record<string, unknown>> {
1058
+ const linkedinUrl = body.linkedinUrl?.trim();
1059
+ const linkedinID = body.linkedinID?.trim();
1060
+ const linkedinNumID = body.linkedinNumID?.trim();
1061
+ const linkedinEntityId = body.linkedinEntityId?.trim();
1062
+ const email = body.email?.trim();
1058
1063
  const hasIdentifier =
1059
- body.linkedinUrl ||
1060
- body.linkedinID ||
1061
- body.linkedinNumID ||
1062
- body.linkedinEntityId ||
1063
- body.email;
1064
+ linkedinUrl || linkedinID || linkedinNumID || linkedinEntityId || email;
1064
1065
  if (!hasIdentifier) {
1065
1066
  throw new EnrichmentGatewayError(
1066
1067
  "Aviato person enrichment requires at least one identifier: linkedinUrl, linkedinID, linkedinNumID, linkedinEntityId, or email.",
@@ -1068,14 +1069,24 @@ export async function aviatoEnrichPerson(
1068
1069
  );
1069
1070
  }
1070
1071
 
1072
+ // Aviato rejects email-only lookups in preview mode ("Email lookups are
1073
+ // not allowed in preview mode"), so when email is the sole identifier we
1074
+ // upgrade to a full (charged) lookup rather than surfacing a hard 400.
1075
+ const emailOnly =
1076
+ Boolean(email) &&
1077
+ !linkedinUrl &&
1078
+ !linkedinID &&
1079
+ !linkedinNumID &&
1080
+ !linkedinEntityId;
1081
+
1071
1082
  const requestBody: Record<string, unknown> = {};
1072
- if (body.linkedinUrl) requestBody.linkedinURL = body.linkedinUrl.trim();
1073
- if (body.linkedinID) requestBody.linkedinID = body.linkedinID.trim();
1074
- if (body.linkedinNumID) requestBody.linkedinNumID = body.linkedinNumID.trim();
1075
- if (body.linkedinEntityId)
1076
- requestBody.linkedinEntityId = body.linkedinEntityId.trim();
1077
- if (body.email) requestBody.email = body.email.trim();
1078
- if (body.preview !== undefined) requestBody.preview = body.preview;
1083
+ if (linkedinUrl) requestBody.linkedinURL = linkedinUrl;
1084
+ if (linkedinID) requestBody.linkedinID = linkedinID;
1085
+ if (linkedinNumID) requestBody.linkedinNumID = linkedinNumID;
1086
+ if (linkedinEntityId) requestBody.linkedinEntityId = linkedinEntityId;
1087
+ if (email) requestBody.email = email;
1088
+ if (body.preview !== undefined && !(body.preview && emailOnly))
1089
+ requestBody.preview = body.preview;
1079
1090
 
1080
1091
  const raw = await callGatewayJson("/v1/enrichment/aviato/person", {
1081
1092
  method: "POST",
@@ -0,0 +1,58 @@
1
+ type OrganizationSelectorFields = {
2
+ _id?: string;
3
+ id?: string;
4
+ name: string;
5
+ slug: string;
6
+ };
7
+
8
+ export function normalizeOrganizationSelector(input: string | undefined | null) {
9
+ const trimmed = input?.trim();
10
+ if (!trimmed) return undefined;
11
+ return trimmed.slice(0, 160);
12
+ }
13
+
14
+ function organizationId(organization: OrganizationSelectorFields) {
15
+ return String(organization._id ?? organization.id ?? "");
16
+ }
17
+
18
+ export function organizationMatchesSelector(
19
+ organization: OrganizationSelectorFields | null | undefined,
20
+ selector: string | null | undefined,
21
+ ) {
22
+ const normalizedSelector =
23
+ normalizeOrganizationSelector(selector)?.toLowerCase();
24
+ if (!organization || !normalizedSelector) return false;
25
+ return (
26
+ organizationId(organization).toLowerCase() === normalizedSelector ||
27
+ organization.slug.toLowerCase() === normalizedSelector ||
28
+ organization.name.trim().toLowerCase() === normalizedSelector
29
+ );
30
+ }
31
+
32
+ export function resolveOrganizationBySelector<
33
+ T extends OrganizationSelectorFields,
34
+ >(organizations: T[], selector: string | null | undefined): T | null {
35
+ const normalizedSelector =
36
+ normalizeOrganizationSelector(selector)?.toLowerCase();
37
+ if (!normalizedSelector) return null;
38
+
39
+ const slugMatch = organizations.find(
40
+ (organization) =>
41
+ organization.slug.toLowerCase() === normalizedSelector,
42
+ );
43
+ if (slugMatch) return slugMatch;
44
+
45
+ const idMatch = organizations.find(
46
+ (organization) =>
47
+ organizationId(organization).toLowerCase() === normalizedSelector,
48
+ );
49
+ if (idMatch) return idMatch;
50
+
51
+ const nameMatches = organizations.filter(
52
+ (organization) =>
53
+ organization.name.trim().toLowerCase() === normalizedSelector,
54
+ );
55
+ if (nameMatches.length === 1) return nameMatches[0];
56
+
57
+ return null;
58
+ }
package/linkedin.ts ADDED
@@ -0,0 +1,210 @@
1
+ /**
2
+ * `dench linkedin` — first-class LinkedIn outreach workflows.
3
+ *
4
+ * This is intentionally not a generic browser surface. The CLI sends
5
+ * one high-level command to Dench Cloud, where Browserbase + the
6
+ * LinkedIn-specific verifier do the whole transaction server-side.
7
+ */
8
+
9
+ import { hasFlag } from "./lib/cli-args";
10
+
11
+ type RuntimeBundle = {
12
+ host: string;
13
+ bearerToken: string;
14
+ };
15
+
16
+ type LinkedInCliContext = {
17
+ args: string[];
18
+ jsonOutput: boolean;
19
+ runtime: RuntimeBundle;
20
+ };
21
+
22
+ type LinkedInResponse = Record<string, unknown> & {
23
+ crmUpdate?: unknown;
24
+ currentUrl?: string | null;
25
+ error?: string;
26
+ message?: string;
27
+ ok?: boolean;
28
+ pageTitle?: string | null;
29
+ profileUrl?: string;
30
+ status?: string;
31
+ threadId?: string;
32
+ threadUrl?: string;
33
+ verified?: boolean;
34
+ };
35
+
36
+ class LinkedInCliError extends Error {}
37
+
38
+ function linkedinHelp(): void {
39
+ console.log(`Usage: dench linkedin <subcommand>
40
+
41
+ Connect with a LinkedIn profile using the cloud Browserbase browser:
42
+ dench linkedin connect <profileUrl> [--entry people:<entryId>] [--thread <threadId>] [--json]
43
+
44
+ CRM:
45
+ --entry people:<entryId> Update that CRM row after verification.
46
+ --object <object> --entry-id <entryId>
47
+ Same as --entry, split into two flags.
48
+
49
+ Behavior:
50
+ CRM is marked reached out only when LinkedIn verifies Pending.
51
+ If LinkedIn asks for login, rate-limits, or shows an unclear modal,
52
+ the result is manual_review and the browser thread stays live.
53
+ `);
54
+ }
55
+
56
+ function consumeFlagValue(args: string[], name: string): string | undefined {
57
+ const idx = args.indexOf(name);
58
+ if (idx === -1) return undefined;
59
+ const value = args[idx + 1];
60
+ args.splice(idx, 2);
61
+ return value;
62
+ }
63
+
64
+ function normalizeProfileUrl(input: string | undefined): string {
65
+ const value = input?.trim();
66
+ if (!value) {
67
+ throw new LinkedInCliError(
68
+ "Usage: dench linkedin connect <profileUrl> [--entry people:<entryId>]",
69
+ );
70
+ }
71
+ if (/^https?:\/\//i.test(value)) return value;
72
+ return `https://${value}`;
73
+ }
74
+
75
+ function parseEntry(value: string | undefined) {
76
+ if (!value) return null;
77
+ const [objectName, ...rest] = value.split(":");
78
+ const entryId = rest.join(":").trim();
79
+ if (!objectName?.trim() || !entryId) {
80
+ throw new LinkedInCliError("--entry must look like people:<entryId>");
81
+ }
82
+ return { objectName: objectName.trim(), entryId };
83
+ }
84
+
85
+ function buildLinkedInCliUrl(host: string): string {
86
+ return `${host.replace(/\/+$/, "")}/api/linkedin/cli`;
87
+ }
88
+
89
+ async function postJson(
90
+ url: string,
91
+ bearer: string,
92
+ body: unknown,
93
+ ): Promise<{ ok: boolean; payload: unknown; status: number }> {
94
+ const response = await fetch(url, {
95
+ method: "POST",
96
+ headers: {
97
+ accept: "application/json",
98
+ authorization: `Bearer ${bearer}`,
99
+ "content-type": "application/json",
100
+ },
101
+ body: JSON.stringify(body),
102
+ });
103
+ const text = await response.text();
104
+ let payload: unknown;
105
+ try {
106
+ payload = text ? JSON.parse(text) : null;
107
+ } catch {
108
+ payload = { raw: text };
109
+ }
110
+ return { ok: response.ok, payload, status: response.status };
111
+ }
112
+
113
+ function payloadError(payload: unknown): string {
114
+ if (payload && typeof payload === "object" && !Array.isArray(payload)) {
115
+ const error = (payload as { error?: unknown }).error;
116
+ if (typeof error === "string" && error.trim()) return error;
117
+ }
118
+ return "LinkedIn request failed";
119
+ }
120
+
121
+ function output(ctx: LinkedInCliContext, value: unknown): void {
122
+ if (ctx.jsonOutput) {
123
+ console.log(JSON.stringify(value, null, 2));
124
+ return;
125
+ }
126
+ console.log(String(value));
127
+ }
128
+
129
+ function formatHuman(response: LinkedInResponse) {
130
+ const lines: string[] = [];
131
+ const status = response.status ?? "unknown";
132
+ if (response.ok === false) {
133
+ lines.push(`LinkedIn connect failed: ${response.error ?? status}`);
134
+ } else {
135
+ lines.push(`LinkedIn connect: ${status}`);
136
+ }
137
+ if (response.verified !== undefined) {
138
+ lines.push(`verified: ${response.verified ? "yes" : "no"}`);
139
+ }
140
+ if (response.message) lines.push(`result: ${response.message}`);
141
+ if (response.profileUrl) lines.push(`profile: ${response.profileUrl}`);
142
+ if (response.currentUrl) lines.push(`url: ${response.currentUrl}`);
143
+ if (response.threadId) lines.push(`threadId: ${response.threadId}`);
144
+ if (response.crmUpdate) lines.push("crm: updated");
145
+ if (response.threadUrl) {
146
+ lines.push("");
147
+ lines.push(`Open in Dench: ${response.threadUrl}`);
148
+ }
149
+ return lines.join("\n");
150
+ }
151
+
152
+ async function runConnectCommand(ctx: LinkedInCliContext) {
153
+ const threadId = consumeFlagValue(ctx.args, "--thread");
154
+ const entry = parseEntry(consumeFlagValue(ctx.args, "--entry"));
155
+ const objectName =
156
+ consumeFlagValue(ctx.args, "--object") ?? entry?.objectName;
157
+ const entryId = consumeFlagValue(ctx.args, "--entry-id") ?? entry?.entryId;
158
+ const profileUrl = normalizeProfileUrl(ctx.args.shift());
159
+
160
+ const body = {
161
+ action: "connect",
162
+ profileUrl,
163
+ threadId,
164
+ ...(objectName ? { crmObjectName: objectName } : {}),
165
+ ...(entryId ? { crmEntryId: entryId } : {}),
166
+ };
167
+
168
+ const result = await postJson(
169
+ buildLinkedInCliUrl(ctx.runtime.host),
170
+ ctx.runtime.bearerToken,
171
+ body,
172
+ );
173
+ if (!result.ok) {
174
+ throw new LinkedInCliError(
175
+ `${payloadError(result.payload)} (${result.status})`,
176
+ );
177
+ }
178
+
179
+ const response =
180
+ result.payload && typeof result.payload === "object"
181
+ ? (result.payload as LinkedInResponse)
182
+ : ({} as LinkedInResponse);
183
+
184
+ output(ctx, ctx.jsonOutput ? response : formatHuman(response));
185
+ }
186
+
187
+ export async function runLinkedInCommand(opts: {
188
+ args: string[];
189
+ runtime: RuntimeBundle;
190
+ }): Promise<void> {
191
+ const args = [...opts.args];
192
+ const jsonOutput = hasFlag(args, "--json");
193
+ const ctx: LinkedInCliContext = {
194
+ args,
195
+ jsonOutput,
196
+ runtime: opts.runtime,
197
+ };
198
+ const subcommand = args.shift();
199
+ if (!subcommand || subcommand === "help" || subcommand === "--help") {
200
+ linkedinHelp();
201
+ return;
202
+ }
203
+ if (subcommand === "connect") {
204
+ await runConnectCommand(ctx);
205
+ return;
206
+ }
207
+ throw new LinkedInCliError(`Unknown linkedin subcommand: ${subcommand}`);
208
+ }
209
+
210
+ export { LinkedInCliError };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dench.com/cli",
3
- "version": "2.1.1",
3
+ "version": "2.1.3",
4
4
  "description": "Dench agent workspace CLI. v2 unifies auth behind `dench signin`; the legacy `dench login` / `dench onboard` / `dench setup` / `dench what-can-i-do` / `dench register` commands now error with a redirect.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -26,6 +26,8 @@
26
26
  "crm.ts",
27
27
  "members.ts",
28
28
  "cron.ts",
29
+ "browser.ts",
30
+ "linkedin.ts",
29
31
  "search.ts",
30
32
  "image.ts",
31
33
  "tools.ts",
@@ -34,6 +36,7 @@
34
36
  "host.ts",
35
37
  "openUrl.ts",
36
38
  "session.ts",
39
+ "lib/organizationSelector.ts",
37
40
  "lib/",
38
41
  "README.md"
39
42
  ],
@@ -43,7 +46,8 @@
43
46
  "dependencies": {
44
47
  "chokidar": "^5.0.0",
45
48
  "convex": "1.34.0",
46
- "tsx": "^4.21.0"
49
+ "tsx": "^4.21.0",
50
+ "zod": "4.3.6"
47
51
  },
48
52
  "publishConfig": {
49
53
  "access": "public"