@dzhng/crm.cli 0.2.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.
Files changed (3) hide show
  1. package/README.md +1312 -0
  2. package/dist/cli.js +4291 -0
  3. package/package.json +52 -0
package/dist/cli.js ADDED
@@ -0,0 +1,4291 @@
1
+ #!/usr/bin/env node
2
+ import { createRequire } from "node:module";
3
+ var __defProp = Object.defineProperty;
4
+ var __returnValue = (v) => v;
5
+ function __exportSetter(name, newValue) {
6
+ this[name] = __returnValue.bind(null, newValue);
7
+ }
8
+ var __export = (target, all) => {
9
+ for (var name in all)
10
+ __defProp(target, name, {
11
+ get: all[name],
12
+ enumerable: true,
13
+ configurable: true,
14
+ set: __exportSetter.bind(all, name)
15
+ });
16
+ };
17
+ var __require = /* @__PURE__ */ createRequire(import.meta.url);
18
+
19
+ // src/cli.ts
20
+ import { Command } from "commander";
21
+
22
+ // src/db.ts
23
+ import { mkdirSync as mkdirSync2 } from "node:fs";
24
+ import { dirname as dirname2 } from "node:path";
25
+ import { createClient } from "@libsql/client";
26
+ import { sql as sql2 } from "drizzle-orm";
27
+ import { drizzle } from "drizzle-orm/libsql";
28
+
29
+ // src/drizzle-schema.ts
30
+ var exports_drizzle_schema = {};
31
+ __export(exports_drizzle_schema, {
32
+ deals: () => deals,
33
+ contacts: () => contacts,
34
+ companies: () => companies,
35
+ activities: () => activities
36
+ });
37
+ import { integer, sqliteTable, text } from "drizzle-orm/sqlite-core";
38
+ var contacts = sqliteTable("contacts", {
39
+ id: text("id").primaryKey(),
40
+ name: text("name").notNull(),
41
+ emails: text("emails").notNull().default("[]"),
42
+ phones: text("phones").notNull().default("[]"),
43
+ companies: text("companies").notNull().default("[]"),
44
+ linkedin: text("linkedin"),
45
+ x: text("x"),
46
+ bluesky: text("bluesky"),
47
+ telegram: text("telegram"),
48
+ tags: text("tags").notNull().default("[]"),
49
+ custom_fields: text("custom_fields").notNull().default("{}"),
50
+ created_at: text("created_at").notNull(),
51
+ updated_at: text("updated_at").notNull()
52
+ });
53
+ var companies = sqliteTable("companies", {
54
+ id: text("id").primaryKey(),
55
+ name: text("name").notNull(),
56
+ websites: text("websites").notNull().default("[]"),
57
+ phones: text("phones").notNull().default("[]"),
58
+ tags: text("tags").notNull().default("[]"),
59
+ custom_fields: text("custom_fields").notNull().default("{}"),
60
+ created_at: text("created_at").notNull(),
61
+ updated_at: text("updated_at").notNull()
62
+ });
63
+ var deals = sqliteTable("deals", {
64
+ id: text("id").primaryKey(),
65
+ title: text("title").notNull(),
66
+ value: integer("value"),
67
+ stage: text("stage").notNull(),
68
+ contacts: text("contacts").notNull().default("[]"),
69
+ company: text("company"),
70
+ expected_close: text("expected_close"),
71
+ probability: integer("probability"),
72
+ tags: text("tags").notNull().default("[]"),
73
+ custom_fields: text("custom_fields").notNull().default("{}"),
74
+ created_at: text("created_at").notNull(),
75
+ updated_at: text("updated_at").notNull()
76
+ });
77
+ var activities = sqliteTable("activities", {
78
+ id: text("id").primaryKey(),
79
+ type: text("type").notNull(),
80
+ body: text("body").notNull().default(""),
81
+ contacts: text("contacts").notNull().default("[]"),
82
+ company: text("company"),
83
+ deal: text("deal"),
84
+ custom_fields: text("custom_fields").notNull().default("{}"),
85
+ created_at: text("created_at").notNull()
86
+ });
87
+
88
+ // src/lib/helpers.ts
89
+ import { eq as eq2, sql } from "drizzle-orm";
90
+ import { ulid } from "ulid";
91
+
92
+ // src/config.ts
93
+ import { execSync } from "node:child_process";
94
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
95
+ import { homedir } from "node:os";
96
+ import { dirname, join, resolve } from "node:path";
97
+ import { parse as parseTOML } from "toml";
98
+ var DEFAULT_STAGES = [
99
+ "lead",
100
+ "qualified",
101
+ "proposal",
102
+ "negotiation",
103
+ "closed-won",
104
+ "closed-lost"
105
+ ];
106
+ function defaultConfig() {
107
+ return {
108
+ database: { path: join(homedir(), ".crm", "crm.db") },
109
+ pipeline: {
110
+ stages: [...DEFAULT_STAGES],
111
+ won_stage: "closed-won",
112
+ lost_stage: "closed-lost"
113
+ },
114
+ defaults: { format: "table" },
115
+ phone: { display: "international" },
116
+ hooks: {},
117
+ mount: {
118
+ default_path: join(homedir(), "crm"),
119
+ readonly: false,
120
+ max_recent_activity: 10,
121
+ search_limit: 20
122
+ }
123
+ };
124
+ }
125
+ function findConfigFile(startDir) {
126
+ let dir = resolve(startDir);
127
+ while (true) {
128
+ const candidate = join(dir, "crm.toml");
129
+ if (existsSync(candidate)) {
130
+ return candidate;
131
+ }
132
+ const parent = dirname(dir);
133
+ if (parent === dir) {
134
+ break;
135
+ }
136
+ dir = parent;
137
+ }
138
+ const global = join(homedir(), ".crm", "config.toml");
139
+ if (existsSync(global)) {
140
+ return global;
141
+ }
142
+ return null;
143
+ }
144
+ function mergeConfig(base, override) {
145
+ const result = { ...base };
146
+ if (override.database?.path) {
147
+ result.database = { ...result.database, path: override.database.path };
148
+ }
149
+ if (override.pipeline) {
150
+ result.pipeline = { ...result.pipeline };
151
+ if (override.pipeline.stages) {
152
+ result.pipeline.stages = override.pipeline.stages;
153
+ }
154
+ if (override.pipeline.won_stage) {
155
+ result.pipeline.won_stage = override.pipeline.won_stage;
156
+ }
157
+ if (override.pipeline.lost_stage) {
158
+ result.pipeline.lost_stage = override.pipeline.lost_stage;
159
+ }
160
+ }
161
+ if (override.defaults?.format) {
162
+ result.defaults = { ...result.defaults, format: override.defaults.format };
163
+ }
164
+ if (override.phone) {
165
+ result.phone = { ...result.phone };
166
+ if (override.phone.default_country) {
167
+ result.phone.default_country = override.phone.default_country;
168
+ }
169
+ if (override.phone.display) {
170
+ result.phone.display = override.phone.display;
171
+ }
172
+ }
173
+ if (override.hooks) {
174
+ result.hooks = { ...result.hooks, ...override.hooks };
175
+ }
176
+ if (override.mount) {
177
+ result.mount = { ...result.mount, ...override.mount };
178
+ }
179
+ return result;
180
+ }
181
+ function detectCountry() {
182
+ try {
183
+ if (process.platform === "darwin") {
184
+ const locale = execSync("defaults read NSGlobalDomain AppleLocale", {
185
+ stdio: ["pipe", "pipe", "pipe"]
186
+ }).toString().trim();
187
+ const match2 = locale.match(/_([A-Z]{2})/);
188
+ if (match2) {
189
+ return match2[1];
190
+ }
191
+ }
192
+ const lang = process.env.LC_ALL || process.env.LANG || "";
193
+ const match = lang.match(/_([A-Z]{2})/);
194
+ if (match) {
195
+ return match[1];
196
+ }
197
+ } catch {}
198
+ return;
199
+ }
200
+ function createDefaultConfig(configPath) {
201
+ const country = detectCountry() || "US";
202
+ const content = `# CRM CLI configuration
203
+ # Docs: https://github.com/dzhng/crm.cli#configuration
204
+
205
+ [phone]
206
+ default_country = "${country}"
207
+ display = "national"
208
+
209
+ [pipeline]
210
+ stages = ["lead", "qualified", "proposal", "negotiation", "closed-won", "closed-lost"]
211
+ won_stage = "closed-won"
212
+ lost_stage = "closed-lost"
213
+ `;
214
+ mkdirSync(dirname(configPath), { recursive: true });
215
+ writeFileSync(configPath, content);
216
+ console.log(`Created default config at ${configPath}`);
217
+ console.log(` phone.default_country = "${country}" (detected from system locale)`);
218
+ console.log(` Edit this file to customize.
219
+ `);
220
+ }
221
+ function loadConfig(opts) {
222
+ let config = defaultConfig();
223
+ const configPath = opts.configPath || process.env.CRM_CONFIG || findConfigFile(process.cwd()) || (() => {
224
+ const p = join(homedir(), ".crm", "config.toml");
225
+ createDefaultConfig(p);
226
+ return p;
227
+ })();
228
+ try {
229
+ const raw = readFileSync(configPath, "utf-8");
230
+ const parsed = parseTOML(raw);
231
+ config = mergeConfig(config, parsed);
232
+ } catch (_e) {
233
+ console.error(`Warning: could not parse config file ${configPath}`);
234
+ }
235
+ if (process.env.CRM_PHONE_DEFAULT_COUNTRY) {
236
+ config.phone.default_country = process.env.CRM_PHONE_DEFAULT_COUNTRY;
237
+ }
238
+ if (process.env.CRM_PHONE_DISPLAY) {
239
+ config.phone.display = process.env.CRM_PHONE_DISPLAY;
240
+ }
241
+ if (opts.dbPath) {
242
+ config.database.path = opts.dbPath;
243
+ } else if (process.env.CRM_DB) {
244
+ config.database.path = process.env.CRM_DB;
245
+ }
246
+ if (opts.format) {
247
+ config.defaults.format = opts.format;
248
+ } else if (process.env.CRM_FORMAT) {
249
+ config.defaults.format = process.env.CRM_FORMAT;
250
+ }
251
+ return config;
252
+ }
253
+
254
+ // src/normalize.ts
255
+ import { parsePhoneNumberFromString } from "libphonenumber-js";
256
+ import normalizeUrl from "normalize-url";
257
+ function normalizePhone(input, defaultCountry) {
258
+ const cleaned = input.trim();
259
+ const country = defaultCountry;
260
+ let phone = parsePhoneNumberFromString(cleaned, country);
261
+ if (!(phone || cleaned.startsWith("+") || country)) {
262
+ phone = parsePhoneNumberFromString(cleaned, "US");
263
+ if (phone?.isValid()) {
264
+ const hasFormatting = /[()\-\s]/.test(cleaned);
265
+ if (!hasFormatting) {
266
+ throw new Error(`Invalid phone number: "${input}". No country code provided — set phone.default_country in config or prefix with +`);
267
+ }
268
+ }
269
+ }
270
+ if (!phone) {
271
+ if (!(defaultCountry || cleaned.startsWith("+"))) {
272
+ throw new Error(`Invalid phone number: "${input}". No country code provided — set phone.default_country in config or prefix with +`);
273
+ }
274
+ throw new Error(`Invalid phone number: "${input}"`);
275
+ }
276
+ if (!phone.isValid()) {
277
+ throw new Error(`Invalid phone number: "${input}"`);
278
+ }
279
+ return phone.format("E.164");
280
+ }
281
+ function formatPhone(e164, display, defaultCountry) {
282
+ const phone = parsePhoneNumberFromString(e164);
283
+ if (!phone) {
284
+ return e164;
285
+ }
286
+ switch (display) {
287
+ case "e164":
288
+ return phone.format("E.164");
289
+ case "national":
290
+ if (defaultCountry && phone.country !== defaultCountry.toUpperCase()) {
291
+ return phone.formatInternational();
292
+ }
293
+ return phone.formatNational();
294
+ default:
295
+ return phone.formatInternational();
296
+ }
297
+ }
298
+ function tryNormalizePhone(input, defaultCountry) {
299
+ try {
300
+ return normalizePhone(input, defaultCountry);
301
+ } catch {
302
+ return null;
303
+ }
304
+ }
305
+ function extractPhoneDigits(input) {
306
+ return input.replace(/[^\d]/g, "");
307
+ }
308
+ function phoneMatchesByDigits(e164, digits) {
309
+ const stored = extractPhoneDigits(e164);
310
+ return stored.endsWith(digits) || digits.endsWith(stored);
311
+ }
312
+ var NORMALIZE_URL_OPTS = {
313
+ stripProtocol: true,
314
+ stripHash: true,
315
+ removeQueryParameters: true,
316
+ stripWWW: true,
317
+ removeSingleSlash: true,
318
+ sortQueryParameters: false
319
+ };
320
+ function normalizeWebsite(input) {
321
+ return normalizeUrl(input.trim(), NORMALIZE_URL_OPTS);
322
+ }
323
+ function tryNormalizeWebsite(input) {
324
+ try {
325
+ return normalizeUrl(input.trim(), NORMALIZE_URL_OPTS);
326
+ } catch {
327
+ return null;
328
+ }
329
+ }
330
+ var SOCIAL_PATTERNS = {
331
+ linkedin: [/(?:https?:\/\/)?(?:www\.)?linkedin\.com\/in\/([^/?#]+)\/?/i],
332
+ x: [/(?:https?:\/\/)?(?:www\.)?(?:x\.com|twitter\.com)\/([^/?#]+)\/?/i],
333
+ bluesky: [/(?:https?:\/\/)?(?:www\.)?bsky\.app\/profile\/([^/?#]+)\/?/i],
334
+ telegram: [/(?:https?:\/\/)?(?:www\.)?t\.me\/([^/?#]+)\/?/i]
335
+ };
336
+ function normalizeSocialHandle(platform, input) {
337
+ const trimmed = input.trim();
338
+ const patterns = SOCIAL_PATTERNS[platform];
339
+ if (patterns) {
340
+ for (const pattern of patterns) {
341
+ const match = trimmed.match(pattern);
342
+ if (match) {
343
+ return match[1];
344
+ }
345
+ }
346
+ }
347
+ if (trimmed.startsWith("@")) {
348
+ return trimmed.slice(1);
349
+ }
350
+ return trimmed;
351
+ }
352
+ function tryExtractSocialHandle(input) {
353
+ for (const [platform, patterns] of Object.entries(SOCIAL_PATTERNS)) {
354
+ for (const pattern of patterns) {
355
+ const match = input.match(pattern);
356
+ if (match) {
357
+ return { platform, handle: match[1] };
358
+ }
359
+ }
360
+ }
361
+ return null;
362
+ }
363
+
364
+ // src/format.ts
365
+ function formatOutput(data, format, config) {
366
+ switch (format) {
367
+ case "json":
368
+ return JSON.stringify(data, null, 2);
369
+ case "csv":
370
+ return formatCSV(data);
371
+ case "tsv":
372
+ return formatTSV(data);
373
+ case "ids":
374
+ return formatIDs(data);
375
+ default:
376
+ return formatTable(data, config);
377
+ }
378
+ }
379
+ function formatIDs(data) {
380
+ if (!Array.isArray(data)) {
381
+ return "";
382
+ }
383
+ return data.map((r) => r.id).join(`
384
+ `);
385
+ }
386
+ function formatCSV(data) {
387
+ if (!Array.isArray(data) || data.length === 0) {
388
+ return "";
389
+ }
390
+ const keys = Object.keys(data[0]);
391
+ const header = keys.map(csvEscape).join(",");
392
+ const rows = data.map((row) => keys.map((k) => {
393
+ const v = row[k];
394
+ if (Array.isArray(v)) {
395
+ return csvEscape(v.join(", "));
396
+ }
397
+ if (v && typeof v === "object") {
398
+ return csvEscape(JSON.stringify(v));
399
+ }
400
+ return csvEscape(String(v ?? ""));
401
+ }).join(","));
402
+ return [header, ...rows].join(`
403
+ `);
404
+ }
405
+ function csvEscape(s) {
406
+ if (s.includes(",") || s.includes('"') || s.includes(`
407
+ `)) {
408
+ return `"${s.replace(/"/g, '""')}"`;
409
+ }
410
+ return s;
411
+ }
412
+ function formatTSV(data) {
413
+ if (!Array.isArray(data) || data.length === 0) {
414
+ return "";
415
+ }
416
+ const keys = Object.keys(data[0]);
417
+ const header = keys.join("\t");
418
+ const rows = data.map((row) => keys.map((k) => {
419
+ const v = row[k];
420
+ if (Array.isArray(v)) {
421
+ return v.join(", ");
422
+ }
423
+ if (v && typeof v === "object") {
424
+ return JSON.stringify(v);
425
+ }
426
+ return String(v ?? "");
427
+ }).join("\t"));
428
+ return [header, ...rows].join(`
429
+ `);
430
+ }
431
+ function formatTable(data, config) {
432
+ if (!data || Array.isArray(data) && data.length === 0) {
433
+ return "";
434
+ }
435
+ if (!Array.isArray(data)) {
436
+ return formatEntityDetail(data);
437
+ }
438
+ const keys = Object.keys(data[0]).filter((k) => data.some((row) => {
439
+ const v = row[k];
440
+ if (v === null || v === undefined || v === "") {
441
+ return false;
442
+ }
443
+ if (Array.isArray(v) && v.length === 0) {
444
+ return false;
445
+ }
446
+ if (typeof v === "object" && !Array.isArray(v) && Object.keys(v).length === 0) {
447
+ return false;
448
+ }
449
+ return true;
450
+ }));
451
+ if (keys.length === 0) {
452
+ return "";
453
+ }
454
+ const widths = {};
455
+ for (const k of keys) {
456
+ widths[k] = k.length;
457
+ }
458
+ for (const row of data) {
459
+ for (const k of keys) {
460
+ const v = displayValue(row[k], config);
461
+ widths[k] = Math.max(widths[k], v.length);
462
+ }
463
+ }
464
+ const header = keys.map((k) => k.padEnd(widths[k])).join(" ");
465
+ const separator = keys.map((k) => "─".repeat(widths[k])).join("──");
466
+ const rows = data.map((row) => keys.map((k) => displayValue(row[k], config).padEnd(widths[k])).join(" "));
467
+ return [header, separator, ...rows].join(`
468
+ `);
469
+ }
470
+ function displayValue(v, config) {
471
+ if (v === null || v === undefined) {
472
+ return "";
473
+ }
474
+ if (Array.isArray(v)) {
475
+ return v.map((item) => displayValue(item, config)).join(", ");
476
+ }
477
+ if (typeof v === "object") {
478
+ return JSON.stringify(v);
479
+ }
480
+ const s = String(v);
481
+ if (/^\+\d{7,15}$/.test(s)) {
482
+ return formatPhone(s, config?.phone?.display || "international", config?.phone?.default_country);
483
+ }
484
+ if (/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}/.test(s)) {
485
+ const d = new Date(s);
486
+ if (!Number.isNaN(d.getTime())) {
487
+ return d.toLocaleString(undefined, {
488
+ year: "numeric",
489
+ month: "short",
490
+ day: "numeric",
491
+ hour: "numeric",
492
+ minute: "2-digit"
493
+ });
494
+ }
495
+ }
496
+ return s;
497
+ }
498
+ function formatEntityDetail(entity) {
499
+ const lines = [];
500
+ for (const [key, value] of Object.entries(entity)) {
501
+ if (value === null || value === undefined) {
502
+ continue;
503
+ }
504
+ if (Array.isArray(value)) {
505
+ if (value.length === 0) {
506
+ continue;
507
+ }
508
+ if (typeof value[0] === "object") {
509
+ lines.push(`${key}:`);
510
+ for (const item of value) {
511
+ lines.push(` ${JSON.stringify(item)}`);
512
+ }
513
+ } else {
514
+ lines.push(`${key}: ${value.join(", ")}`);
515
+ }
516
+ } else if (typeof value === "object") {
517
+ lines.push(`${key}:`);
518
+ for (const [k, v] of Object.entries(value)) {
519
+ lines.push(` ${k}: ${v}`);
520
+ }
521
+ } else {
522
+ lines.push(`${key}: ${value}`);
523
+ }
524
+ }
525
+ return lines.join(`
526
+ `);
527
+ }
528
+ function contactToRow(c) {
529
+ const emails = safeJSON(c.emails);
530
+ const phones = safeJSON(c.phones);
531
+ const companies2 = safeJSON(c.companies);
532
+ const tags = safeJSON(c.tags);
533
+ const custom = safeJSON(c.custom_fields);
534
+ return {
535
+ id: c.id,
536
+ name: c.name,
537
+ emails,
538
+ phones,
539
+ companies: companies2,
540
+ linkedin: c.linkedin || null,
541
+ x: c.x || null,
542
+ bluesky: c.bluesky || null,
543
+ telegram: c.telegram || null,
544
+ tags,
545
+ custom_fields: custom,
546
+ created_at: c.created_at,
547
+ updated_at: c.updated_at
548
+ };
549
+ }
550
+ function companyToRow(c) {
551
+ return {
552
+ id: c.id,
553
+ name: c.name,
554
+ websites: safeJSON(c.websites),
555
+ phones: safeJSON(c.phones),
556
+ tags: safeJSON(c.tags),
557
+ custom_fields: safeJSON(c.custom_fields),
558
+ created_at: c.created_at,
559
+ updated_at: c.updated_at
560
+ };
561
+ }
562
+ function dealToRow(d) {
563
+ return {
564
+ id: d.id,
565
+ title: d.title,
566
+ value: d.value ?? null,
567
+ stage: d.stage,
568
+ contacts: safeJSON(d.contacts),
569
+ company: d.company || null,
570
+ expected_close: d.expected_close || null,
571
+ probability: d.probability ?? null,
572
+ tags: safeJSON(d.tags),
573
+ custom_fields: safeJSON(d.custom_fields),
574
+ created_at: d.created_at,
575
+ updated_at: d.updated_at
576
+ };
577
+ }
578
+ function activityToRow(a) {
579
+ return {
580
+ id: a.id,
581
+ type: a.type,
582
+ body: a.body,
583
+ contacts: safeJSON(a.contacts),
584
+ company: a.company || null,
585
+ deal: a.deal || null,
586
+ custom_fields: safeJSON(a.custom_fields),
587
+ created_at: a.created_at
588
+ };
589
+ }
590
+ function safeJSON(val) {
591
+ if (val === null || val === undefined) {
592
+ if (typeof val === "string") {
593
+ return val;
594
+ }
595
+ return Array.isArray(val) ? [] : {};
596
+ }
597
+ if (typeof val === "string") {
598
+ try {
599
+ return JSON.parse(val);
600
+ } catch {
601
+ return val;
602
+ }
603
+ }
604
+ return val;
605
+ }
606
+
607
+ // src/resolve.ts
608
+ import { eq } from "drizzle-orm";
609
+ async function resolveContact(db, rawRef, config) {
610
+ const ref = rawRef.trim();
611
+ if (ref.startsWith("ct_")) {
612
+ const results = await db.select().from(contacts).where(eq(contacts.id, ref));
613
+ return results[0] || null;
614
+ }
615
+ if (ref.includes("@") && !ref.includes("/")) {
616
+ const handle = ref.startsWith("@") ? ref.slice(1) : ref;
617
+ const all = await db.select().from(contacts);
618
+ for (const c of all) {
619
+ const emails = safeJSON(c.emails);
620
+ if (emails.some((e) => e.toLowerCase() === ref.toLowerCase())) {
621
+ return c;
622
+ }
623
+ }
624
+ for (const c of all) {
625
+ if (c.linkedin === handle || c.x === handle || c.bluesky === handle || c.telegram === handle) {
626
+ return c;
627
+ }
628
+ }
629
+ return null;
630
+ }
631
+ const extracted = tryExtractSocialHandle(ref);
632
+ if (extracted) {
633
+ const col = extracted.platform;
634
+ const results = await db.select().from(contacts).where(eq(contacts[col], extracted.handle));
635
+ if (results[0]) {
636
+ return results[0];
637
+ }
638
+ }
639
+ const phoneNorm = tryNormalizePhone(ref, config?.phone?.default_country);
640
+ if (phoneNorm) {
641
+ const all = await db.select().from(contacts);
642
+ for (const c of all) {
643
+ const phones = safeJSON(c.phones);
644
+ if (phones.includes(phoneNorm)) {
645
+ return c;
646
+ }
647
+ }
648
+ }
649
+ const digits = extractPhoneDigits(ref);
650
+ if (digits.length >= 7) {
651
+ const all = await db.select().from(contacts);
652
+ for (const c of all) {
653
+ const phones = safeJSON(c.phones);
654
+ for (const p of phones) {
655
+ if (phoneMatchesByDigits(p, digits)) {
656
+ return c;
657
+ }
658
+ }
659
+ }
660
+ }
661
+ {
662
+ const handle = ref.startsWith("@") ? ref.slice(1) : ref;
663
+ const all = await db.select().from(contacts);
664
+ for (const c of all) {
665
+ if (c.linkedin === handle || c.x === handle || c.bluesky === handle || c.telegram === handle) {
666
+ return c;
667
+ }
668
+ }
669
+ }
670
+ return null;
671
+ }
672
+ async function resolveCompany(db, rawRef, config) {
673
+ const ref = rawRef.trim();
674
+ if (ref.startsWith("co_")) {
675
+ const results = await db.select().from(companies).where(eq(companies.id, ref));
676
+ return results[0] || null;
677
+ }
678
+ const all = await db.select().from(companies);
679
+ const normalizedWeb = tryNormalizeWebsite(ref);
680
+ if (normalizedWeb) {
681
+ for (const co of all) {
682
+ const websites = safeJSON(co.websites);
683
+ if (websites.some((w) => w === normalizedWeb)) {
684
+ return co;
685
+ }
686
+ }
687
+ }
688
+ const phoneNorm = tryNormalizePhone(ref, config?.phone?.default_country);
689
+ if (phoneNorm) {
690
+ for (const co of all) {
691
+ const phones = safeJSON(co.phones);
692
+ if (phones.includes(phoneNorm)) {
693
+ return co;
694
+ }
695
+ }
696
+ }
697
+ const digits = extractPhoneDigits(ref);
698
+ if (digits.length >= 7) {
699
+ for (const co of all) {
700
+ const phones = safeJSON(co.phones);
701
+ for (const p of phones) {
702
+ if (phoneMatchesByDigits(p, digits)) {
703
+ return co;
704
+ }
705
+ }
706
+ }
707
+ }
708
+ for (const co of all) {
709
+ if (co.name === ref) {
710
+ return co;
711
+ }
712
+ }
713
+ return null;
714
+ }
715
+ async function resolveDeal(db, rawRef) {
716
+ const ref = rawRef.trim();
717
+ if (ref.startsWith("dl_")) {
718
+ const results = await db.select().from(deals).where(eq(deals.id, ref));
719
+ return results[0] || null;
720
+ }
721
+ return null;
722
+ }
723
+ async function resolveEntity(db, rawRef, config) {
724
+ const ref = rawRef.trim();
725
+ const contact = await resolveContact(db, ref, config);
726
+ if (contact) {
727
+ return { type: "contact", entity: contact };
728
+ }
729
+ const company = await resolveCompany(db, ref, config);
730
+ if (company) {
731
+ return { type: "company", entity: company };
732
+ }
733
+ const deal = await resolveDeal(db, ref);
734
+ if (deal) {
735
+ return { type: "deal", entity: deal };
736
+ }
737
+ return null;
738
+ }
739
+ async function resolveCompanyForLink(db, rawRef) {
740
+ const ref = rawRef.trim();
741
+ if (ref.startsWith("co_")) {
742
+ const results = await db.select().from(companies).where(eq(companies.id, ref));
743
+ return results[0] || null;
744
+ }
745
+ const all = await db.select().from(companies);
746
+ const normalizedWeb = tryNormalizeWebsite(ref);
747
+ if (normalizedWeb) {
748
+ for (const co of all) {
749
+ const websites = safeJSON(co.websites);
750
+ if (websites.some((w) => w === normalizedWeb)) {
751
+ return co;
752
+ }
753
+ }
754
+ }
755
+ for (const co of all) {
756
+ if (co.name === ref) {
757
+ return co;
758
+ }
759
+ }
760
+ return null;
761
+ }
762
+
763
+ // src/lib/helpers.ts
764
+ var rawArgv = process.argv.slice(2);
765
+ var gDb;
766
+ var gConfig;
767
+ var gFmt;
768
+ var cleanArgv = [];
769
+ var _argIdx = 0;
770
+ while (_argIdx < rawArgv.length) {
771
+ const arg = rawArgv[_argIdx];
772
+ if (arg === "--db") {
773
+ _argIdx++;
774
+ gDb = rawArgv[_argIdx];
775
+ } else if (arg === "--config") {
776
+ _argIdx++;
777
+ gConfig = rawArgv[_argIdx];
778
+ } else if (arg === "--format") {
779
+ _argIdx++;
780
+ gFmt = rawArgv[_argIdx];
781
+ } else if (arg !== "--no-color") {
782
+ cleanArgv.push(arg);
783
+ }
784
+ _argIdx++;
785
+ }
786
+ async function getCtx() {
787
+ const config = loadConfig({ configPath: gConfig, dbPath: gDb, format: gFmt });
788
+ const db = await openDB(config.database.path);
789
+ return { config, db, fmt: config.defaults.format };
790
+ }
791
+ function makeId(prefix) {
792
+ return `${prefix}_${ulid()}`;
793
+ }
794
+ function now() {
795
+ return new Date().toISOString();
796
+ }
797
+ function die(msg) {
798
+ console.error(msg);
799
+ process.exit(1);
800
+ }
801
+ function collect(v, prev) {
802
+ prev.push(v);
803
+ return prev;
804
+ }
805
+ function confirmOrForce(force, label) {
806
+ if (force) {
807
+ return;
808
+ }
809
+ if (!process.stdin.isTTY) {
810
+ die(`Error: refusing to delete ${label} without --force (non-interactive)`);
811
+ }
812
+ const fs = __require("node:fs");
813
+ process.stdout.write(`Delete ${label}? [y/N] `);
814
+ const buf = Buffer.alloc(64);
815
+ const fd = fs.openSync("/dev/tty", "r");
816
+ try {
817
+ const n = fs.readSync(fd, buf, 0, 64, null);
818
+ const answer = buf.slice(0, n).toString().trim().toLowerCase();
819
+ if (answer !== "y" && answer !== "yes") {
820
+ die("Aborted");
821
+ }
822
+ } finally {
823
+ fs.closeSync(fd);
824
+ }
825
+ }
826
+ function parseKV(arr) {
827
+ const r = {};
828
+ for (const s of arr || []) {
829
+ const i = s.indexOf("=");
830
+ if (i > 0) {
831
+ const key = s.slice(0, i).trim();
832
+ const val = s.slice(i + 1).trim();
833
+ if (key.startsWith("json:")) {
834
+ try {
835
+ r[key.slice(5)] = JSON.parse(val);
836
+ } catch {
837
+ die(`Error: invalid JSON for custom field "${key.slice(5)}"`);
838
+ }
839
+ } else {
840
+ r[key] = val;
841
+ }
842
+ }
843
+ }
844
+ return r;
845
+ }
846
+ async function getOrCreateCompanyId(db, rawRef) {
847
+ const ref = rawRef.trim();
848
+ const co = await resolveCompanyForLink(db, ref);
849
+ if (co) {
850
+ return co.id;
851
+ }
852
+ const cid = makeId("co");
853
+ const n = now();
854
+ await db.insert(companies).values({
855
+ id: cid,
856
+ name: ref,
857
+ websites: "[]",
858
+ phones: "[]",
859
+ tags: "[]",
860
+ custom_fields: "{}",
861
+ created_at: n,
862
+ updated_at: n
863
+ });
864
+ await upsertSearchIndex(db, "company", cid, ref);
865
+ return cid;
866
+ }
867
+ async function getOrCreateContactId(db, rawRef, config) {
868
+ const ref = rawRef.trim();
869
+ const ct = await resolveContact(db, ref, config);
870
+ if (ct) {
871
+ return ct.id;
872
+ }
873
+ const cid = makeId("ct");
874
+ const n = now();
875
+ const isEmail = ref.includes("@") && !ref.includes("/");
876
+ const normalizedPhone = isEmail ? null : tryNormalizePhone(ref, config.phone?.default_country);
877
+ let name = ref;
878
+ if (isEmail) {
879
+ name = ref.split("@")[0];
880
+ }
881
+ await db.insert(contacts).values({
882
+ id: cid,
883
+ name,
884
+ emails: isEmail ? JSON.stringify([ref]) : "[]",
885
+ phones: normalizedPhone ? JSON.stringify([normalizedPhone]) : "[]",
886
+ companies: "[]",
887
+ tags: "[]",
888
+ custom_fields: "{}",
889
+ created_at: n,
890
+ updated_at: n
891
+ });
892
+ await upsertSearchIndex(db, "contact", cid, ref);
893
+ return cid;
894
+ }
895
+ function validateEmail(email) {
896
+ if (!email.includes("@") || email.startsWith("@") || email.endsWith("@")) {
897
+ die(`Error: invalid email "${email}" — must contain @`);
898
+ }
899
+ }
900
+ async function checkDupeEmail(db, email, excludeId) {
901
+ const all = await db.select().from(contacts);
902
+ for (const c of all) {
903
+ if (excludeId && c.id === excludeId) {
904
+ continue;
905
+ }
906
+ const emails = safeJSON(c.emails);
907
+ if (emails.some((e) => e.toLowerCase() === email.toLowerCase())) {
908
+ die(`Error: duplicate email "${email}" — already belongs to ${c.name} (${c.id})`);
909
+ }
910
+ }
911
+ }
912
+ async function checkDupePhone(db, phone, table, excludeId) {
913
+ const all = table === "contacts" ? await db.select().from(contacts) : await db.select().from(companies);
914
+ for (const c of all) {
915
+ if (excludeId && c.id === excludeId) {
916
+ continue;
917
+ }
918
+ const phones = safeJSON(c.phones);
919
+ if (phones.includes(phone)) {
920
+ die(`Error: duplicate phone "${phone}" — already belongs to ${c.name} (${c.id})`);
921
+ }
922
+ }
923
+ }
924
+ async function checkDupeWebsite(db, website, excludeId) {
925
+ const all = await db.select().from(companies);
926
+ for (const co of all) {
927
+ if (excludeId && co.id === excludeId) {
928
+ continue;
929
+ }
930
+ const websites = safeJSON(co.websites);
931
+ if (websites.includes(website)) {
932
+ die(`Error: duplicate website "${website}" — already belongs to ${co.name} (${co.id})`);
933
+ }
934
+ }
935
+ }
936
+ async function checkDupeSocial(db, platform, handle, excludeId) {
937
+ const col = platform;
938
+ const existing = await db.select().from(contacts).where(eq2(contacts[col], handle));
939
+ const match = existing[0];
940
+ if (match && match.id !== excludeId) {
941
+ die(`Error: duplicate ${platform} handle "${handle}" — already belongs to ${match.name} (${match.id})`);
942
+ }
943
+ }
944
+ async function buildContactSearch(db, c) {
945
+ const companyIds = safeJSON(c.companies);
946
+ const allCompanies = await db.select().from(companies);
947
+ const companyNames = companyIds.map((id) => allCompanies.find((co) => co.id === id)?.name).filter(Boolean);
948
+ return [
949
+ c.name,
950
+ c.emails,
951
+ c.phones,
952
+ companyNames.join(" "),
953
+ c.linkedin,
954
+ c.x,
955
+ c.bluesky,
956
+ c.telegram,
957
+ JSON.stringify(safeJSON(c.custom_fields)),
958
+ c.tags
959
+ ].filter(Boolean).join(" ");
960
+ }
961
+ function buildCompanySearch(co) {
962
+ return [
963
+ co.name,
964
+ co.websites,
965
+ co.phones,
966
+ JSON.stringify(safeJSON(co.custom_fields)),
967
+ co.tags
968
+ ].filter(Boolean).join(" ");
969
+ }
970
+ function buildDealSearch(d) {
971
+ return [d.title, d.stage, JSON.stringify(safeJSON(d.custom_fields)), d.tags].filter(Boolean).join(" ");
972
+ }
973
+ async function contactDetail(db, c, config) {
974
+ const row = contactToRow(c);
975
+ const phones = safeJSON(c.phones);
976
+ row._display_phones = phones.map((p) => formatPhone(p, config.phone.display, config.phone.default_country));
977
+ const companyIds = safeJSON(c.companies);
978
+ const allCompanies = await db.select().from(companies);
979
+ row.companies = companyIds.map((id) => {
980
+ const co = allCompanies.find((x) => x.id === id);
981
+ return co ? co.name : id;
982
+ });
983
+ const allDeals = await db.select().from(deals);
984
+ row.deals = allDeals.filter((d) => {
985
+ const contacts2 = safeJSON(d.contacts);
986
+ return contacts2.includes(c.id);
987
+ }).map((d) => ({ id: d.id, title: d.title, stage: d.stage, value: d.value }));
988
+ return row;
989
+ }
990
+ async function companyDetail(db, co, config) {
991
+ const row = companyToRow(co);
992
+ const phones = safeJSON(co.phones);
993
+ row._display_phones = phones.map((p) => formatPhone(p, config.phone.display, config.phone.default_country));
994
+ const linkedContacts = await db.select().from(contacts);
995
+ row.contacts = linkedContacts.filter((ct) => {
996
+ const companies2 = safeJSON(ct.companies);
997
+ return companies2.includes(co.id);
998
+ }).map((ct) => ({ id: ct.id, name: ct.name, emails: safeJSON(ct.emails) }));
999
+ const allDeals = await db.select().from(deals).where(eq2(deals.company, co.id));
1000
+ row.deals = allDeals.map((d) => ({
1001
+ id: d.id,
1002
+ title: d.title,
1003
+ stage: d.stage,
1004
+ value: d.value
1005
+ }));
1006
+ return row;
1007
+ }
1008
+ async function dealDetail(db, d) {
1009
+ const row = dealToRow(d);
1010
+ const contactIds = safeJSON(d.contacts);
1011
+ const contactPromises = contactIds.map(async (cid) => {
1012
+ const results = await db.select().from(contacts).where(eq2(contacts.id, cid));
1013
+ const ct = results[0];
1014
+ return ct ? { id: ct.id, name: ct.name, emails: safeJSON(ct.emails) } : null;
1015
+ });
1016
+ row.contacts = (await Promise.all(contactPromises)).filter(Boolean);
1017
+ if (d.company) {
1018
+ const results = await db.select().from(companies).where(eq2(companies.id, d.company));
1019
+ const co = results[0];
1020
+ row.company = co ? { id: co.id, name: co.name } : null;
1021
+ }
1022
+ const stageChanges = await db.select().from(activities).where(sql`${activities.deal} = ${d.id} AND ${activities.type} = 'stage-change'`).orderBy(activities.created_at);
1023
+ const history = [];
1024
+ if (stageChanges.length > 0) {
1025
+ const m = stageChanges[0].body.match(/from (\S+) to/);
1026
+ history.push({ stage: m ? m[1] : d.stage, at: d.created_at });
1027
+ for (const sc of stageChanges) {
1028
+ const tm = sc.body.match(/to (\S+)/);
1029
+ history.push({ stage: tm ? tm[1] : "", at: sc.created_at });
1030
+ }
1031
+ } else {
1032
+ history.push({ stage: d.stage, at: d.created_at });
1033
+ }
1034
+ row.stage_history = history;
1035
+ row.notes = stageChanges.filter((a) => a.body.includes("|")).map((a) => a.body);
1036
+ return row;
1037
+ }
1038
+ function showEntity(detail, fmt) {
1039
+ if (fmt === "json") {
1040
+ console.log(JSON.stringify(detail, null, 2));
1041
+ return;
1042
+ }
1043
+ const lines = [];
1044
+ for (const [k, v] of Object.entries(detail)) {
1045
+ if (k === "_display_phones") {
1046
+ continue;
1047
+ }
1048
+ if (v === null || v === undefined) {
1049
+ continue;
1050
+ }
1051
+ if (Array.isArray(v)) {
1052
+ if (v.length === 0) {
1053
+ continue;
1054
+ }
1055
+ if (typeof v[0] === "object") {
1056
+ lines.push(`${k}:`);
1057
+ for (const item of v) {
1058
+ lines.push(` ${typeof item === "object" ? JSON.stringify(item) : item}`);
1059
+ }
1060
+ } else {
1061
+ lines.push(`${k}: ${v.join(", ")}`);
1062
+ }
1063
+ } else if (typeof v === "object") {
1064
+ lines.push(`${k}:`);
1065
+ for (const [sk, sv] of Object.entries(v)) {
1066
+ lines.push(` ${sk}: ${sv}`);
1067
+ }
1068
+ } else {
1069
+ lines.push(`${k}: ${v}`);
1070
+ }
1071
+ }
1072
+ const displayPhones = detail._display_phones;
1073
+ if (displayPhones?.length) {
1074
+ const idx = lines.findIndex((l) => l.startsWith("phones:"));
1075
+ if (idx >= 0) {
1076
+ lines[idx] = `phones: ${displayPhones.join(", ")}`;
1077
+ }
1078
+ }
1079
+ console.log(lines.join(`
1080
+ `));
1081
+ }
1082
+ function parseCSV(text2) {
1083
+ const lines = text2.split(`
1084
+ `).filter((l) => l.trim());
1085
+ if (lines.length < 1) {
1086
+ return [];
1087
+ }
1088
+ const headers = parseCSVLine(lines[0]);
1089
+ const rows = [];
1090
+ for (let i = 1;i < lines.length; i++) {
1091
+ const vals = parseCSVLine(lines[i]);
1092
+ const row = {};
1093
+ for (let j = 0;j < headers.length; j++) {
1094
+ row[headers[j]] = vals[j] || "";
1095
+ }
1096
+ rows.push(row);
1097
+ }
1098
+ return rows;
1099
+ }
1100
+ function parseCSVLine(line) {
1101
+ const result = [];
1102
+ let current = "";
1103
+ let inQuotes = false;
1104
+ for (let i = 0;i < line.length; i++) {
1105
+ const ch = line[i];
1106
+ if (inQuotes) {
1107
+ if (ch === '"' && line[i + 1] === '"') {
1108
+ current += '"';
1109
+ i++;
1110
+ continue;
1111
+ }
1112
+ if (ch === '"') {
1113
+ inQuotes = false;
1114
+ continue;
1115
+ }
1116
+ current += ch;
1117
+ } else {
1118
+ if (ch === '"') {
1119
+ inQuotes = true;
1120
+ continue;
1121
+ }
1122
+ if (ch === ",") {
1123
+ result.push(current);
1124
+ current = "";
1125
+ continue;
1126
+ }
1127
+ current += ch;
1128
+ }
1129
+ }
1130
+ result.push(current);
1131
+ return result;
1132
+ }
1133
+ function levenshtein(a, b) {
1134
+ const la = a.length, lb = b.length;
1135
+ const dp = Array.from({ length: la + 1 }, () => new Array(lb + 1).fill(0));
1136
+ for (let i = 0;i <= la; i++) {
1137
+ dp[i][0] = i;
1138
+ }
1139
+ for (let j = 0;j <= lb; j++) {
1140
+ dp[0][j] = j;
1141
+ }
1142
+ for (let i = 1;i <= la; i++) {
1143
+ for (let j = 1;j <= lb; j++) {
1144
+ dp[i][j] = Math.min(dp[i - 1][j] + 1, dp[i][j - 1] + 1, dp[i - 1][j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1));
1145
+ }
1146
+ }
1147
+ return dp[la][lb];
1148
+ }
1149
+
1150
+ // src/db.ts
1151
+ var SCHEMA_SQL = `
1152
+ CREATE TABLE IF NOT EXISTS contacts (
1153
+ id TEXT PRIMARY KEY,
1154
+ name TEXT NOT NULL,
1155
+ emails TEXT NOT NULL DEFAULT '[]',
1156
+ phones TEXT NOT NULL DEFAULT '[]',
1157
+ companies TEXT NOT NULL DEFAULT '[]',
1158
+ linkedin TEXT,
1159
+ x TEXT,
1160
+ bluesky TEXT,
1161
+ telegram TEXT,
1162
+ tags TEXT NOT NULL DEFAULT '[]',
1163
+ custom_fields TEXT NOT NULL DEFAULT '{}',
1164
+ created_at TEXT NOT NULL,
1165
+ updated_at TEXT NOT NULL
1166
+ );
1167
+
1168
+ CREATE UNIQUE INDEX IF NOT EXISTS idx_contacts_linkedin ON contacts(linkedin) WHERE linkedin IS NOT NULL;
1169
+ CREATE UNIQUE INDEX IF NOT EXISTS idx_contacts_x ON contacts(x) WHERE x IS NOT NULL;
1170
+ CREATE UNIQUE INDEX IF NOT EXISTS idx_contacts_bluesky ON contacts(bluesky) WHERE bluesky IS NOT NULL;
1171
+ CREATE UNIQUE INDEX IF NOT EXISTS idx_contacts_telegram ON contacts(telegram) WHERE telegram IS NOT NULL;
1172
+
1173
+ CREATE TABLE IF NOT EXISTS companies (
1174
+ id TEXT PRIMARY KEY,
1175
+ name TEXT NOT NULL,
1176
+ websites TEXT NOT NULL DEFAULT '[]',
1177
+ phones TEXT NOT NULL DEFAULT '[]',
1178
+ tags TEXT NOT NULL DEFAULT '[]',
1179
+ custom_fields TEXT NOT NULL DEFAULT '{}',
1180
+ created_at TEXT NOT NULL,
1181
+ updated_at TEXT NOT NULL
1182
+ );
1183
+
1184
+ CREATE TABLE IF NOT EXISTS deals (
1185
+ id TEXT PRIMARY KEY,
1186
+ title TEXT NOT NULL,
1187
+ value INTEGER,
1188
+ stage TEXT NOT NULL,
1189
+ contacts TEXT NOT NULL DEFAULT '[]',
1190
+ company TEXT REFERENCES companies(id) ON DELETE SET NULL,
1191
+ expected_close TEXT,
1192
+ probability INTEGER,
1193
+ tags TEXT NOT NULL DEFAULT '[]',
1194
+ custom_fields TEXT NOT NULL DEFAULT '{}',
1195
+ created_at TEXT NOT NULL,
1196
+ updated_at TEXT NOT NULL
1197
+ );
1198
+
1199
+ CREATE TABLE IF NOT EXISTS activities (
1200
+ id TEXT PRIMARY KEY,
1201
+ type TEXT NOT NULL,
1202
+ body TEXT NOT NULL DEFAULT '',
1203
+ contacts TEXT NOT NULL DEFAULT '[]',
1204
+ company TEXT,
1205
+ deal TEXT,
1206
+ custom_fields TEXT NOT NULL DEFAULT '{}',
1207
+ created_at TEXT NOT NULL
1208
+ );
1209
+
1210
+ CREATE VIRTUAL TABLE IF NOT EXISTS search_index USING fts5(
1211
+ entity_type, entity_id, content
1212
+ );
1213
+ `;
1214
+ async function openDB(dbPath) {
1215
+ mkdirSync2(dirname2(dbPath), { recursive: true });
1216
+ const client = createClient({ url: `file:${dbPath}` });
1217
+ const db = drizzle(client, { schema: exports_drizzle_schema });
1218
+ const statements = SCHEMA_SQL.split(";").map((s) => s.trim()).filter((s) => s.length > 0);
1219
+ for (const stmt of statements) {
1220
+ await client.execute(stmt);
1221
+ }
1222
+ await client.execute("PRAGMA journal_mode=WAL");
1223
+ await client.execute("PRAGMA foreign_keys=ON");
1224
+ return db;
1225
+ }
1226
+ async function upsertSearchIndex(db, entityType, entityId, content) {
1227
+ await db.run(sql2`DELETE FROM search_index WHERE entity_id = ${entityId}`);
1228
+ await db.run(sql2`INSERT INTO search_index (entity_type, entity_id, content) VALUES (${entityType}, ${entityId}, ${content})`);
1229
+ }
1230
+ async function removeSearchIndex(db, entityId) {
1231
+ await db.run(sql2`DELETE FROM search_index WHERE entity_id = ${entityId}`);
1232
+ }
1233
+ async function rebuildSearchIndex(db) {
1234
+ await db.run(sql2`DELETE FROM search_index`);
1235
+ const allContacts = await db.select().from(contacts);
1236
+ for (const c of allContacts) {
1237
+ const content = await buildContactSearch(db, c);
1238
+ await db.run(sql2`INSERT INTO search_index (entity_type, entity_id, content) VALUES (${"contact"}, ${c.id}, ${content})`);
1239
+ }
1240
+ const allCompanies = await db.select().from(companies);
1241
+ for (const co of allCompanies) {
1242
+ const content = buildCompanySearch(co);
1243
+ await db.run(sql2`INSERT INTO search_index (entity_type, entity_id, content) VALUES (${"company"}, ${co.id}, ${content})`);
1244
+ }
1245
+ const allDeals = await db.select().from(deals);
1246
+ for (const d of allDeals) {
1247
+ const content = buildDealSearch(d);
1248
+ await db.run(sql2`INSERT INTO search_index (entity_type, entity_id, content) VALUES (${"deal"}, ${d.id}, ${content})`);
1249
+ }
1250
+ const allActivities = await db.select().from(activities);
1251
+ for (const a of allActivities) {
1252
+ const content = [a.type, a.body, a.custom_fields].filter(Boolean).join(" ");
1253
+ await db.run(sql2`INSERT INTO search_index (entity_type, entity_id, content) VALUES (${"activity"}, ${a.id}, ${content})`);
1254
+ }
1255
+ }
1256
+
1257
+ // src/hooks.ts
1258
+ import { spawnSync } from "node:child_process";
1259
+ function runHook(config, hookName, data) {
1260
+ const hookCmd = config.hooks[hookName];
1261
+ if (!hookCmd) {
1262
+ return true;
1263
+ }
1264
+ const jsonData = JSON.stringify(data);
1265
+ const result = spawnSync(hookCmd, {
1266
+ shell: true,
1267
+ input: jsonData,
1268
+ stdio: ["pipe", "pipe", "pipe"],
1269
+ timeout: 30000
1270
+ });
1271
+ return result.status === 0;
1272
+ }
1273
+
1274
+ // src/commands/activity.ts
1275
+ var VALID_TYPES = ["note", "call", "meeting", "email"];
1276
+ function registerLogCommand(program) {
1277
+ program.command("log").description("Log an activity").argument("<type>", "Activity type (note, call, meeting, email)").argument("<body>", "Activity body").option("--contact <ref>", "Link to contact (repeatable)", collect, []).option("--company <ref>", "Link to company (auto-creates if needed)").option("--deal <ref>", "Link to deal").option("--at <date>", "Custom timestamp").option("--set <kv>", "Custom field", collect, []).action(async (rawType, rawBody, opts) => {
1278
+ const { db, config } = await getCtx();
1279
+ const type = rawType.trim();
1280
+ const body = rawBody.trim();
1281
+ opts.contact = opts.contact.map((c) => c.trim());
1282
+ if (opts.company) {
1283
+ opts.company = opts.company.trim();
1284
+ }
1285
+ if (opts.deal) {
1286
+ opts.deal = opts.deal.trim();
1287
+ }
1288
+ if (!VALID_TYPES.includes(type)) {
1289
+ die(`Error: invalid activity type "${type}". Must be one of: ${VALID_TYPES.join(", ")}`);
1290
+ }
1291
+ const contacts2 = [];
1292
+ let company = null;
1293
+ let deal = null;
1294
+ for (const cRef of opts.contact) {
1295
+ const ctId = await getOrCreateContactId(db, cRef, config);
1296
+ if (!contacts2.includes(ctId)) {
1297
+ contacts2.push(ctId);
1298
+ }
1299
+ }
1300
+ if (opts.company) {
1301
+ company = await getOrCreateCompanyId(db, opts.company);
1302
+ }
1303
+ if (opts.deal) {
1304
+ const d = await resolveDeal(db, opts.deal);
1305
+ if (!d) {
1306
+ die(`Error: deal not found: ${opts.deal}`);
1307
+ }
1308
+ deal = d.id;
1309
+ }
1310
+ if (opts.at) {
1311
+ opts.at = opts.at.trim();
1312
+ const d = new Date(opts.at);
1313
+ if (Number.isNaN(d.getTime())) {
1314
+ die("Error: invalid --at date");
1315
+ }
1316
+ }
1317
+ const id = makeId("ac");
1318
+ const ts = opts.at || now();
1319
+ const custom = parseKV(opts.set);
1320
+ if (!runHook(config, "pre-activity-add", {
1321
+ type,
1322
+ body,
1323
+ contacts: contacts2,
1324
+ company,
1325
+ deal,
1326
+ custom_fields: custom
1327
+ })) {
1328
+ die("Error: pre-activity-add hook rejected creation");
1329
+ }
1330
+ await db.insert(activities).values({
1331
+ id,
1332
+ type,
1333
+ body,
1334
+ contacts: JSON.stringify(contacts2),
1335
+ company,
1336
+ deal,
1337
+ custom_fields: JSON.stringify(custom),
1338
+ created_at: ts
1339
+ });
1340
+ await upsertSearchIndex(db, "activity", id, `${type} ${body}`);
1341
+ runHook(config, "post-activity-add", {
1342
+ id,
1343
+ type,
1344
+ body,
1345
+ contacts: contacts2,
1346
+ company,
1347
+ deal,
1348
+ custom_fields: custom
1349
+ });
1350
+ });
1351
+ }
1352
+ function registerActivityCommands(program) {
1353
+ const cmd = program.command("activity").description("Activity management");
1354
+ cmd.command("list").option("--contact <ref>").option("--company <ref>").option("--deal <id>").option("--type <type>").option("--since <date>").option("--sort <field>").option("--reverse", "Reverse sort order").option("--limit <n>").option("--offset <n>").action(async (opts) => {
1355
+ const { db, config, fmt } = await getCtx();
1356
+ let rows = (await db.select().from(activities)).map((a) => activityToRow(a));
1357
+ if (opts.contact) {
1358
+ const ct = await resolveContact(db, opts.contact, config);
1359
+ if (ct) {
1360
+ rows = rows.filter((a) => a.contacts.includes(ct.id));
1361
+ } else {
1362
+ rows = [];
1363
+ }
1364
+ }
1365
+ if (opts.company) {
1366
+ const co = await resolveCompany(db, opts.company, config);
1367
+ if (co) {
1368
+ rows = rows.filter((a) => a.company === co.id);
1369
+ } else {
1370
+ rows = [];
1371
+ }
1372
+ }
1373
+ if (opts.deal) {
1374
+ rows = rows.filter((a) => a.deal === opts.deal);
1375
+ }
1376
+ if (opts.type) {
1377
+ rows = rows.filter((a) => a.type === opts.type);
1378
+ }
1379
+ if (opts.since) {
1380
+ rows = rows.filter((a) => a.created_at >= opts.since);
1381
+ }
1382
+ if (opts.sort) {
1383
+ rows.sort((a, b) => String(a[opts.sort] ?? "").localeCompare(String(b[opts.sort] ?? "")));
1384
+ }
1385
+ if (opts.reverse) {
1386
+ rows.reverse();
1387
+ }
1388
+ if (opts.offset) {
1389
+ rows = rows.slice(Number(opts.offset));
1390
+ }
1391
+ if (opts.limit) {
1392
+ rows = rows.slice(0, Number(opts.limit));
1393
+ }
1394
+ console.log(formatOutput(rows, fmt, config));
1395
+ });
1396
+ }
1397
+
1398
+ // src/commands/company.ts
1399
+ import { eq as eq3 } from "drizzle-orm";
1400
+
1401
+ // src/filter.ts
1402
+ function parseFilter(expr) {
1403
+ const orParts = expr.split(/\s+OR\s+/);
1404
+ if (orParts.length > 1) {
1405
+ const conditions2 = [];
1406
+ for (const part of orParts) {
1407
+ conditions2.push(parseSingleCondition(part.trim()));
1408
+ }
1409
+ return { conditions: conditions2, logic: "OR" };
1410
+ }
1411
+ const andParts = expr.split(/\s+AND\s+/);
1412
+ const conditions = andParts.map((p) => parseSingleCondition(p.trim()));
1413
+ return { conditions, logic: "AND" };
1414
+ }
1415
+ function parseSingleCondition(expr) {
1416
+ const match = expr.match(/^([^!~<>=]+)(~=|!=|>=|<=|>|<|=)(.*)$/);
1417
+ if (match) {
1418
+ return {
1419
+ field: match[1].trim(),
1420
+ op: match[2],
1421
+ value: match[3].trim()
1422
+ };
1423
+ }
1424
+ throw new Error(`Invalid filter expression: ${expr}`);
1425
+ }
1426
+ function applyFilter(row, filter) {
1427
+ if (filter.logic === "AND") {
1428
+ return filter.conditions.every((c) => matchCondition(row, c));
1429
+ }
1430
+ return filter.conditions.some((c) => matchCondition(row, c));
1431
+ }
1432
+ function matchCondition(row, condition) {
1433
+ const { field, op, value } = condition;
1434
+ let fieldValue = row[field];
1435
+ if (fieldValue === undefined || fieldValue === null) {
1436
+ const custom = typeof row.custom_fields === "string" ? safeJSON(row.custom_fields) : row.custom_fields || {};
1437
+ fieldValue = custom[field];
1438
+ }
1439
+ if (fieldValue === undefined || fieldValue === null) {
1440
+ if (op === "!=") {
1441
+ return true;
1442
+ }
1443
+ return false;
1444
+ }
1445
+ const strVal = String(fieldValue);
1446
+ switch (op) {
1447
+ case "=":
1448
+ return strVal === value;
1449
+ case "!=":
1450
+ return strVal !== value;
1451
+ case "~=":
1452
+ return strVal.toLowerCase().includes(value.toLowerCase());
1453
+ case ">":
1454
+ return Number(strVal) > Number(value);
1455
+ case "<":
1456
+ return Number(strVal) < Number(value);
1457
+ default:
1458
+ return false;
1459
+ }
1460
+ }
1461
+
1462
+ // src/commands/company.ts
1463
+ function registerCompanyCommands(program) {
1464
+ const cmd = program.command("company").description("Manage companies");
1465
+ cmd.command("add").requiredOption("--name <name>", "Company name").option("--website <url>", "Website", collect, []).option("--phone <phone>", "Phone", collect, []).option("--tag <tag>", "Tag", collect, []).option("--set <kv>", "Custom field", collect, []).action(async (opts) => {
1466
+ const { db, config } = await getCtx();
1467
+ opts.name = opts.name.trim();
1468
+ opts.website = opts.website.map((w) => w.trim());
1469
+ opts.phone = opts.phone.map((p) => p.trim());
1470
+ opts.tag = opts.tag.map((t) => t.trim());
1471
+ const cid = makeId("co");
1472
+ const n = now();
1473
+ const websites = [];
1474
+ for (const w of opts.website) {
1475
+ const norm = normalizeWebsite(w);
1476
+ await checkDupeWebsite(db, norm);
1477
+ websites.push(norm);
1478
+ }
1479
+ const phones = [];
1480
+ for (const p of opts.phone) {
1481
+ try {
1482
+ const norm = normalizePhone(p, config.phone.default_country);
1483
+ await checkDupePhone(db, norm, "companies");
1484
+ phones.push(norm);
1485
+ } catch (e) {
1486
+ die(`Error: invalid phone — ${e.message}`);
1487
+ }
1488
+ }
1489
+ const custom = parseKV(opts.set);
1490
+ if (!runHook(config, "pre-company-add", {
1491
+ name: opts.name,
1492
+ websites,
1493
+ phones,
1494
+ tags: opts.tag,
1495
+ custom_fields: custom
1496
+ })) {
1497
+ die("Error: pre-company-add hook rejected creation");
1498
+ }
1499
+ await db.insert(companies).values({
1500
+ id: cid,
1501
+ name: opts.name,
1502
+ websites: JSON.stringify(websites),
1503
+ phones: JSON.stringify(phones),
1504
+ tags: JSON.stringify(opts.tag),
1505
+ custom_fields: JSON.stringify(custom),
1506
+ created_at: n,
1507
+ updated_at: n
1508
+ });
1509
+ const results = await db.select().from(companies).where(eq3(companies.id, cid));
1510
+ const row = results[0];
1511
+ await upsertSearchIndex(db, "company", cid, buildCompanySearch(row));
1512
+ runHook(config, "post-company-add", {
1513
+ id: cid,
1514
+ name: opts.name,
1515
+ websites,
1516
+ phones,
1517
+ tags: opts.tag,
1518
+ custom_fields: custom
1519
+ });
1520
+ console.log(cid);
1521
+ });
1522
+ cmd.command("list").option("--tag <tag>").option("--sort <field>").option("--reverse", "Reverse sort order").option("--limit <n>").option("--offset <n>").option("--filter <expr>").action(async (opts) => {
1523
+ const { db, config, fmt } = await getCtx();
1524
+ let rows = (await db.select().from(companies)).map((c) => companyToRow(c));
1525
+ if (opts.filter) {
1526
+ const f = parseFilter(opts.filter);
1527
+ rows = rows.filter((c) => applyFilter(c, f));
1528
+ }
1529
+ if (opts.tag) {
1530
+ rows = rows.filter((c) => c.tags?.includes(opts.tag));
1531
+ }
1532
+ if (opts.sort) {
1533
+ rows.sort((a, b) => String(a[opts.sort] ?? "").localeCompare(String(b[opts.sort] ?? "")));
1534
+ }
1535
+ if (opts.reverse) {
1536
+ rows.reverse();
1537
+ }
1538
+ if (opts.offset) {
1539
+ rows = rows.slice(Number(opts.offset));
1540
+ }
1541
+ if (opts.limit) {
1542
+ rows = rows.slice(0, Number(opts.limit));
1543
+ }
1544
+ console.log(formatOutput(rows, fmt, config));
1545
+ });
1546
+ cmd.command("show").argument("<ref>").action(async (ref) => {
1547
+ const { db, config, fmt } = await getCtx();
1548
+ const co = await resolveCompany(db, ref, config);
1549
+ if (!co) {
1550
+ die(`Error: company not found: ${ref}`);
1551
+ }
1552
+ showEntity(await companyDetail(db, co, config), fmt);
1553
+ });
1554
+ cmd.command("edit").argument("<ref>").option("--name <name>").option("--add-website <url>", "", collect, []).option("--rm-website <url>", "", collect, []).option("--add-phone <p>", "", collect, []).option("--rm-phone <p>", "", collect, []).option("--add-tag <t>", "", collect, []).option("--rm-tag <t>", "", collect, []).option("--set <kv>", "", collect, []).option("--unset <key>", "", collect, []).action(async (ref, opts) => {
1555
+ const { db, config } = await getCtx();
1556
+ if (opts.name) {
1557
+ opts.name = opts.name.trim();
1558
+ }
1559
+ opts.addWebsite = opts.addWebsite.map((w) => w.trim());
1560
+ opts.rmWebsite = opts.rmWebsite.map((w) => w.trim());
1561
+ opts.addPhone = opts.addPhone.map((p) => p.trim());
1562
+ opts.rmPhone = opts.rmPhone.map((p) => p.trim());
1563
+ opts.addTag = opts.addTag.map((t) => t.trim());
1564
+ opts.rmTag = opts.rmTag.map((t) => t.trim());
1565
+ const co = await resolveCompany(db, ref.trim(), config);
1566
+ if (!co) {
1567
+ die(`Error: company not found: ${ref}`);
1568
+ }
1569
+ let websites = safeJSON(co.websites);
1570
+ let phones = safeJSON(co.phones);
1571
+ let tags = safeJSON(co.tags);
1572
+ const custom = safeJSON(co.custom_fields);
1573
+ let name = co.name;
1574
+ if (opts.name) {
1575
+ name = opts.name;
1576
+ }
1577
+ for (const w of opts.addWebsite) {
1578
+ const norm = normalizeWebsite(w);
1579
+ if (!websites.includes(norm)) {
1580
+ await checkDupeWebsite(db, norm, co.id);
1581
+ websites.push(norm);
1582
+ }
1583
+ }
1584
+ for (const w of opts.rmWebsite) {
1585
+ const norm = normalizeWebsite(w);
1586
+ websites = websites.filter((v) => v !== norm);
1587
+ }
1588
+ for (const p of opts.addPhone) {
1589
+ const norm = normalizePhone(p, config.phone.default_country);
1590
+ if (!phones.includes(norm)) {
1591
+ await checkDupePhone(db, norm, "companies", co.id);
1592
+ phones.push(norm);
1593
+ }
1594
+ }
1595
+ for (const p of opts.rmPhone) {
1596
+ const norm = tryNormalizePhone(p, config.phone.default_country);
1597
+ phones = norm ? phones.filter((v) => v !== norm) : phones.filter((v) => v !== p);
1598
+ }
1599
+ for (const t of opts.addTag) {
1600
+ if (!tags.includes(t)) {
1601
+ tags.push(t);
1602
+ }
1603
+ }
1604
+ for (const t of opts.rmTag) {
1605
+ tags = tags.filter((v) => v !== t);
1606
+ }
1607
+ const kvs = parseKV(opts.set);
1608
+ for (const [k, v] of Object.entries(kvs)) {
1609
+ custom[k] = v;
1610
+ }
1611
+ for (const k of opts.unset) {
1612
+ delete custom[k];
1613
+ }
1614
+ if (!runHook(config, "pre-company-edit", {
1615
+ id: co.id,
1616
+ name,
1617
+ websites,
1618
+ phones,
1619
+ tags,
1620
+ custom_fields: custom
1621
+ })) {
1622
+ die("Error: pre-company-edit hook rejected edit");
1623
+ }
1624
+ await db.update(companies).set({
1625
+ name,
1626
+ websites: JSON.stringify(websites),
1627
+ phones: JSON.stringify(phones),
1628
+ tags: JSON.stringify(tags),
1629
+ custom_fields: JSON.stringify(custom),
1630
+ updated_at: now()
1631
+ }).where(eq3(companies.id, co.id));
1632
+ const results = await db.select().from(companies).where(eq3(companies.id, co.id));
1633
+ const row = results[0];
1634
+ await upsertSearchIndex(db, "company", co.id, buildCompanySearch(row));
1635
+ console.log(co.id);
1636
+ runHook(config, "post-company-edit", {
1637
+ id: co.id,
1638
+ name,
1639
+ websites,
1640
+ phones,
1641
+ tags,
1642
+ custom_fields: custom
1643
+ });
1644
+ });
1645
+ cmd.command("rm").argument("<ref>").option("--force", "Skip confirmation").action(async (ref, opts) => {
1646
+ const { db, config } = await getCtx();
1647
+ const co = await resolveCompany(db, ref, config);
1648
+ if (!co) {
1649
+ die(`Error: company not found: ${ref}`);
1650
+ }
1651
+ confirmOrForce(opts.force, `company "${co.name}" (${co.id})`);
1652
+ if (!runHook(config, "pre-company-rm", { id: co.id, name: co.name })) {
1653
+ die("Error: pre-company-rm hook rejected deletion");
1654
+ }
1655
+ const allContacts = await db.select().from(contacts);
1656
+ for (const ct of allContacts) {
1657
+ const companies2 = safeJSON(ct.companies);
1658
+ if (companies2.includes(co.id)) {
1659
+ await db.update(contacts).set({
1660
+ companies: JSON.stringify(companies2.filter((n) => n !== co.id))
1661
+ }).where(eq3(contacts.id, ct.id));
1662
+ }
1663
+ }
1664
+ await db.update(deals).set({ company: null }).where(eq3(deals.company, co.id));
1665
+ await db.delete(companies).where(eq3(companies.id, co.id));
1666
+ await removeSearchIndex(db, co.id);
1667
+ runHook(config, "post-company-rm", { id: co.id, name: co.name });
1668
+ });
1669
+ cmd.command("merge").argument("<id1>").argument("<id2>").action(async (id1, id2) => {
1670
+ const { db, config } = await getCtx();
1671
+ const c1 = await resolveCompany(db, id1, config), c2 = await resolveCompany(db, id2, config);
1672
+ if (!(c1 && c2)) {
1673
+ die("Error: one or both companies not found");
1674
+ }
1675
+ const mergedWebsites = [
1676
+ ...new Set([...safeJSON(c1.websites), ...safeJSON(c2.websites)])
1677
+ ];
1678
+ const mergedPhones = [
1679
+ ...new Set([...safeJSON(c1.phones), ...safeJSON(c2.phones)])
1680
+ ];
1681
+ const mergedTags = [
1682
+ ...new Set([...safeJSON(c1.tags), ...safeJSON(c2.tags)])
1683
+ ];
1684
+ const mergedCustom = {
1685
+ ...safeJSON(c2.custom_fields),
1686
+ ...safeJSON(c1.custom_fields)
1687
+ };
1688
+ await db.update(companies).set({
1689
+ websites: JSON.stringify(mergedWebsites),
1690
+ phones: JSON.stringify(mergedPhones),
1691
+ tags: JSON.stringify(mergedTags),
1692
+ custom_fields: JSON.stringify(mergedCustom),
1693
+ updated_at: now()
1694
+ }).where(eq3(companies.id, c1.id));
1695
+ const allContacts = await db.select().from(contacts);
1696
+ for (const ct of allContacts) {
1697
+ const companies2 = safeJSON(ct.companies);
1698
+ if (companies2.includes(c2.id)) {
1699
+ const updated = [
1700
+ ...new Set(companies2.map((n) => n === c2.id ? c1.id : n))
1701
+ ];
1702
+ await db.update(contacts).set({ companies: JSON.stringify(updated) }).where(eq3(contacts.id, ct.id));
1703
+ }
1704
+ }
1705
+ await db.update(deals).set({ company: c1.id }).where(eq3(deals.company, c2.id));
1706
+ await db.update(activities).set({ company: c1.id }).where(eq3(activities.company, c2.id));
1707
+ await db.delete(companies).where(eq3(companies.id, c2.id));
1708
+ await removeSearchIndex(db, c2.id);
1709
+ const results = await db.select().from(companies).where(eq3(companies.id, c1.id));
1710
+ const row = results[0];
1711
+ await upsertSearchIndex(db, "company", c1.id, buildCompanySearch(row));
1712
+ console.log(c1.id);
1713
+ });
1714
+ }
1715
+
1716
+ // src/commands/contact.ts
1717
+ import { eq as eq4 } from "drizzle-orm";
1718
+ function registerContactCommands(program) {
1719
+ const cmd = program.command("contact").description("Manage contacts");
1720
+ cmd.command("add").requiredOption("--name <name>", "Contact name").option("--email <email>", "Email", collect, []).option("--phone <phone>", "Phone", collect, []).option("--company <company>", "Company", collect, []).option("--tag <tag>", "Tag", collect, []).option("--linkedin <h>", "LinkedIn").option("--x <h>", "X/Twitter").option("--bluesky <h>", "Bluesky").option("--telegram <h>", "Telegram").option("--set <kv>", "Custom field", collect, []).action(async (opts) => {
1721
+ const { db, config } = await getCtx();
1722
+ opts.name = opts.name.trim();
1723
+ opts.email = opts.email.map((e) => e.trim());
1724
+ opts.phone = opts.phone.map((p) => p.trim());
1725
+ opts.company = opts.company.map((c) => c.trim());
1726
+ opts.tag = opts.tag.map((t) => t.trim());
1727
+ const cid = makeId("ct");
1728
+ const n = now();
1729
+ for (const e of opts.email) {
1730
+ validateEmail(e);
1731
+ await checkDupeEmail(db, e);
1732
+ }
1733
+ const phones = [];
1734
+ for (const p of opts.phone) {
1735
+ try {
1736
+ const norm = normalizePhone(p, config.phone.default_country);
1737
+ await checkDupePhone(db, norm, "contacts");
1738
+ phones.push(norm);
1739
+ } catch (e) {
1740
+ die(`Error: invalid phone — ${e.message}`);
1741
+ }
1742
+ }
1743
+ const linkedin = opts.linkedin ? normalizeSocialHandle("linkedin", opts.linkedin.trim()) : null;
1744
+ const x = opts.x ? normalizeSocialHandle("x", opts.x.trim()) : null;
1745
+ const bluesky = opts.bluesky ? normalizeSocialHandle("bluesky", opts.bluesky.trim()) : null;
1746
+ const telegram = opts.telegram ? normalizeSocialHandle("telegram", opts.telegram.trim()) : null;
1747
+ if (linkedin) {
1748
+ await checkDupeSocial(db, "linkedin", linkedin);
1749
+ }
1750
+ if (x) {
1751
+ await checkDupeSocial(db, "x", x);
1752
+ }
1753
+ if (bluesky) {
1754
+ await checkDupeSocial(db, "bluesky", bluesky);
1755
+ }
1756
+ if (telegram) {
1757
+ await checkDupeSocial(db, "telegram", telegram);
1758
+ }
1759
+ const companies2 = [];
1760
+ for (const c of opts.company) {
1761
+ companies2.push(await getOrCreateCompanyId(db, c));
1762
+ }
1763
+ const custom = parseKV(opts.set);
1764
+ if (!runHook(config, "pre-contact-add", {
1765
+ name: opts.name,
1766
+ emails: opts.email,
1767
+ phones,
1768
+ companies: companies2,
1769
+ linkedin,
1770
+ x,
1771
+ bluesky,
1772
+ telegram,
1773
+ tags: opts.tag,
1774
+ custom_fields: custom
1775
+ })) {
1776
+ die("Error: pre-contact-add hook rejected creation");
1777
+ }
1778
+ await db.insert(contacts).values({
1779
+ id: cid,
1780
+ name: opts.name,
1781
+ emails: JSON.stringify(opts.email),
1782
+ phones: JSON.stringify(phones),
1783
+ companies: JSON.stringify(companies2),
1784
+ linkedin,
1785
+ x,
1786
+ bluesky,
1787
+ telegram,
1788
+ tags: JSON.stringify(opts.tag),
1789
+ custom_fields: JSON.stringify(custom),
1790
+ created_at: n,
1791
+ updated_at: n
1792
+ });
1793
+ const results = await db.select().from(contacts).where(eq4(contacts.id, cid));
1794
+ const row = results[0];
1795
+ await upsertSearchIndex(db, "contact", cid, await buildContactSearch(db, row));
1796
+ runHook(config, "post-contact-add", {
1797
+ id: cid,
1798
+ name: opts.name,
1799
+ emails: opts.email,
1800
+ phones,
1801
+ companies: companies2,
1802
+ linkedin,
1803
+ x,
1804
+ bluesky,
1805
+ telegram,
1806
+ tags: opts.tag,
1807
+ custom_fields: custom
1808
+ });
1809
+ console.log(cid);
1810
+ });
1811
+ cmd.command("list").option("--tag <tag>").option("--company <company>").option("--sort <field>").option("--reverse", "Reverse sort order").option("--limit <n>").option("--offset <n>").option("--filter <expr>").action(async (opts) => {
1812
+ const { db, config, fmt } = await getCtx();
1813
+ let rows = (await db.select().from(contacts)).map((c) => contactToRow(c));
1814
+ if (opts.tag) {
1815
+ rows = rows.filter((c) => c.tags?.includes(opts.tag));
1816
+ }
1817
+ if (opts.company) {
1818
+ const co = await resolveCompany(db, opts.company, config);
1819
+ if (co) {
1820
+ rows = rows.filter((c) => c.companies?.includes(co.id));
1821
+ } else {
1822
+ rows = [];
1823
+ }
1824
+ }
1825
+ if (opts.filter) {
1826
+ const f = parseFilter(opts.filter);
1827
+ rows = rows.filter((c) => applyFilter(c, f));
1828
+ }
1829
+ if (opts.sort) {
1830
+ rows.sort((a, b) => String(a[opts.sort] ?? "").localeCompare(String(b[opts.sort] ?? "")));
1831
+ }
1832
+ if (opts.reverse) {
1833
+ rows.reverse();
1834
+ }
1835
+ if (opts.offset) {
1836
+ rows = rows.slice(Number(opts.offset));
1837
+ }
1838
+ if (opts.limit) {
1839
+ rows = rows.slice(0, Number(opts.limit));
1840
+ }
1841
+ console.log(formatOutput(rows, fmt, config));
1842
+ });
1843
+ cmd.command("show").argument("<ref>").action(async (ref) => {
1844
+ const { db, config, fmt } = await getCtx();
1845
+ const c = await resolveContact(db, ref, config);
1846
+ if (!c) {
1847
+ die(`Error: contact not found: ${ref}`);
1848
+ }
1849
+ showEntity(await contactDetail(db, c, config), fmt);
1850
+ });
1851
+ cmd.command("edit").argument("<ref>").option("--name <name>").option("--add-email <e>", "", collect, []).option("--rm-email <e>", "", collect, []).option("--add-phone <p>", "", collect, []).option("--rm-phone <p>", "", collect, []).option("--add-company <c>", "", collect, []).option("--rm-company <c>", "", collect, []).option("--add-tag <t>", "", collect, []).option("--rm-tag <t>", "", collect, []).option("--linkedin <h>").option("--x <h>").option("--bluesky <h>").option("--telegram <h>").option("--set <kv>", "", collect, []).option("--unset <key>", "", collect, []).action(async (ref, opts) => {
1852
+ const { db, config } = await getCtx();
1853
+ if (opts.name) {
1854
+ opts.name = opts.name.trim();
1855
+ }
1856
+ opts.addEmail = opts.addEmail.map((e) => e.trim());
1857
+ opts.rmEmail = opts.rmEmail.map((e) => e.trim());
1858
+ opts.addPhone = opts.addPhone.map((p) => p.trim());
1859
+ opts.rmPhone = opts.rmPhone.map((p) => p.trim());
1860
+ opts.addCompany = opts.addCompany.map((c2) => c2.trim());
1861
+ opts.rmCompany = opts.rmCompany.map((c2) => c2.trim());
1862
+ opts.addTag = opts.addTag.map((t) => t.trim());
1863
+ opts.rmTag = opts.rmTag.map((t) => t.trim());
1864
+ const c = await resolveContact(db, ref.trim(), config);
1865
+ if (!c) {
1866
+ die(`Error: contact not found: ${ref}`);
1867
+ }
1868
+ let emails = safeJSON(c.emails);
1869
+ let phones = safeJSON(c.phones);
1870
+ let companies2 = safeJSON(c.companies);
1871
+ let tags = safeJSON(c.tags);
1872
+ const custom = safeJSON(c.custom_fields);
1873
+ let { name, linkedin, x, bluesky, telegram } = c;
1874
+ if (opts.name) {
1875
+ name = opts.name;
1876
+ }
1877
+ for (const e of opts.addEmail) {
1878
+ validateEmail(e);
1879
+ await checkDupeEmail(db, e, c.id);
1880
+ if (!emails.includes(e)) {
1881
+ emails.push(e);
1882
+ }
1883
+ }
1884
+ for (const e of opts.rmEmail) {
1885
+ emails = emails.filter((v) => v !== e);
1886
+ }
1887
+ for (const p of opts.addPhone) {
1888
+ const norm = normalizePhone(p, config.phone.default_country);
1889
+ if (!phones.includes(norm)) {
1890
+ await checkDupePhone(db, norm, "contacts", c.id);
1891
+ phones.push(norm);
1892
+ }
1893
+ }
1894
+ for (const p of opts.rmPhone) {
1895
+ const norm = tryNormalizePhone(p, config.phone.default_country);
1896
+ phones = norm ? phones.filter((v) => v !== norm) : phones.filter((v) => v !== p);
1897
+ }
1898
+ for (const co of opts.addCompany) {
1899
+ const coId = await getOrCreateCompanyId(db, co);
1900
+ if (!companies2.includes(coId)) {
1901
+ companies2.push(coId);
1902
+ }
1903
+ }
1904
+ for (const co of opts.rmCompany) {
1905
+ const resolved = await resolveCompany(db, co, config);
1906
+ if (resolved) {
1907
+ companies2 = companies2.filter((v) => v !== resolved.id);
1908
+ }
1909
+ }
1910
+ for (const t of opts.addTag) {
1911
+ if (!tags.includes(t)) {
1912
+ tags.push(t);
1913
+ }
1914
+ }
1915
+ for (const t of opts.rmTag) {
1916
+ tags = tags.filter((v) => v !== t);
1917
+ }
1918
+ if (opts.linkedin) {
1919
+ linkedin = normalizeSocialHandle("linkedin", opts.linkedin.trim());
1920
+ await checkDupeSocial(db, "linkedin", linkedin, c.id);
1921
+ }
1922
+ if (opts.x) {
1923
+ x = normalizeSocialHandle("x", opts.x.trim());
1924
+ await checkDupeSocial(db, "x", x, c.id);
1925
+ }
1926
+ if (opts.bluesky) {
1927
+ bluesky = normalizeSocialHandle("bluesky", opts.bluesky.trim());
1928
+ await checkDupeSocial(db, "bluesky", bluesky, c.id);
1929
+ }
1930
+ if (opts.telegram) {
1931
+ telegram = normalizeSocialHandle("telegram", opts.telegram.trim());
1932
+ await checkDupeSocial(db, "telegram", telegram, c.id);
1933
+ }
1934
+ const kvs = parseKV(opts.set);
1935
+ for (const [k, v] of Object.entries(kvs)) {
1936
+ custom[k] = v;
1937
+ }
1938
+ for (const k of opts.unset) {
1939
+ delete custom[k];
1940
+ if (k === "linkedin") {
1941
+ linkedin = null;
1942
+ }
1943
+ if (k === "x") {
1944
+ x = null;
1945
+ }
1946
+ if (k === "bluesky") {
1947
+ bluesky = null;
1948
+ }
1949
+ if (k === "telegram") {
1950
+ telegram = null;
1951
+ }
1952
+ }
1953
+ if (!runHook(config, "pre-contact-edit", {
1954
+ id: c.id,
1955
+ name,
1956
+ emails,
1957
+ phones,
1958
+ companies: companies2,
1959
+ linkedin,
1960
+ x,
1961
+ bluesky,
1962
+ telegram,
1963
+ tags,
1964
+ custom_fields: custom
1965
+ })) {
1966
+ die("Error: pre-contact-edit hook rejected edit");
1967
+ }
1968
+ await db.update(contacts).set({
1969
+ name,
1970
+ emails: JSON.stringify(emails),
1971
+ phones: JSON.stringify(phones),
1972
+ companies: JSON.stringify(companies2),
1973
+ linkedin,
1974
+ x,
1975
+ bluesky,
1976
+ telegram,
1977
+ tags: JSON.stringify(tags),
1978
+ custom_fields: JSON.stringify(custom),
1979
+ updated_at: now()
1980
+ }).where(eq4(contacts.id, c.id));
1981
+ const results = await db.select().from(contacts).where(eq4(contacts.id, c.id));
1982
+ const row = results[0];
1983
+ await upsertSearchIndex(db, "contact", c.id, await buildContactSearch(db, row));
1984
+ console.log(c.id);
1985
+ runHook(config, "post-contact-edit", {
1986
+ id: c.id,
1987
+ name,
1988
+ emails,
1989
+ phones,
1990
+ companies: companies2,
1991
+ linkedin,
1992
+ x,
1993
+ bluesky,
1994
+ telegram,
1995
+ tags,
1996
+ custom_fields: custom
1997
+ });
1998
+ });
1999
+ cmd.command("rm").argument("<ref>").option("--force", "Skip confirmation").action(async (ref, opts) => {
2000
+ const { db, config } = await getCtx();
2001
+ const c = await resolveContact(db, ref, config);
2002
+ if (!c) {
2003
+ die(`Error: contact not found: ${ref}`);
2004
+ }
2005
+ confirmOrForce(opts.force, `contact "${c.name}" (${c.id})`);
2006
+ if (!runHook(config, "pre-contact-rm", { id: c.id, name: c.name })) {
2007
+ die("Error: pre-contact-rm hook rejected deletion");
2008
+ }
2009
+ const allDeals = await db.select().from(deals);
2010
+ for (const d of allDeals) {
2011
+ const contacts2 = safeJSON(d.contacts);
2012
+ if (contacts2.includes(c.id)) {
2013
+ await db.update(deals).set({
2014
+ contacts: JSON.stringify(contacts2.filter((id) => id !== c.id))
2015
+ }).where(eq4(deals.id, d.id));
2016
+ }
2017
+ }
2018
+ await db.delete(contacts).where(eq4(contacts.id, c.id));
2019
+ await removeSearchIndex(db, c.id);
2020
+ runHook(config, "post-contact-rm", { id: c.id, name: c.name });
2021
+ });
2022
+ cmd.command("merge").argument("<id1>").argument("<id2>").action(async (id1, id2) => {
2023
+ const { db, config } = await getCtx();
2024
+ const c1 = await resolveContact(db, id1, config), c2 = await resolveContact(db, id2, config);
2025
+ if (!(c1 && c2)) {
2026
+ die("Error: one or both contacts not found");
2027
+ }
2028
+ const mergedEmails = [
2029
+ ...new Set([...safeJSON(c1.emails), ...safeJSON(c2.emails)])
2030
+ ];
2031
+ const mergedPhones = [
2032
+ ...new Set([...safeJSON(c1.phones), ...safeJSON(c2.phones)])
2033
+ ];
2034
+ const mergedCompanies = [
2035
+ ...new Set([...safeJSON(c1.companies), ...safeJSON(c2.companies)])
2036
+ ];
2037
+ const mergedTags = [
2038
+ ...new Set([...safeJSON(c1.tags), ...safeJSON(c2.tags)])
2039
+ ];
2040
+ const mergedCustom = {
2041
+ ...safeJSON(c2.custom_fields),
2042
+ ...safeJSON(c1.custom_fields)
2043
+ };
2044
+ const linkedin = c1.linkedin || c2.linkedin;
2045
+ const x = c1.x || c2.x;
2046
+ const bluesky = c1.bluesky || c2.bluesky;
2047
+ const telegram = c1.telegram || c2.telegram;
2048
+ await db.update(contacts).set({ linkedin: null, x: null, bluesky: null, telegram: null }).where(eq4(contacts.id, c2.id));
2049
+ await db.update(contacts).set({
2050
+ emails: JSON.stringify(mergedEmails),
2051
+ phones: JSON.stringify(mergedPhones),
2052
+ companies: JSON.stringify(mergedCompanies),
2053
+ tags: JSON.stringify(mergedTags),
2054
+ custom_fields: JSON.stringify(mergedCustom),
2055
+ linkedin,
2056
+ x,
2057
+ bluesky,
2058
+ telegram,
2059
+ updated_at: now()
2060
+ }).where(eq4(contacts.id, c1.id));
2061
+ const allDeals = await db.select().from(deals);
2062
+ for (const d of allDeals) {
2063
+ const contacts2 = safeJSON(d.contacts);
2064
+ if (contacts2.includes(c2.id)) {
2065
+ const updated = [
2066
+ ...new Set(contacts2.map((id) => id === c2.id ? c1.id : id))
2067
+ ];
2068
+ await db.update(deals).set({ contacts: JSON.stringify(updated) }).where(eq4(deals.id, d.id));
2069
+ }
2070
+ }
2071
+ const allActivities = await db.select().from(activities);
2072
+ for (const a of allActivities) {
2073
+ const contacts2 = safeJSON(a.contacts);
2074
+ if (contacts2.includes(c2.id)) {
2075
+ const updated = [
2076
+ ...new Set(contacts2.map((id) => id === c2.id ? c1.id : id))
2077
+ ];
2078
+ await db.update(activities).set({ contacts: JSON.stringify(updated) }).where(eq4(activities.id, a.id));
2079
+ }
2080
+ }
2081
+ await db.delete(contacts).where(eq4(contacts.id, c2.id));
2082
+ await removeSearchIndex(db, c2.id);
2083
+ const results = await db.select().from(contacts).where(eq4(contacts.id, c1.id));
2084
+ const row = results[0];
2085
+ await upsertSearchIndex(db, "contact", c1.id, await buildContactSearch(db, row));
2086
+ console.log(c1.id);
2087
+ });
2088
+ }
2089
+
2090
+ // src/commands/deal.ts
2091
+ import { eq as eq5 } from "drizzle-orm";
2092
+ function registerDealCommands(program) {
2093
+ const cmd = program.command("deal").description("Manage deals");
2094
+ cmd.command("add").requiredOption("--title <title>", "Deal title").option("--value <n>", "Deal value").option("--stage <stage>", "Pipeline stage").option("--contact <ref>", "Contact", collect, []).option("--company <ref>", "Company").option("--expected-close <date>", "Expected close date").option("--probability <n>", "Win probability 0-100").option("--tag <tag>", "Tag", collect, []).option("--set <kv>", "Custom field", collect, []).action(async (opts) => {
2095
+ const { db, config } = await getCtx();
2096
+ opts.title = opts.title.trim();
2097
+ opts.contact = opts.contact.map((c) => c.trim());
2098
+ opts.tag = opts.tag.map((t) => t.trim());
2099
+ if (opts.company) {
2100
+ opts.company = opts.company.trim();
2101
+ }
2102
+ if (opts.stage) {
2103
+ opts.stage = opts.stage.trim();
2104
+ }
2105
+ const id = makeId("dl");
2106
+ const n = now();
2107
+ if (opts.value !== undefined && Number(opts.value) < 0) {
2108
+ die("Error: value must be non-negative");
2109
+ }
2110
+ if (opts.probability !== undefined) {
2111
+ const prob = Number(opts.probability);
2112
+ if (prob < 0 || prob > 100) {
2113
+ die("Error: probability must be between 0 and 100");
2114
+ }
2115
+ }
2116
+ if (opts.expectedClose) {
2117
+ opts.expectedClose = opts.expectedClose.trim();
2118
+ const d = new Date(opts.expectedClose);
2119
+ if (Number.isNaN(d.getTime())) {
2120
+ die("Error: invalid expected-close date");
2121
+ }
2122
+ }
2123
+ const stage = opts.stage || config.pipeline.stages[0];
2124
+ if (!config.pipeline.stages.includes(stage)) {
2125
+ die(`Error: invalid stage "${stage}"`);
2126
+ }
2127
+ const contactIds = [];
2128
+ for (const ref of opts.contact) {
2129
+ const ctId = await getOrCreateContactId(db, ref, config);
2130
+ if (!contactIds.includes(ctId)) {
2131
+ contactIds.push(ctId);
2132
+ }
2133
+ }
2134
+ let companyId = null;
2135
+ if (opts.company) {
2136
+ const co = await resolveCompanyForLink(db, opts.company);
2137
+ if (co) {
2138
+ companyId = co.id;
2139
+ } else {
2140
+ if (opts.company.includes(".")) {
2141
+ die(`Error: company not found: ${opts.company}`);
2142
+ }
2143
+ companyId = await getOrCreateCompanyId(db, opts.company);
2144
+ }
2145
+ }
2146
+ const custom = parseKV(opts.set);
2147
+ const value = opts.value === undefined ? null : Number(opts.value);
2148
+ const probability = opts.probability === undefined ? null : Number(opts.probability);
2149
+ if (!runHook(config, "pre-deal-add", {
2150
+ title: opts.title,
2151
+ value,
2152
+ stage,
2153
+ contacts: contactIds,
2154
+ company: companyId,
2155
+ expected_close: opts.expectedClose || null,
2156
+ probability,
2157
+ tags: opts.tag,
2158
+ custom_fields: custom
2159
+ })) {
2160
+ die("Error: pre-deal-add hook rejected creation");
2161
+ }
2162
+ await db.insert(deals).values({
2163
+ id,
2164
+ title: opts.title,
2165
+ value,
2166
+ stage,
2167
+ contacts: JSON.stringify(contactIds),
2168
+ company: companyId,
2169
+ expected_close: opts.expectedClose || null,
2170
+ probability,
2171
+ tags: JSON.stringify(opts.tag),
2172
+ custom_fields: JSON.stringify(custom),
2173
+ created_at: n,
2174
+ updated_at: n
2175
+ });
2176
+ const results = await db.select().from(deals).where(eq5(deals.id, id));
2177
+ const row = results[0];
2178
+ await upsertSearchIndex(db, "deal", id, buildDealSearch(row));
2179
+ runHook(config, "post-deal-add", {
2180
+ id,
2181
+ title: opts.title,
2182
+ value,
2183
+ stage,
2184
+ contacts: contactIds,
2185
+ company: companyId,
2186
+ expected_close: opts.expectedClose || null,
2187
+ probability,
2188
+ tags: opts.tag,
2189
+ custom_fields: custom
2190
+ });
2191
+ console.log(id);
2192
+ });
2193
+ cmd.command("list").option("--stage <stage>").option("--min-value <n>").option("--max-value <n>").option("--contact <ref>").option("--company <ref>").option("--tag <tag>").option("--filter <expr>").option("--sort <field>").option("--reverse", "Reverse sort order").option("--limit <n>").option("--offset <n>").action(async (opts) => {
2194
+ const { db, config, fmt } = await getCtx();
2195
+ let rows = (await db.select().from(deals)).map((d) => dealToRow(d));
2196
+ if (opts.stage) {
2197
+ rows = rows.filter((d) => d.stage === opts.stage);
2198
+ }
2199
+ if (opts.minValue) {
2200
+ rows = rows.filter((d) => (d.value ?? 0) >= Number(opts.minValue));
2201
+ }
2202
+ if (opts.maxValue) {
2203
+ rows = rows.filter((d) => (d.value ?? 0) <= Number(opts.maxValue));
2204
+ }
2205
+ if (opts.contact) {
2206
+ const ct = await resolveContact(db, opts.contact, config);
2207
+ if (ct) {
2208
+ rows = rows.filter((d) => d.contacts?.includes(ct.id));
2209
+ } else {
2210
+ rows = [];
2211
+ }
2212
+ }
2213
+ if (opts.company) {
2214
+ const co = await resolveCompany(db, opts.company, config);
2215
+ if (co) {
2216
+ rows = rows.filter((d) => d.company === co.id);
2217
+ } else {
2218
+ rows = [];
2219
+ }
2220
+ }
2221
+ if (opts.tag) {
2222
+ rows = rows.filter((d) => d.tags?.includes(opts.tag));
2223
+ }
2224
+ if (opts.filter) {
2225
+ const f = parseFilter(opts.filter);
2226
+ rows = rows.filter((d) => applyFilter(d, f));
2227
+ }
2228
+ if (opts.sort) {
2229
+ rows.sort((a, b) => {
2230
+ const av = a[opts.sort], bv = b[opts.sort];
2231
+ if (typeof av === "number" && typeof bv === "number") {
2232
+ return av - bv;
2233
+ }
2234
+ return String(av ?? "").localeCompare(String(bv ?? ""));
2235
+ });
2236
+ }
2237
+ if (opts.reverse) {
2238
+ rows.reverse();
2239
+ }
2240
+ if (opts.offset) {
2241
+ rows = rows.slice(Number(opts.offset));
2242
+ }
2243
+ if (opts.limit) {
2244
+ rows = rows.slice(0, Number(opts.limit));
2245
+ }
2246
+ console.log(formatOutput(rows, fmt, config));
2247
+ });
2248
+ cmd.command("show").argument("<ref>").action(async (ref) => {
2249
+ const { db, fmt } = await getCtx();
2250
+ const d = await resolveDeal(db, ref);
2251
+ if (!d) {
2252
+ die(`Error: deal not found: ${ref}`);
2253
+ }
2254
+ showEntity(await dealDetail(db, d), fmt);
2255
+ });
2256
+ cmd.command("edit").argument("<ref>").option("--title <title>").option("--value <n>").option("--company <ref>", "Change linked company").option("--expected-close <date>", "Expected close date (YYYY-MM-DD)").option("--probability <n>", "Win probability 0-100").option("--add-contact <ref>", "", collect, []).option("--rm-contact <ref>", "", collect, []).option("--add-tag <t>", "", collect, []).option("--rm-tag <t>", "", collect, []).option("--set <kv>", "", collect, []).option("--unset <key>", "", collect, []).action(async (ref, opts) => {
2257
+ const { db, config } = await getCtx();
2258
+ if (opts.title) {
2259
+ opts.title = opts.title.trim();
2260
+ }
2261
+ if (opts.company) {
2262
+ opts.company = opts.company.trim();
2263
+ }
2264
+ opts.addContact = opts.addContact.map((c) => c.trim());
2265
+ opts.rmContact = opts.rmContact.map((c) => c.trim());
2266
+ opts.addTag = opts.addTag.map((t) => t.trim());
2267
+ opts.rmTag = opts.rmTag.map((t) => t.trim());
2268
+ const d = await resolveDeal(db, ref.trim());
2269
+ if (!d) {
2270
+ die(`Error: deal not found: ${ref}`);
2271
+ }
2272
+ const title = opts.title ?? d.title;
2273
+ const value = opts.value === undefined ? d.value : Number(opts.value);
2274
+ const expectedClose = opts.expectedClose ? opts.expectedClose.trim() : d.expected_close;
2275
+ const probability = opts.probability === undefined ? d.probability : Number(opts.probability);
2276
+ let companyId = d.company;
2277
+ if (opts.company) {
2278
+ companyId = await getOrCreateCompanyId(db, opts.company);
2279
+ }
2280
+ let contacts2 = safeJSON(d.contacts);
2281
+ let tags = safeJSON(d.tags);
2282
+ const custom = safeJSON(d.custom_fields);
2283
+ for (const r of opts.addContact) {
2284
+ const ctId = await getOrCreateContactId(db, r, config);
2285
+ if (!contacts2.includes(ctId)) {
2286
+ contacts2.push(ctId);
2287
+ }
2288
+ }
2289
+ for (const r of opts.rmContact) {
2290
+ const ct = await resolveContact(db, r, config);
2291
+ if (ct) {
2292
+ contacts2 = contacts2.filter((id) => id !== ct.id);
2293
+ }
2294
+ }
2295
+ for (const t of opts.addTag) {
2296
+ if (!tags.includes(t)) {
2297
+ tags.push(t);
2298
+ }
2299
+ }
2300
+ for (const t of opts.rmTag) {
2301
+ tags = tags.filter((v) => v !== t);
2302
+ }
2303
+ const kvs = parseKV(opts.set);
2304
+ for (const [k, v] of Object.entries(kvs)) {
2305
+ custom[k] = v;
2306
+ }
2307
+ for (const k of opts.unset) {
2308
+ delete custom[k];
2309
+ }
2310
+ if (!runHook(config, "pre-deal-edit", {
2311
+ id: d.id,
2312
+ title,
2313
+ value,
2314
+ contacts: contacts2,
2315
+ tags,
2316
+ custom_fields: custom
2317
+ })) {
2318
+ die("Error: pre-deal-edit hook rejected edit");
2319
+ }
2320
+ await db.update(deals).set({
2321
+ title,
2322
+ value,
2323
+ company: companyId,
2324
+ expected_close: expectedClose,
2325
+ probability,
2326
+ contacts: JSON.stringify(contacts2),
2327
+ tags: JSON.stringify(tags),
2328
+ custom_fields: JSON.stringify(custom),
2329
+ updated_at: now()
2330
+ }).where(eq5(deals.id, d.id));
2331
+ const results = await db.select().from(deals).where(eq5(deals.id, d.id));
2332
+ const row = results[0];
2333
+ await upsertSearchIndex(db, "deal", d.id, buildDealSearch(row));
2334
+ console.log(d.id);
2335
+ runHook(config, "post-deal-edit", {
2336
+ id: d.id,
2337
+ title,
2338
+ value,
2339
+ contacts: contacts2,
2340
+ tags,
2341
+ custom_fields: custom
2342
+ });
2343
+ });
2344
+ cmd.command("move").argument("<ref>").requiredOption("--stage <stage>", "Target stage").option("--note <text>", "Note").action(async (ref, opts) => {
2345
+ const { db, config } = await getCtx();
2346
+ opts.stage = opts.stage.trim();
2347
+ if (opts.note) {
2348
+ opts.note = opts.note.trim();
2349
+ }
2350
+ const d = await resolveDeal(db, ref);
2351
+ if (!d) {
2352
+ die(`Error: deal not found: ${ref}`);
2353
+ }
2354
+ if (!config.pipeline.stages.includes(opts.stage)) {
2355
+ die(`Error: invalid stage "${opts.stage}"`);
2356
+ }
2357
+ if (d.stage === opts.stage) {
2358
+ die(`Error: deal is already in stage "${opts.stage}"`);
2359
+ }
2360
+ const oldStage = d.stage;
2361
+ if (!runHook(config, "pre-deal-stage-change", {
2362
+ deal: d.id,
2363
+ from: oldStage,
2364
+ to: opts.stage,
2365
+ note: opts.note
2366
+ })) {
2367
+ die("Error: pre-deal-stage-change hook rejected stage move");
2368
+ }
2369
+ const n = now();
2370
+ await db.update(deals).set({ stage: opts.stage, updated_at: n }).where(eq5(deals.id, d.id));
2371
+ let body = `from ${oldStage} to ${opts.stage}`;
2372
+ if (opts.note) {
2373
+ body += ` | ${opts.note}`;
2374
+ }
2375
+ const aid = makeId("ac");
2376
+ await db.insert(activities).values({
2377
+ id: aid,
2378
+ type: "stage-change",
2379
+ body,
2380
+ deal: d.id,
2381
+ created_at: n
2382
+ });
2383
+ await upsertSearchIndex(db, "activity", aid, `stage-change ${body}`);
2384
+ console.log(d.id);
2385
+ runHook(config, "post-deal-stage-change", {
2386
+ deal: d.id,
2387
+ from: oldStage,
2388
+ to: opts.stage,
2389
+ note: opts.note
2390
+ });
2391
+ });
2392
+ cmd.command("rm").argument("<ref>").option("--force", "Skip confirmation").action(async (ref, opts) => {
2393
+ const { db, config } = await getCtx();
2394
+ const d = await resolveDeal(db, ref);
2395
+ if (!d) {
2396
+ die(`Error: deal not found: ${ref}`);
2397
+ }
2398
+ confirmOrForce(opts.force, `deal "${d.title}" (${d.id})`);
2399
+ if (!runHook(config, "pre-deal-rm", {
2400
+ id: d.id,
2401
+ title: d.title
2402
+ })) {
2403
+ die("Error: pre-deal-rm hook rejected deletion");
2404
+ }
2405
+ await db.delete(activities).where(eq5(activities.deal, d.id));
2406
+ await db.delete(deals).where(eq5(deals.id, d.id));
2407
+ await removeSearchIndex(db, d.id);
2408
+ runHook(config, "post-deal-rm", {
2409
+ id: d.id,
2410
+ title: d.title
2411
+ });
2412
+ });
2413
+ }
2414
+ function registerPipelineCommand(program) {
2415
+ program.command("pipeline").description("Pipeline summary").action(async () => {
2416
+ const { db, config, fmt } = await getCtx();
2417
+ const deals2 = await db.select().from(deals);
2418
+ const summary = config.pipeline.stages.map((stage) => ({
2419
+ stage,
2420
+ count: deals2.filter((d) => d.stage === stage).length,
2421
+ value: deals2.filter((d) => d.stage === stage).reduce((s, d) => s + (d.value || 0), 0)
2422
+ }));
2423
+ const total = {
2424
+ stage: "Total",
2425
+ count: deals2.length,
2426
+ value: deals2.reduce((s, d) => s + (d.value || 0), 0)
2427
+ };
2428
+ const data = [...summary, total];
2429
+ if (fmt === "json") {
2430
+ console.log(JSON.stringify(data, null, 2));
2431
+ } else {
2432
+ console.log(formatOutput(data, fmt, config));
2433
+ }
2434
+ });
2435
+ }
2436
+
2437
+ // src/commands/dupes.ts
2438
+ function registerDupesCommand(program) {
2439
+ program.command("dupes").description("Find likely duplicates").option("--type <type>", "Entity type (contact or company)").option("--threshold <n>", "Similarity threshold 0-1", "0.3").option("--limit <n>", "Max results").action(async (opts) => {
2440
+ const { db, fmt } = await getCtx();
2441
+ const threshold = Number(opts.threshold);
2442
+ let results = [];
2443
+ if (!opts.type || opts.type === "contact") {
2444
+ const contacts2 = await db.select().from(contacts);
2445
+ for (let i = 0;i < contacts2.length; i++) {
2446
+ for (let j = i + 1;j < contacts2.length; j++) {
2447
+ const reasons = contactDupeReasons(contacts2[i], contacts2[j]);
2448
+ const score = dupeScore(reasons);
2449
+ if (score >= threshold) {
2450
+ results.push({
2451
+ left: contactToRow(contacts2[i]),
2452
+ right: contactToRow(contacts2[j]),
2453
+ reasons,
2454
+ score
2455
+ });
2456
+ }
2457
+ }
2458
+ }
2459
+ }
2460
+ if (!opts.type || opts.type === "company") {
2461
+ const companies2 = await db.select().from(companies);
2462
+ for (let i = 0;i < companies2.length; i++) {
2463
+ for (let j = i + 1;j < companies2.length; j++) {
2464
+ const reasons = companyDupeReasons(companies2[i], companies2[j]);
2465
+ const score = dupeScore(reasons);
2466
+ if (score >= threshold) {
2467
+ results.push({
2468
+ left: companyToRow(companies2[i]),
2469
+ right: companyToRow(companies2[j]),
2470
+ reasons,
2471
+ score
2472
+ });
2473
+ }
2474
+ }
2475
+ }
2476
+ }
2477
+ results.sort((a, b) => b.score - a.score);
2478
+ if (opts.limit) {
2479
+ results = results.slice(0, Number(opts.limit));
2480
+ }
2481
+ if (fmt === "json") {
2482
+ console.log(JSON.stringify(results.map((r) => ({
2483
+ left: r.left,
2484
+ right: r.right,
2485
+ reasons: r.reasons
2486
+ })), null, 2));
2487
+ } else {
2488
+ if (results.length === 0) {
2489
+ console.log("");
2490
+ return;
2491
+ }
2492
+ const lines = results.map((r) => {
2493
+ const lName = r.left.name || r.left.title || r.left.id;
2494
+ const rName = r.right.name || r.right.title || r.right.id;
2495
+ return `${lName} <-> ${rName}: ${r.reasons.join(", ")}`;
2496
+ });
2497
+ console.log(lines.join(`
2498
+ `));
2499
+ }
2500
+ });
2501
+ }
2502
+ function contactDupeReasons(a, b) {
2503
+ const reasons = [];
2504
+ const aName = (a.name || "").toLowerCase();
2505
+ const bName = (b.name || "").toLowerCase();
2506
+ const nameDistance = levenshtein(aName, bName);
2507
+ const maxLen = Math.max(aName.length, bName.length);
2508
+ const nameSimilarity = maxLen > 0 ? 1 - nameDistance / maxLen : 0;
2509
+ if (nameSimilarity >= 0.5) {
2510
+ reasons.push("similar name");
2511
+ }
2512
+ const aEmails = safeJSON(a.emails);
2513
+ const bEmails = safeJSON(b.emails);
2514
+ for (const ae of aEmails) {
2515
+ for (const be of bEmails) {
2516
+ if (ae.toLowerCase() === be.toLowerCase()) {
2517
+ reasons.push("same email");
2518
+ }
2519
+ }
2520
+ }
2521
+ const aPhones = safeJSON(a.phones);
2522
+ const bPhones = safeJSON(b.phones);
2523
+ for (const ap of aPhones) {
2524
+ for (const bp of bPhones) {
2525
+ if (ap === bp) {
2526
+ reasons.push("same phone");
2527
+ }
2528
+ }
2529
+ }
2530
+ const aCompanies = safeJSON(a.companies);
2531
+ const bCompanies = safeJSON(b.companies);
2532
+ for (const ac of aCompanies) {
2533
+ for (const bc of bCompanies) {
2534
+ if (ac.toLowerCase() === bc.toLowerCase()) {
2535
+ reasons.push("same company");
2536
+ break;
2537
+ }
2538
+ }
2539
+ }
2540
+ const socialFields = ["linkedin", "x", "bluesky", "telegram"];
2541
+ for (const field of socialFields) {
2542
+ if (a[field] && b[field]) {
2543
+ const aVal = a[field];
2544
+ const bVal = b[field];
2545
+ const dist = levenshtein(aVal.toLowerCase(), bVal.toLowerCase());
2546
+ const ml = Math.max(aVal.length, bVal.length);
2547
+ if (ml > 0 && 1 - dist / ml >= 0.6) {
2548
+ reasons.push(`similar ${field}`);
2549
+ }
2550
+ }
2551
+ }
2552
+ if (nameSimilarity >= 0.3) {
2553
+ let found = false;
2554
+ for (const ae of aEmails) {
2555
+ if (found) {
2556
+ break;
2557
+ }
2558
+ for (const be of bEmails) {
2559
+ const aDomain = ae.split("@")[1]?.toLowerCase();
2560
+ const bDomain = be.split("@")[1]?.toLowerCase();
2561
+ if (aDomain && bDomain && aDomain === bDomain && !["gmail.com", "yahoo.com", "hotmail.com", "outlook.com"].includes(aDomain)) {
2562
+ reasons.push("shared email domain");
2563
+ found = true;
2564
+ break;
2565
+ }
2566
+ }
2567
+ }
2568
+ }
2569
+ return reasons;
2570
+ }
2571
+ function companyDupeReasons(a, b) {
2572
+ const reasons = [];
2573
+ const aName = (a.name || "").toLowerCase();
2574
+ const bName = (b.name || "").toLowerCase();
2575
+ const nameDistance = levenshtein(aName, bName);
2576
+ const maxLen = Math.max(aName.length, bName.length);
2577
+ const nameSimilarity = maxLen > 0 ? 1 - nameDistance / maxLen : 0;
2578
+ if (nameSimilarity >= 0.5) {
2579
+ reasons.push("similar name");
2580
+ }
2581
+ const aWebsites = safeJSON(a.websites);
2582
+ const bWebsites = safeJSON(b.websites);
2583
+ for (const aw of aWebsites) {
2584
+ for (const bw of bWebsites) {
2585
+ const aDomain = aw.split("/")[0];
2586
+ const bDomain = bw.split("/")[0];
2587
+ if (aDomain === bDomain) {
2588
+ reasons.push("same domain");
2589
+ }
2590
+ }
2591
+ }
2592
+ return reasons;
2593
+ }
2594
+ function dupeScore(reasons) {
2595
+ if (reasons.length === 0) {
2596
+ return 0;
2597
+ }
2598
+ let score = 0;
2599
+ for (const r of reasons) {
2600
+ if (r === "same email" || r === "same phone") {
2601
+ score += 0.5;
2602
+ } else if (r === "similar name") {
2603
+ score += 0.4;
2604
+ } else if (r === "same company") {
2605
+ score += 0.15;
2606
+ } else if (r.startsWith("similar ")) {
2607
+ score += 0.2;
2608
+ } else if (r === "same domain") {
2609
+ score += 0.2;
2610
+ } else if (r === "shared email domain") {
2611
+ score += 0.15;
2612
+ } else {
2613
+ score += 0.1;
2614
+ }
2615
+ }
2616
+ return Math.min(score, 1);
2617
+ }
2618
+
2619
+ // src/commands/fuse.ts
2620
+ import { spawn, spawnSync as spawnSync2 } from "node:child_process";
2621
+ import {
2622
+ existsSync as existsSync3,
2623
+ mkdirSync as mkdirSync4,
2624
+ readFileSync as readFileSync2,
2625
+ unlinkSync,
2626
+ writeFileSync as writeFileSync3
2627
+ } from "node:fs";
2628
+ import { homedir as homedir2, tmpdir } from "node:os";
2629
+ import { join as join3 } from "node:path";
2630
+
2631
+ // src/export-fs.ts
2632
+ import { copyFileSync, existsSync as existsSync2, mkdirSync as mkdirSync3, writeFileSync as writeFileSync2 } from "node:fs";
2633
+ import { join as join2 } from "node:path";
2634
+ import { eq as eq8 } from "drizzle-orm";
2635
+
2636
+ // src/fuse-json.ts
2637
+ import { eq as eq6 } from "drizzle-orm";
2638
+ var LLM_TXT = `# CRM Filesystem
2639
+
2640
+ This directory is a live view of a CRM database, powered by crm.cli.
2641
+ All data is JSON. Changes made here are written back to the database.
2642
+
2643
+ ## Structure
2644
+
2645
+ contacts/ → One JSON file per contact
2646
+ _by-email/ → Lookup by email address
2647
+ _by-phone/ → Lookup by E.164 phone (+12125551234.json)
2648
+ _by-linkedin/ → Lookup by LinkedIn handle
2649
+ _by-x/ → Lookup by X/Twitter handle
2650
+ _by-bluesky/ → Lookup by Bluesky handle
2651
+ _by-telegram/ → Lookup by Telegram handle
2652
+ _by-company/ → Contacts grouped by company name
2653
+ _by-tag/ → Contacts grouped by tag
2654
+
2655
+ companies/ → One JSON file per company
2656
+ _by-website/ → Lookup by website domain
2657
+ _by-phone/ → Lookup by E.164 phone
2658
+ _by-tag/ → Companies grouped by tag
2659
+
2660
+ deals/ → One JSON file per deal
2661
+ _by-stage/ → Deals grouped by pipeline stage
2662
+ _by-company/ → Deals grouped by company name
2663
+ _by-tag/ → Deals grouped by tag
2664
+
2665
+ activities/ → One JSON file per activity (note, email, call, meeting)
2666
+ _by-contact/ → Activities grouped by contact
2667
+ _by-company/ → Activities grouped by company
2668
+ _by-deal/ → Activities grouped by deal
2669
+ _by-type/ → Activities grouped by type
2670
+
2671
+ reports/ → Pre-computed analytics (pipeline, forecast, velocity, stale, won, lost, conversion)
2672
+ search/ → Read search/<query>.json to search (filename is the query)
2673
+ pipeline.json → Pipeline stage counts and values
2674
+ tags.json → All tags with usage counts
2675
+
2676
+ ## Reading data
2677
+
2678
+ Each entity file (e.g. contacts/ct_01J8Z...jane-doe.json) is self-contained JSON
2679
+ with all fields, linked entities, and recent activity. The _by-* directories contain
2680
+ copies of the same files, organized for lookup. Use ls + cat to explore.
2681
+
2682
+ ## Writing data
2683
+
2684
+ Write a JSON file to a top-level directory to create or update an entity:
2685
+
2686
+ echo '{"name":"Jane Doe","emails":["jane@acme.com"]}' > contacts/new.json
2687
+
2688
+ The filename doesn't matter for writes — the CRM assigns an ID and renames the file.
2689
+ To update, write to the existing filename. Fields you omit are left unchanged.
2690
+
2691
+ ## Search
2692
+
2693
+ Read a file in search/ where the filename is your query:
2694
+
2695
+ cat search/jane.json → contacts/companies/deals matching "jane"
2696
+ cat search/fintech-cto.json → results matching "fintech-cto"
2697
+
2698
+ ## Phones
2699
+
2700
+ Phones are stored in E.164 format (+12125551234). The _by-phone directories use E.164
2701
+ filenames. When writing, any common format is accepted and normalized automatically.
2702
+
2703
+ ## Tips for agents
2704
+
2705
+ - Start with \`ls\` at the root to see what's available
2706
+ - Use _by-email, _by-phone, _by-linkedin for fast lookups instead of scanning all files
2707
+ - Read pipeline.json for a quick overview of deal flow
2708
+ - Read reports/ for pre-computed analytics — no need to calculate from raw data
2709
+ - All JSON files are self-contained — no need to join across files
2710
+ - The search/ directory accepts natural language queries
2711
+ `;
2712
+ function slugify(name) {
2713
+ return (name || "unknown").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
2714
+ }
2715
+ function contactFilename(c) {
2716
+ return `${c.id}...${slugify(c.name || "")}.json`;
2717
+ }
2718
+ function companyFilename(co) {
2719
+ return `${co.id}...${slugify(co.name || "")}.json`;
2720
+ }
2721
+ function dealFilename(d) {
2722
+ return `${d.id}...${slugify(d.title || "")}.json`;
2723
+ }
2724
+ function activityFilename(a) {
2725
+ const dateStr = (a.created_at || "").slice(0, 10);
2726
+ return `${a.id}...${a.type || "unknown"}-${dateStr}.json`;
2727
+ }
2728
+ async function buildContactJSON(db, c, config) {
2729
+ const emails = safeJSON(c.emails);
2730
+ const phones = safeJSON(c.phones);
2731
+ const companyIds = safeJSON(c.companies);
2732
+ const tags = safeJSON(c.tags);
2733
+ const customFields = safeJSON(c.custom_fields) || {};
2734
+ const allCompanies = await db.select().from(companies);
2735
+ const linkedCompanies = companyIds.map((id) => {
2736
+ const co = allCompanies.find((x) => x.id === id);
2737
+ return co ? { id: co.id, name: co.name } : { id };
2738
+ }).filter(Boolean);
2739
+ const allDeals = await db.select().from(deals);
2740
+ const linkedDeals = allDeals.filter((d) => {
2741
+ const contacts2 = safeJSON(d.contacts);
2742
+ return contacts2.includes(c.id);
2743
+ }).map((d) => ({ id: d.id, title: d.title, stage: d.stage, value: d.value }));
2744
+ const activities2 = await db.select().from(activities);
2745
+ const contactActivities = activities2.filter((a) => {
2746
+ const contacts2 = safeJSON(a.contacts);
2747
+ return contacts2.includes(c.id);
2748
+ }).sort((a, b) => (b.created_at || "").localeCompare(a.created_at || "")).slice(0, config.mount.max_recent_activity).map((a) => ({
2749
+ id: a.id,
2750
+ type: a.type,
2751
+ note: a.body,
2752
+ created_at: a.created_at
2753
+ }));
2754
+ return {
2755
+ id: c.id,
2756
+ name: c.name,
2757
+ emails,
2758
+ phones,
2759
+ companies: linkedCompanies,
2760
+ linkedin: c.linkedin,
2761
+ x: c.x,
2762
+ bluesky: c.bluesky,
2763
+ telegram: c.telegram,
2764
+ tags,
2765
+ custom_fields: Object.keys(customFields).length > 0 ? customFields : undefined,
2766
+ deals: linkedDeals,
2767
+ recent_activity: contactActivities,
2768
+ created_at: c.created_at,
2769
+ updated_at: c.updated_at
2770
+ };
2771
+ }
2772
+ async function buildCompanyJSON(db, co) {
2773
+ const websites = safeJSON(co.websites);
2774
+ const phones = safeJSON(co.phones);
2775
+ const tags = safeJSON(co.tags);
2776
+ const customFields = safeJSON(co.custom_fields) || {};
2777
+ const allContacts = await db.select().from(contacts);
2778
+ const linkedContacts = allContacts.filter((c) => {
2779
+ const companies2 = safeJSON(c.companies);
2780
+ return companies2.includes(co.id);
2781
+ }).map((c) => ({ id: c.id, name: c.name }));
2782
+ const allDeals = await db.select().from(deals);
2783
+ const linkedDeals = allDeals.filter((d) => d.company === co.id).map((d) => ({ id: d.id, title: d.title, stage: d.stage, value: d.value }));
2784
+ return {
2785
+ id: co.id,
2786
+ name: co.name,
2787
+ websites,
2788
+ phones,
2789
+ tags,
2790
+ custom_fields: Object.keys(customFields).length > 0 ? customFields : undefined,
2791
+ contacts: linkedContacts,
2792
+ deals: linkedDeals,
2793
+ created_at: co.created_at,
2794
+ updated_at: co.updated_at
2795
+ };
2796
+ }
2797
+ async function buildDealJSON(db, d) {
2798
+ const contactIds = safeJSON(d.contacts);
2799
+ const tags = safeJSON(d.tags);
2800
+ const customFields = safeJSON(d.custom_fields) || {};
2801
+ const allContacts = await db.select().from(contacts);
2802
+ const linkedContacts = contactIds.map((id) => {
2803
+ const c = allContacts.find((x) => x.id === id);
2804
+ return c ? { id: c.id, name: c.name } : { id };
2805
+ }).filter(Boolean);
2806
+ let companyObj;
2807
+ if (d.company) {
2808
+ const results = await db.select().from(companies).where(eq6(companies.id, d.company));
2809
+ if (results[0]) {
2810
+ companyObj = { id: results[0].id, name: results[0].name };
2811
+ }
2812
+ }
2813
+ const activities2 = await db.select().from(activities);
2814
+ const stageChanges = activities2.filter((a) => a.deal === d.id && a.type === "stage-change").sort((a, b) => (a.created_at || "").localeCompare(b.created_at || "")).map((a) => ({ body: a.body, created_at: a.created_at }));
2815
+ const firstChange = stageChanges[0];
2816
+ let initialStage = d.stage;
2817
+ if (firstChange?.body) {
2818
+ const match = firstChange.body.match(/from (\S+) to/);
2819
+ if (match) {
2820
+ initialStage = match[1];
2821
+ }
2822
+ }
2823
+ const stageHistory = [
2824
+ { stage: initialStage, at: d.created_at },
2825
+ ...stageChanges.map((sc) => {
2826
+ const match = sc.body?.match(/to (\S+)/);
2827
+ return { stage: match ? match[1] : "", at: sc.created_at };
2828
+ })
2829
+ ];
2830
+ return {
2831
+ id: d.id,
2832
+ title: d.title,
2833
+ value: d.value,
2834
+ stage: d.stage,
2835
+ contacts: linkedContacts,
2836
+ company: companyObj,
2837
+ expected_close: d.expected_close,
2838
+ probability: d.probability,
2839
+ tags,
2840
+ custom_fields: Object.keys(customFields).length > 0 ? customFields : undefined,
2841
+ stage_history: stageHistory,
2842
+ created_at: d.created_at,
2843
+ updated_at: d.updated_at
2844
+ };
2845
+ }
2846
+ function buildActivityJSON(a) {
2847
+ const customFields = safeJSON(a.custom_fields) || {};
2848
+ return {
2849
+ id: a.id,
2850
+ type: a.type,
2851
+ body: a.body,
2852
+ contacts: safeJSON(a.contacts),
2853
+ company: a.company,
2854
+ deal: a.deal,
2855
+ custom_fields: Object.keys(customFields).length > 0 ? customFields : undefined,
2856
+ created_at: a.created_at
2857
+ };
2858
+ }
2859
+
2860
+ // src/reports.ts
2861
+ import { eq as eq7, sql as sql3 } from "drizzle-orm";
2862
+ function computePipeline(deals2, stages) {
2863
+ return stages.map((stage) => ({
2864
+ stage,
2865
+ count: deals2.filter((d) => d.stage === stage).length,
2866
+ value: deals2.filter((d) => d.stage === stage).reduce((s, d) => s + (d.value || 0), 0)
2867
+ }));
2868
+ }
2869
+ async function computeStale(db, config, days = 30) {
2870
+ const cutoff = new Date(Date.now() - days * 86400000).toISOString();
2871
+ const terminal = new Set([
2872
+ config.pipeline.won_stage,
2873
+ config.pipeline.lost_stage
2874
+ ]);
2875
+ const results = [];
2876
+ const contacts2 = await db.select().from(contacts);
2877
+ const allActivities = await db.select().from(activities);
2878
+ for (const c of contacts2) {
2879
+ const contactActivities = allActivities.filter((a) => {
2880
+ const linked = JSON.parse(a.contacts || "[]");
2881
+ return linked.includes(c.id);
2882
+ });
2883
+ const last = contactActivities.reduce((max, a) => max && max > a.created_at ? max : a.created_at, null);
2884
+ if (!last || last < cutoff) {
2885
+ results.push({
2886
+ type: "contact",
2887
+ id: c.id,
2888
+ name: c.name,
2889
+ last_activity: last
2890
+ });
2891
+ }
2892
+ }
2893
+ const deals2 = await db.select().from(deals);
2894
+ for (const d of deals2) {
2895
+ if (terminal.has(d.stage)) {
2896
+ continue;
2897
+ }
2898
+ const dealActivities = allActivities.filter((a) => a.deal === d.id);
2899
+ const last = dealActivities.reduce((max, a) => max && max > a.created_at ? max : a.created_at, null);
2900
+ const lastTouch = last || d.created_at;
2901
+ if (lastTouch < cutoff) {
2902
+ results.push({
2903
+ type: "deal",
2904
+ id: d.id,
2905
+ title: d.title,
2906
+ last_activity: last
2907
+ });
2908
+ }
2909
+ }
2910
+ return results;
2911
+ }
2912
+ async function computeConversion(db, stages, since) {
2913
+ let activities2 = await db.select().from(activities).where(eq7(activities.type, "stage-change"));
2914
+ if (since) {
2915
+ activities2 = activities2.filter((a) => a.created_at >= since);
2916
+ }
2917
+ const stageEntries = {};
2918
+ const stageExits = {};
2919
+ for (const s of stages) {
2920
+ stageEntries[s] = new Set;
2921
+ stageExits[s] = new Set;
2922
+ }
2923
+ const deals2 = await db.select().from(deals);
2924
+ for (const d of deals2) {
2925
+ const dealActivities = activities2.filter((a) => a.deal === d.id);
2926
+ if (dealActivities.length === 0) {
2927
+ stageEntries[d.stage]?.add(d.id);
2928
+ } else {
2929
+ const first = dealActivities[0];
2930
+ const m = first.body.match(/from (\S+) to/);
2931
+ const initialStage = m ? m[1] : d.stage;
2932
+ if (stageEntries[initialStage]) {
2933
+ stageEntries[initialStage].add(d.id);
2934
+ }
2935
+ for (const a of dealActivities) {
2936
+ const from = a.body.match(/from (\S+) to/)?.[1];
2937
+ const to = a.body.match(/to (\S+)/)?.[1];
2938
+ if (from && stageExits[from]) {
2939
+ stageExits[from].add(d.id);
2940
+ }
2941
+ if (to && stageEntries[to]) {
2942
+ stageEntries[to].add(d.id);
2943
+ }
2944
+ }
2945
+ }
2946
+ }
2947
+ return stages.map((stage) => ({
2948
+ stage,
2949
+ entered: stageEntries[stage].size,
2950
+ advanced: stageExits[stage].size,
2951
+ rate: stageEntries[stage].size > 0 ? `${Math.round(stageExits[stage].size / stageEntries[stage].size * 100)}%` : "0%"
2952
+ }));
2953
+ }
2954
+ function formatDuration(ms) {
2955
+ if (ms === 0) {
2956
+ return "0s";
2957
+ }
2958
+ const secs = Math.floor(ms / 1000);
2959
+ const mins = Math.floor(secs / 60);
2960
+ const hours = Math.floor(mins / 60);
2961
+ const days = Math.floor(hours / 24);
2962
+ if (days > 0) {
2963
+ return `${days}d ${hours % 24}h`;
2964
+ }
2965
+ if (hours > 0) {
2966
+ return `${hours}h ${mins % 60}m`;
2967
+ }
2968
+ if (mins > 0) {
2969
+ return `${mins}m ${secs % 60}s`;
2970
+ }
2971
+ return `${secs}s`;
2972
+ }
2973
+ async function computeVelocity(db, stages, wonStage) {
2974
+ const activities2 = await db.select().from(activities).where(eq7(activities.type, "stage-change")).orderBy(activities.created_at);
2975
+ const stageTimes = {};
2976
+ for (const s of stages) {
2977
+ stageTimes[s] = [];
2978
+ }
2979
+ const dealIds = [...new Set(activities2.map((a) => a.deal).filter(Boolean))];
2980
+ for (const did of dealIds) {
2981
+ if (!did) {
2982
+ continue;
2983
+ }
2984
+ const dealActs = activities2.filter((a) => a.deal === did);
2985
+ const dealResults = await db.select().from(deals).where(eq7(deals.id, did));
2986
+ const deal = dealResults[0];
2987
+ if (!deal) {
2988
+ continue;
2989
+ }
2990
+ if (wonStage && deal.stage !== wonStage) {
2991
+ continue;
2992
+ }
2993
+ let prevTime = new Date(deal.created_at).getTime();
2994
+ const first = dealActs[0];
2995
+ const initialStage = first.body.match(/from (\S+) to/)?.[1];
2996
+ if (initialStage && stageTimes[initialStage]) {
2997
+ const duration = new Date(first.created_at).getTime() - prevTime;
2998
+ stageTimes[initialStage].push(duration);
2999
+ prevTime = new Date(first.created_at).getTime();
3000
+ }
3001
+ for (let i = 1;i < dealActs.length; i++) {
3002
+ const from = dealActs[i].body.match(/from (\S+) to/)?.[1];
3003
+ if (from && stageTimes[from]) {
3004
+ const duration = new Date(dealActs[i].created_at).getTime() - new Date(dealActs[i - 1].created_at).getTime();
3005
+ stageTimes[from].push(duration);
3006
+ }
3007
+ }
3008
+ }
3009
+ return stages.map((stage) => {
3010
+ const times = stageTimes[stage];
3011
+ const avg = times.length > 0 ? times.reduce((a, b) => a + b, 0) / times.length : 0;
3012
+ return {
3013
+ stage,
3014
+ avg_ms: Math.round(avg),
3015
+ deals: times.length,
3016
+ avg_display: formatDuration(avg)
3017
+ };
3018
+ });
3019
+ }
3020
+ async function computeForecast(db, config) {
3021
+ const terminal = new Set([
3022
+ config.pipeline.won_stage,
3023
+ config.pipeline.lost_stage
3024
+ ]);
3025
+ const deals2 = await db.select().from(deals);
3026
+ const open = deals2.filter((d) => !terminal.has(d.stage));
3027
+ return open.map((d) => ({
3028
+ id: d.id,
3029
+ title: d.title,
3030
+ value: d.value || 0,
3031
+ probability: d.probability ?? 100,
3032
+ weighted: Math.round((d.value || 0) * (d.probability ?? 100) / 100),
3033
+ expected_close: d.expected_close,
3034
+ stage: d.stage
3035
+ }));
3036
+ }
3037
+ async function computeWon(db, config) {
3038
+ const wonStage = config.pipeline.won_stage;
3039
+ const deals2 = await db.select().from(deals).where(eq7(deals.stage, wonStage));
3040
+ return Promise.all(deals2.map(async (d) => {
3041
+ const row = dealToRow(d);
3042
+ row.notes = await extractStageNotes(db, d.id, wonStage);
3043
+ return row;
3044
+ }));
3045
+ }
3046
+ async function computeLost(db, config) {
3047
+ const lostStage = config.pipeline.lost_stage;
3048
+ const deals2 = await db.select().from(deals).where(eq7(deals.stage, lostStage));
3049
+ return Promise.all(deals2.map(async (d) => {
3050
+ const row = dealToRow(d);
3051
+ row.notes = await extractStageNotes(db, d.id, lostStage);
3052
+ return row;
3053
+ }));
3054
+ }
3055
+ async function extractStageNotes(db, dealId, stage) {
3056
+ const actResults = await db.all(sql3`SELECT body FROM activities WHERE deal = ${dealId} AND type = 'stage-change' AND body LIKE ${`%${stage}%`}`);
3057
+ const act = actResults[0];
3058
+ return act?.body?.split("|").slice(1).map((s) => s.trim()).join(", ") || "";
3059
+ }
3060
+
3061
+ // src/export-fs.ts
3062
+ function writeJSON(filePath, data) {
3063
+ writeFileSync2(filePath, JSON.stringify(data, null, 2));
3064
+ }
3065
+ function ensureDir(dir) {
3066
+ if (!existsSync2(dir)) {
3067
+ mkdirSync3(dir, { recursive: true });
3068
+ }
3069
+ }
3070
+ async function generateFS(db, config, outDir) {
3071
+ ensureDir(outDir);
3072
+ ensureDir(join2(outDir, "contacts"));
3073
+ ensureDir(join2(outDir, "contacts", "_by-email"));
3074
+ ensureDir(join2(outDir, "contacts", "_by-phone"));
3075
+ ensureDir(join2(outDir, "contacts", "_by-linkedin"));
3076
+ ensureDir(join2(outDir, "contacts", "_by-x"));
3077
+ ensureDir(join2(outDir, "contacts", "_by-bluesky"));
3078
+ ensureDir(join2(outDir, "contacts", "_by-telegram"));
3079
+ ensureDir(join2(outDir, "contacts", "_by-company"));
3080
+ ensureDir(join2(outDir, "contacts", "_by-tag"));
3081
+ ensureDir(join2(outDir, "companies"));
3082
+ ensureDir(join2(outDir, "companies", "_by-website"));
3083
+ ensureDir(join2(outDir, "companies", "_by-phone"));
3084
+ ensureDir(join2(outDir, "companies", "_by-tag"));
3085
+ ensureDir(join2(outDir, "deals"));
3086
+ ensureDir(join2(outDir, "deals", "_by-stage"));
3087
+ for (const stage of config.pipeline.stages) {
3088
+ ensureDir(join2(outDir, "deals", "_by-stage", stage));
3089
+ }
3090
+ ensureDir(join2(outDir, "deals", "_by-company"));
3091
+ ensureDir(join2(outDir, "deals", "_by-tag"));
3092
+ ensureDir(join2(outDir, "activities"));
3093
+ ensureDir(join2(outDir, "activities", "_by-contact"));
3094
+ ensureDir(join2(outDir, "activities", "_by-company"));
3095
+ ensureDir(join2(outDir, "activities", "_by-deal"));
3096
+ ensureDir(join2(outDir, "activities", "_by-type"));
3097
+ ensureDir(join2(outDir, "reports"));
3098
+ ensureDir(join2(outDir, "search"));
3099
+ writeFileSync2(join2(outDir, "llm.txt"), LLM_TXT);
3100
+ const companies2 = await db.select().from(companies);
3101
+ const contacts2 = await db.select().from(contacts);
3102
+ for (const c of contacts2) {
3103
+ const data = await buildContactJSON(db, c, config);
3104
+ const filename = contactFilename(c);
3105
+ const filePath = join2(outDir, "contacts", filename);
3106
+ writeJSON(filePath, data);
3107
+ const emails = safeJSON(c.emails);
3108
+ for (const email of emails) {
3109
+ copyFileSync(filePath, join2(outDir, "contacts", "_by-email", `${email}.json`));
3110
+ }
3111
+ const phones = safeJSON(c.phones);
3112
+ for (const phone of phones) {
3113
+ copyFileSync(filePath, join2(outDir, "contacts", "_by-phone", `${phone}.json`));
3114
+ }
3115
+ if (c.linkedin) {
3116
+ copyFileSync(filePath, join2(outDir, "contacts", "_by-linkedin", `${c.linkedin}.json`));
3117
+ }
3118
+ if (c.x) {
3119
+ copyFileSync(filePath, join2(outDir, "contacts", "_by-x", `${c.x}.json`));
3120
+ }
3121
+ if (c.bluesky) {
3122
+ copyFileSync(filePath, join2(outDir, "contacts", "_by-bluesky", `${c.bluesky}.json`));
3123
+ }
3124
+ if (c.telegram) {
3125
+ copyFileSync(filePath, join2(outDir, "contacts", "_by-telegram", `${c.telegram}.json`));
3126
+ }
3127
+ const companyIds = safeJSON(c.companies);
3128
+ for (const compId of companyIds) {
3129
+ const compRecord = companies2.find((co) => co.id === compId);
3130
+ const compSlug = slugify(compRecord?.name || compId);
3131
+ ensureDir(join2(outDir, "contacts", "_by-company", compSlug));
3132
+ copyFileSync(filePath, join2(outDir, "contacts", "_by-company", compSlug, filename));
3133
+ }
3134
+ const tags = safeJSON(c.tags);
3135
+ for (const tag of tags) {
3136
+ ensureDir(join2(outDir, "contacts", "_by-tag", tag));
3137
+ copyFileSync(filePath, join2(outDir, "contacts", "_by-tag", tag, filename));
3138
+ }
3139
+ }
3140
+ for (const co of companies2) {
3141
+ const data = await buildCompanyJSON(db, co);
3142
+ const filename = companyFilename(co);
3143
+ const filePath = join2(outDir, "companies", filename);
3144
+ writeJSON(filePath, data);
3145
+ const websites = safeJSON(co.websites);
3146
+ for (const website of websites) {
3147
+ copyFileSync(filePath, join2(outDir, "companies", "_by-website", `${website}.json`));
3148
+ }
3149
+ const phones = safeJSON(co.phones);
3150
+ for (const phone of phones) {
3151
+ copyFileSync(filePath, join2(outDir, "companies", "_by-phone", `${phone}.json`));
3152
+ }
3153
+ const tags = safeJSON(co.tags);
3154
+ for (const tag of tags) {
3155
+ ensureDir(join2(outDir, "companies", "_by-tag", tag));
3156
+ copyFileSync(filePath, join2(outDir, "companies", "_by-tag", tag, filename));
3157
+ }
3158
+ }
3159
+ const deals2 = await db.select().from(deals);
3160
+ for (const d of deals2) {
3161
+ const data = await buildDealJSON(db, d);
3162
+ const filename = dealFilename(d);
3163
+ const filePath = join2(outDir, "deals", filename);
3164
+ writeJSON(filePath, data);
3165
+ if (d.stage) {
3166
+ const stageDir = join2(outDir, "deals", "_by-stage", d.stage);
3167
+ ensureDir(stageDir);
3168
+ copyFileSync(filePath, join2(stageDir, filename));
3169
+ }
3170
+ if (d.company) {
3171
+ const companyResults = await db.select().from(companies).where(eq8(companies.id, d.company));
3172
+ if (companyResults[0]) {
3173
+ const compSlug = slugify(companyResults[0].name || "");
3174
+ ensureDir(join2(outDir, "deals", "_by-company", compSlug));
3175
+ copyFileSync(filePath, join2(outDir, "deals", "_by-company", compSlug, filename));
3176
+ }
3177
+ }
3178
+ const tags = safeJSON(d.tags);
3179
+ for (const tag of tags) {
3180
+ ensureDir(join2(outDir, "deals", "_by-tag", tag));
3181
+ copyFileSync(filePath, join2(outDir, "deals", "_by-tag", tag, filename));
3182
+ }
3183
+ }
3184
+ const activities2 = await db.select().from(activities);
3185
+ for (const a of activities2) {
3186
+ const data = buildActivityJSON(a);
3187
+ const filename = activityFilename(a);
3188
+ const filePath = join2(outDir, "activities", filename);
3189
+ writeJSON(filePath, data);
3190
+ const actContacts = safeJSON(a.contacts);
3191
+ for (const contactId of actContacts) {
3192
+ const contactResults = await db.select().from(contacts).where(eq8(contacts.id, contactId));
3193
+ if (contactResults[0]) {
3194
+ const contactSlug = `${contactId}...${slugify(contactResults[0].name || "")}`;
3195
+ ensureDir(join2(outDir, "activities", "_by-contact", contactSlug));
3196
+ copyFileSync(filePath, join2(outDir, "activities", "_by-contact", contactSlug, filename));
3197
+ }
3198
+ }
3199
+ if (a.company) {
3200
+ const companyResults = await db.select().from(companies).where(eq8(companies.id, a.company));
3201
+ if (companyResults[0]) {
3202
+ const compSlug = slugify(companyResults[0].name || "");
3203
+ ensureDir(join2(outDir, "activities", "_by-company", compSlug));
3204
+ copyFileSync(filePath, join2(outDir, "activities", "_by-company", compSlug, filename));
3205
+ }
3206
+ }
3207
+ if (a.deal) {
3208
+ ensureDir(join2(outDir, "activities", "_by-deal", a.deal));
3209
+ copyFileSync(filePath, join2(outDir, "activities", "_by-deal", a.deal, filename));
3210
+ }
3211
+ if (a.type) {
3212
+ ensureDir(join2(outDir, "activities", "_by-type", a.type));
3213
+ copyFileSync(filePath, join2(outDir, "activities", "_by-type", a.type, filename));
3214
+ }
3215
+ }
3216
+ const pipelineData = config.pipeline.stages.map((stage) => ({
3217
+ stage,
3218
+ count: deals2.filter((d) => d.stage === stage).length,
3219
+ value: deals2.filter((d) => d.stage === stage).reduce((s, d) => s + (d.value || 0), 0)
3220
+ }));
3221
+ writeJSON(join2(outDir, "pipeline.json"), pipelineData);
3222
+ const tagCounts = {};
3223
+ for (const c of contacts2) {
3224
+ for (const t of safeJSON(c.tags)) {
3225
+ tagCounts[t] = (tagCounts[t] || 0) + 1;
3226
+ }
3227
+ }
3228
+ for (const co of companies2) {
3229
+ for (const t of safeJSON(co.tags)) {
3230
+ tagCounts[t] = (tagCounts[t] || 0) + 1;
3231
+ }
3232
+ }
3233
+ for (const d of deals2) {
3234
+ for (const t of safeJSON(d.tags)) {
3235
+ tagCounts[t] = (tagCounts[t] || 0) + 1;
3236
+ }
3237
+ }
3238
+ const tagsData = Object.entries(tagCounts).map(([tag, count]) => ({
3239
+ tag,
3240
+ count
3241
+ }));
3242
+ writeJSON(join2(outDir, "tags.json"), tagsData);
3243
+ writeJSON(join2(outDir, "reports", "pipeline.json"), pipelineData);
3244
+ writeJSON(join2(outDir, "reports", "stale.json"), await computeStale(db, config));
3245
+ writeJSON(join2(outDir, "reports", "forecast.json"), await computeForecast(db, config));
3246
+ writeJSON(join2(outDir, "reports", "conversion.json"), await computeConversion(db, config.pipeline.stages));
3247
+ writeJSON(join2(outDir, "reports", "velocity.json"), await computeVelocity(db, config.pipeline.stages));
3248
+ writeJSON(join2(outDir, "reports", "won.json"), await computeWon(db, config));
3249
+ writeJSON(join2(outDir, "reports", "lost.json"), await computeLost(db, config));
3250
+ }
3251
+
3252
+ // src/commands/fuse.ts
3253
+ function ensureDir2(dir) {
3254
+ if (!existsSync3(dir)) {
3255
+ mkdirSync4(dir, { recursive: true });
3256
+ }
3257
+ }
3258
+ async function mountDarwin(mp, config, _opts) {
3259
+ const nfsHelperPath = join3(homedir2(), ".crm", "bin", "crm-nfs");
3260
+ if (!existsSync3(nfsHelperPath)) {
3261
+ const nfsSrcDir = join3(import.meta.dir, "..", "nfs-server");
3262
+ if (!existsSync3(join3(nfsSrcDir, "Cargo.toml"))) {
3263
+ die(`Error: NFS server source not found at ${nfsSrcDir}`);
3264
+ }
3265
+ ensureDir2(join3(homedir2(), ".crm", "bin"));
3266
+ const cargoPath = spawnSync2("which", ["cargo"], { stdio: ["pipe", "pipe", "pipe"] }).stdout?.toString().trim() || join3(homedir2(), ".cargo", "bin", "cargo");
3267
+ if (!existsSync3(cargoPath)) {
3268
+ die("Error: Rust not found. Install with: curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh");
3269
+ }
3270
+ console.log("Compiling NFS server (first time only)...");
3271
+ const compile = spawnSync2(cargoPath, ["build", "--release", "--manifest-path", join3(nfsSrcDir, "Cargo.toml")], { stdio: ["pipe", "pipe", "pipe"] });
3272
+ if (compile.status !== 0) {
3273
+ die(`Error: Failed to compile NFS server.
3274
+ ${compile.stderr?.toString() || ""}`);
3275
+ }
3276
+ const built = join3(nfsSrcDir, "target", "release", "crm-nfs");
3277
+ spawnSync2("sh", [
3278
+ "-c",
3279
+ `cat "${built}" > "${nfsHelperPath}" && chmod +x "${nfsHelperPath}"`
3280
+ ]);
3281
+ }
3282
+ const socketPath = join3(tmpdir(), `crm-fuse-${slugify(mp)}.sock`);
3283
+ const daemonPath = join3(import.meta.dir, "..", "fuse-daemon.ts");
3284
+ const daemonProc = spawn("bun", [
3285
+ "run",
3286
+ daemonPath,
3287
+ socketPath,
3288
+ config.database.path,
3289
+ ...config.pipeline.stages
3290
+ ], { stdio: "ignore", detached: true });
3291
+ daemonProc.unref();
3292
+ const deadline = Date.now() + 5000;
3293
+ while (Date.now() < deadline) {
3294
+ if (existsSync3(socketPath)) {
3295
+ break;
3296
+ }
3297
+ await new Promise((resolve2) => setTimeout(resolve2, 50));
3298
+ }
3299
+ if (!existsSync3(socketPath)) {
3300
+ daemonProc.kill();
3301
+ die("Error: FUSE daemon failed to start.");
3302
+ }
3303
+ const nfsProc = spawn(nfsHelperPath, [socketPath, "0"], {
3304
+ stdio: ["pipe", "pipe", "pipe"],
3305
+ detached: true
3306
+ });
3307
+ const port = await new Promise((resolve2, reject) => {
3308
+ let buf = "";
3309
+ const timeout = setTimeout(() => reject(new Error("NFS server did not report port")), 5000);
3310
+ nfsProc.stdout?.on("data", (chunk) => {
3311
+ buf += chunk.toString();
3312
+ const nl = buf.indexOf(`
3313
+ `);
3314
+ if (nl !== -1) {
3315
+ clearTimeout(timeout);
3316
+ resolve2(Number(buf.slice(0, nl).trim()));
3317
+ }
3318
+ });
3319
+ nfsProc.on("exit", (code) => {
3320
+ clearTimeout(timeout);
3321
+ reject(new Error(`NFS server exited with code ${code}`));
3322
+ });
3323
+ }).catch((err) => {
3324
+ daemonProc.kill();
3325
+ try {
3326
+ nfsProc.kill();
3327
+ } catch {}
3328
+ die(`Error: ${err.message}`);
3329
+ return 0;
3330
+ });
3331
+ nfsProc.stdout?.destroy();
3332
+ nfsProc.stderr?.destroy();
3333
+ nfsProc.stdin?.destroy();
3334
+ nfsProc.unref();
3335
+ const connDeadline = Date.now() + 3000;
3336
+ while (Date.now() < connDeadline) {
3337
+ try {
3338
+ const net = await import("node:net");
3339
+ const ok = await new Promise((resolve2) => {
3340
+ const sock = net.createConnection({ host: "127.0.0.1", port }, () => {
3341
+ sock.destroy();
3342
+ resolve2(true);
3343
+ });
3344
+ sock.on("error", () => resolve2(false));
3345
+ });
3346
+ if (ok) {
3347
+ break;
3348
+ }
3349
+ } catch {}
3350
+ await new Promise((r) => setTimeout(r, 50));
3351
+ }
3352
+ const mountResult = spawnSync2("/sbin/mount_nfs", [
3353
+ "-o",
3354
+ `locallocks,vers=3,tcp,port=${port},mountport=${port},soft,intr,timeo=10,retrans=3,noac`,
3355
+ "127.0.0.1:/",
3356
+ mp
3357
+ ], { stdio: ["pipe", "pipe", "pipe"] });
3358
+ if (mountResult.status !== 0) {
3359
+ daemonProc.kill();
3360
+ try {
3361
+ nfsProc.kill();
3362
+ } catch {}
3363
+ die(`Error: NFS mount failed: ${mountResult.stderr?.toString() || "unknown error"}`);
3364
+ }
3365
+ const pidFile = join3(tmpdir(), `crm-mount-${slugify(mp)}.pid`);
3366
+ writeFileSync3(pidFile, `${nfsProc.pid}
3367
+ ${daemonProc.pid}`);
3368
+ console.log(`Mounted at ${mp} (NFS port ${port}, PID ${nfsProc.pid})`);
3369
+ }
3370
+ async function mountLinux(mp, config, opts) {
3371
+ const helperPath = join3(homedir2(), ".crm", "bin", "crm-fuse");
3372
+ if (!existsSync3(helperPath)) {
3373
+ const srcPath = join3(import.meta.dir, "..", "fuse-helper.c");
3374
+ if (!existsSync3(srcPath)) {
3375
+ die("Error: FUSE helper not found. Install FUSE dependencies and rebuild, or use `crm export-fs` instead.");
3376
+ }
3377
+ ensureDir2(join3(homedir2(), ".crm", "bin"));
3378
+ const fuseFlags = (() => {
3379
+ const pc3 = spawnSync2("pkg-config", ["--cflags", "--libs", "fuse3"]);
3380
+ if (pc3.status === 0 && pc3.stdout) {
3381
+ return pc3.stdout.toString().trim().split(/\s+/);
3382
+ }
3383
+ return ["-lfuse3", "-lpthread"];
3384
+ })();
3385
+ const compile = spawnSync2("gcc", ["-o", helperPath, srcPath, ...fuseFlags], { stdio: ["pipe", "pipe", "pipe"] });
3386
+ if (compile.status !== 0) {
3387
+ die(`Error: Failed to compile FUSE helper. Install libfuse3-dev (apt) or fuse3-devel (yum).
3388
+ ${compile.stderr?.toString() || ""}`);
3389
+ }
3390
+ }
3391
+ const socketPath = join3(tmpdir(), `crm-fuse-${slugify(mp)}.sock`);
3392
+ const daemonPath = join3(import.meta.dir, "..", "fuse-daemon.ts");
3393
+ const daemonProc = spawn("bun", [
3394
+ "run",
3395
+ daemonPath,
3396
+ socketPath,
3397
+ config.database.path,
3398
+ ...config.pipeline.stages
3399
+ ], { stdio: "ignore", detached: true });
3400
+ daemonProc.unref();
3401
+ const deadline = Date.now() + 5000;
3402
+ while (Date.now() < deadline) {
3403
+ if (existsSync3(socketPath)) {
3404
+ break;
3405
+ }
3406
+ await new Promise((resolve2) => setTimeout(resolve2, 50));
3407
+ }
3408
+ if (!existsSync3(socketPath)) {
3409
+ daemonProc.kill();
3410
+ die("Error: FUSE daemon failed to start.");
3411
+ }
3412
+ const fuseArgs = ["-f", mp, "--", socketPath];
3413
+ if (opts.readonly || config.mount.readonly) {
3414
+ fuseArgs.unshift("-o", "ro");
3415
+ }
3416
+ const fuseProc = spawn(helperPath, fuseArgs, {
3417
+ stdio: "ignore",
3418
+ detached: true
3419
+ });
3420
+ await new Promise((resolve2) => setTimeout(resolve2, 500));
3421
+ if (fuseProc.exitCode !== null) {
3422
+ daemonProc.kill();
3423
+ die("Error: FUSE mount failed. Is FUSE available?");
3424
+ }
3425
+ fuseProc.unref();
3426
+ const pidFile = join3(tmpdir(), `crm-mount-${slugify(mp)}.pid`);
3427
+ writeFileSync3(pidFile, `${fuseProc.pid}
3428
+ ${daemonProc.pid}`);
3429
+ console.log(`Mounted at ${mp} (PID ${fuseProc.pid})`);
3430
+ }
3431
+ async function unmountDarwin(mp) {
3432
+ const pidFile = join3(tmpdir(), `crm-mount-${slugify(mp)}.pid`);
3433
+ if (existsSync3(pidFile)) {
3434
+ const lines = readFileSync2(pidFile, "utf-8").trim().split(`
3435
+ `);
3436
+ const serverPid = Number(lines[0]);
3437
+ const daemonPid = lines[1] ? Number(lines[1]) : null;
3438
+ try {
3439
+ process.kill(serverPid, "SIGTERM");
3440
+ } catch {}
3441
+ const deadline = Date.now() + 2000;
3442
+ while (Date.now() < deadline) {
3443
+ try {
3444
+ process.kill(serverPid, 0);
3445
+ await new Promise((r) => setTimeout(r, 20));
3446
+ } catch {
3447
+ break;
3448
+ }
3449
+ }
3450
+ try {
3451
+ process.kill(serverPid, "SIGKILL");
3452
+ } catch {}
3453
+ await new Promise((r) => setTimeout(r, 50));
3454
+ if (daemonPid) {
3455
+ try {
3456
+ process.kill(daemonPid);
3457
+ } catch {}
3458
+ }
3459
+ unlinkSync(pidFile);
3460
+ }
3461
+ spawnSync2("umount", [mp], { stdio: ["pipe", "pipe", "pipe"] });
3462
+ }
3463
+ function unmountLinux(mp) {
3464
+ const result = spawnSync2("fusermount", ["-u", mp], {
3465
+ stdio: ["pipe", "pipe", "pipe"]
3466
+ });
3467
+ if (result.status !== 0) {
3468
+ spawnSync2("umount", [mp], { stdio: ["pipe", "pipe", "pipe"] });
3469
+ }
3470
+ const pidFile = join3(tmpdir(), `crm-mount-${slugify(mp)}.pid`);
3471
+ if (existsSync3(pidFile)) {
3472
+ const pids = readFileSync2(pidFile, "utf-8").trim().split(`
3473
+ `);
3474
+ for (const pid of pids) {
3475
+ try {
3476
+ process.kill(Number(pid));
3477
+ } catch {}
3478
+ }
3479
+ unlinkSync(pidFile);
3480
+ }
3481
+ const socketPath = join3(tmpdir(), `crm-fuse-${slugify(mp)}.sock`);
3482
+ try {
3483
+ unlinkSync(socketPath);
3484
+ } catch {}
3485
+ }
3486
+ function registerFuseCommands(program) {
3487
+ program.command("mount").description("Mount CRM as virtual filesystem").argument("[mountpoint]", "Mount point directory").option("--readonly", "Mount read-only").action(async (mountpoint, opts) => {
3488
+ const { config } = await getCtx();
3489
+ const mp = mountpoint || config.mount.default_path;
3490
+ if (!existsSync3(mp)) {
3491
+ mkdirSync4(mp, { recursive: true });
3492
+ }
3493
+ if (process.platform === "darwin") {
3494
+ await mountDarwin(mp, config, opts);
3495
+ } else {
3496
+ await mountLinux(mp, config, opts);
3497
+ }
3498
+ });
3499
+ program.command("unmount").description("Unmount CRM filesystem").argument("[mountpoint]", "Mount point").action(async (mountpoint) => {
3500
+ const { config } = await getCtx();
3501
+ const mp = mountpoint || config.mount.default_path;
3502
+ if (process.platform === "darwin") {
3503
+ await unmountDarwin(mp);
3504
+ } else {
3505
+ await unmountLinux(mp);
3506
+ }
3507
+ console.log(`Unmounted ${mp}`);
3508
+ });
3509
+ program.command("export-fs").description("Export CRM data as static filesystem tree").argument("<dir>", "Output directory").action(async (dir) => {
3510
+ const { db, config } = await getCtx();
3511
+ await generateFS(db, config, dir);
3512
+ console.log(`Exported to ${dir}`);
3513
+ });
3514
+ }
3515
+
3516
+ // src/commands/importexport.ts
3517
+ import { readFileSync as readFileSync3 } from "node:fs";
3518
+ import { eq as eq9 } from "drizzle-orm";
3519
+ var CONTACT_FIELDS = new Set([
3520
+ "name",
3521
+ "email",
3522
+ "emails",
3523
+ "phone",
3524
+ "phones",
3525
+ "company",
3526
+ "companies",
3527
+ "tags",
3528
+ "linkedin",
3529
+ "x",
3530
+ "bluesky",
3531
+ "telegram"
3532
+ ]);
3533
+ var COMPANY_FIELDS = new Set([
3534
+ "name",
3535
+ "website",
3536
+ "websites",
3537
+ "phone",
3538
+ "phones",
3539
+ "tags"
3540
+ ]);
3541
+ var DEAL_FIELDS = new Set([
3542
+ "title",
3543
+ "value",
3544
+ "stage",
3545
+ "contacts",
3546
+ "company",
3547
+ "expected_close",
3548
+ "probability",
3549
+ "tags"
3550
+ ]);
3551
+ function registerImportExportCommands(program) {
3552
+ const imp = program.command("import").description("Import data");
3553
+ imp.command("contacts").argument("<file>").option("--dry-run").option("--skip-errors").option("--update").action(async (file, opts) => {
3554
+ const { db, config } = await getCtx();
3555
+ const records = readRecords(file);
3556
+ let imported = 0, skipped = 0, errors = 0;
3557
+ for (const rec of records) {
3558
+ try {
3559
+ if (!rec.name) {
3560
+ if (opts.skipErrors) {
3561
+ errors++;
3562
+ continue;
3563
+ }
3564
+ die("Error: row missing name");
3565
+ }
3566
+ const name = (rec.name || "").trim();
3567
+ const emails = splitField(rec.email || rec.emails).map((e) => e.trim()).filter((e) => e.includes("@") && !e.startsWith("@") && !e.endsWith("@"));
3568
+ const phones = splitField(rec.phone || rec.phones).map((p) => {
3569
+ const n2 = tryNormalizePhone(p, config.phone.default_country);
3570
+ return n2 || p;
3571
+ }).filter((p) => /^\+\d+$/.test(p));
3572
+ const companies2 = splitField(rec.company || rec.companies).map((c) => c.trim());
3573
+ const tags = splitField(rec.tags).map((t) => t.trim());
3574
+ let existing = null;
3575
+ for (const e of emails) {
3576
+ existing = await findContactByEmail(db, e);
3577
+ if (existing) {
3578
+ break;
3579
+ }
3580
+ }
3581
+ if (existing && !opts.update) {
3582
+ skipped++;
3583
+ continue;
3584
+ }
3585
+ if (existing && opts.update) {
3586
+ const custom2 = safeJSON(existing.custom_fields);
3587
+ for (const [k, v] of Object.entries(rec)) {
3588
+ if (!CONTACT_FIELDS.has(k) && v) {
3589
+ custom2[k] = v;
3590
+ }
3591
+ }
3592
+ await db.update(contacts).set({
3593
+ name: name || existing.name,
3594
+ custom_fields: JSON.stringify(custom2),
3595
+ updated_at: now()
3596
+ }).where(eq9(contacts.id, existing.id));
3597
+ const results2 = await db.select().from(contacts).where(eq9(contacts.id, existing.id));
3598
+ const row2 = results2[0];
3599
+ await upsertSearchIndex(db, "contact", existing.id, await buildContactSearch(db, row2));
3600
+ imported++;
3601
+ continue;
3602
+ }
3603
+ if (opts.dryRun) {
3604
+ console.log(`[dry-run] ${name} (${emails.join(", ")})`);
3605
+ imported++;
3606
+ continue;
3607
+ }
3608
+ const id = makeId("ct");
3609
+ const n = now();
3610
+ const custom = {};
3611
+ for (const [k, v] of Object.entries(rec)) {
3612
+ if (!CONTACT_FIELDS.has(k) && v) {
3613
+ custom[k] = v;
3614
+ }
3615
+ }
3616
+ const social = {
3617
+ linkedin: rec.linkedin?.trim() || null,
3618
+ x: rec.x?.trim() || null,
3619
+ bluesky: rec.bluesky?.trim() || null,
3620
+ telegram: rec.telegram?.trim() || null
3621
+ };
3622
+ await db.insert(contacts).values({
3623
+ id,
3624
+ name,
3625
+ emails: JSON.stringify(emails),
3626
+ phones: JSON.stringify(phones),
3627
+ companies: JSON.stringify(companies2),
3628
+ linkedin: social.linkedin,
3629
+ x: social.x,
3630
+ bluesky: social.bluesky,
3631
+ telegram: social.telegram,
3632
+ tags: JSON.stringify(tags),
3633
+ custom_fields: JSON.stringify(custom),
3634
+ created_at: n,
3635
+ updated_at: n
3636
+ });
3637
+ const results = await db.select().from(contacts).where(eq9(contacts.id, id));
3638
+ const row = results[0];
3639
+ await upsertSearchIndex(db, "contact", id, await buildContactSearch(db, row));
3640
+ imported++;
3641
+ } catch (e) {
3642
+ if (opts.skipErrors) {
3643
+ errors++;
3644
+ continue;
3645
+ }
3646
+ die(`Error importing row: ${e.message}`);
3647
+ }
3648
+ }
3649
+ console.log(`Imported: ${imported}, skipped: ${skipped}, errors: ${errors}`);
3650
+ });
3651
+ imp.command("companies").argument("<file>").option("--dry-run").option("--skip-errors").action(async (file, opts) => {
3652
+ const { db, config } = await getCtx();
3653
+ const records = readRecords(file);
3654
+ let imported = 0;
3655
+ for (const rec of records) {
3656
+ try {
3657
+ if (!rec.name) {
3658
+ if (opts.skipErrors) {
3659
+ continue;
3660
+ }
3661
+ die("Error: company missing name");
3662
+ }
3663
+ rec.name = rec.name.trim();
3664
+ const websites = splitField(rec.website || rec.websites).map((w) => {
3665
+ try {
3666
+ return normalizeWebsite(w);
3667
+ } catch {
3668
+ return w;
3669
+ }
3670
+ });
3671
+ const phones = splitField(rec.phone || rec.phones).map((p) => {
3672
+ const n2 = tryNormalizePhone(p, config.phone.default_country);
3673
+ return n2 || p;
3674
+ });
3675
+ const tags = splitField(rec.tags);
3676
+ const custom = {};
3677
+ for (const [k, v] of Object.entries(rec)) {
3678
+ if (!COMPANY_FIELDS.has(k) && v) {
3679
+ custom[k] = v;
3680
+ }
3681
+ }
3682
+ if (opts.dryRun) {
3683
+ console.log(`[dry-run] ${rec.name}`);
3684
+ imported++;
3685
+ continue;
3686
+ }
3687
+ const id = makeId("co");
3688
+ const n = now();
3689
+ await db.insert(companies).values({
3690
+ id,
3691
+ name: rec.name,
3692
+ websites: JSON.stringify(websites),
3693
+ phones: JSON.stringify(phones),
3694
+ tags: JSON.stringify(tags),
3695
+ custom_fields: JSON.stringify(custom),
3696
+ created_at: n,
3697
+ updated_at: n
3698
+ });
3699
+ const results = await db.select().from(companies).where(eq9(companies.id, id));
3700
+ const row = results[0];
3701
+ await upsertSearchIndex(db, "company", id, buildCompanySearch(row));
3702
+ imported++;
3703
+ } catch (e) {
3704
+ if (opts.skipErrors) {
3705
+ continue;
3706
+ }
3707
+ die(`Error: ${e.message}`);
3708
+ }
3709
+ }
3710
+ console.log(`Imported: ${imported}`);
3711
+ });
3712
+ imp.command("deals").argument("<file>").option("--dry-run").option("--skip-errors").action(async (file, opts) => {
3713
+ const { db, config } = await getCtx();
3714
+ const records = readRecords(file);
3715
+ let imported = 0;
3716
+ for (const rec of records) {
3717
+ try {
3718
+ if (!rec.title) {
3719
+ if (opts.skipErrors) {
3720
+ continue;
3721
+ }
3722
+ die("Error: deal missing title");
3723
+ }
3724
+ rec.title = rec.title.trim();
3725
+ const stage = (rec.stage || config.pipeline.stages[0]).trim();
3726
+ const value = rec.value ? Number(rec.value) : null;
3727
+ const tags = splitField(rec.tags);
3728
+ const custom = {};
3729
+ for (const [k, v] of Object.entries(rec)) {
3730
+ if (!DEAL_FIELDS.has(k) && v) {
3731
+ custom[k] = v;
3732
+ }
3733
+ }
3734
+ if (opts.dryRun) {
3735
+ console.log(`[dry-run] ${rec.title}`);
3736
+ imported++;
3737
+ continue;
3738
+ }
3739
+ const id = makeId("dl");
3740
+ const n = now();
3741
+ await db.insert(deals).values({
3742
+ id,
3743
+ title: rec.title,
3744
+ value,
3745
+ stage,
3746
+ contacts: "[]",
3747
+ company: null,
3748
+ expected_close: rec.expected_close || null,
3749
+ probability: rec.probability ? Number(rec.probability) : null,
3750
+ tags: JSON.stringify(tags),
3751
+ custom_fields: JSON.stringify(custom),
3752
+ created_at: n,
3753
+ updated_at: n
3754
+ });
3755
+ const results = await db.select().from(deals).where(eq9(deals.id, id));
3756
+ const row = results[0];
3757
+ await upsertSearchIndex(db, "deal", id, buildDealSearch(row));
3758
+ imported++;
3759
+ } catch (e) {
3760
+ if (opts.skipErrors) {
3761
+ continue;
3762
+ }
3763
+ die(`Error: ${e.message}`);
3764
+ }
3765
+ }
3766
+ console.log(`Imported: ${imported}`);
3767
+ });
3768
+ const exp = program.command("export").description("Export data");
3769
+ exp.command("contacts").action(async () => {
3770
+ const { db, config, fmt } = await getCtx();
3771
+ const rows = (await db.select().from(contacts)).map((c) => contactToRow(c));
3772
+ console.log(formatOutput(rows, fmt, config));
3773
+ });
3774
+ exp.command("companies").action(async () => {
3775
+ const { db, config, fmt } = await getCtx();
3776
+ const rows = (await db.select().from(companies)).map((c) => companyToRow(c));
3777
+ console.log(formatOutput(rows, fmt, config));
3778
+ });
3779
+ exp.command("deals").action(async () => {
3780
+ const { db, config, fmt } = await getCtx();
3781
+ const rows = (await db.select().from(deals)).map((d) => dealToRow(d));
3782
+ console.log(formatOutput(rows, fmt, config));
3783
+ });
3784
+ exp.command("all").action(async () => {
3785
+ const { db, config, fmt } = await getCtx();
3786
+ const data = {
3787
+ contacts: (await db.select().from(contacts)).map((c) => contactToRow(c)),
3788
+ companies: (await db.select().from(companies)).map((c) => companyToRow(c)),
3789
+ deals: (await db.select().from(deals)).map((d) => dealToRow(d)),
3790
+ activities: (await db.select().from(activities)).map((a) => activityToRow(a))
3791
+ };
3792
+ if (fmt === "json") {
3793
+ console.log(JSON.stringify(data, null, 2));
3794
+ } else {
3795
+ console.log(formatOutput(Object.entries(data).map(([k, v]) => ({ type: k, count: v.length })), fmt, config));
3796
+ }
3797
+ });
3798
+ }
3799
+ function readRecords(file) {
3800
+ let raw;
3801
+ if (file === "-") {
3802
+ const chunks = [];
3803
+ const fd = __require("node:fs").openSync("/dev/stdin", "r");
3804
+ const buf = Buffer.alloc(65536);
3805
+ let n = __require("node:fs").readSync(fd, buf);
3806
+ while (n > 0) {
3807
+ chunks.push(buf.subarray(0, n));
3808
+ n = __require("node:fs").readSync(fd, buf);
3809
+ }
3810
+ __require("node:fs").closeSync(fd);
3811
+ raw = Buffer.concat(chunks).toString("utf-8");
3812
+ } else {
3813
+ raw = readFileSync3(file, "utf-8");
3814
+ }
3815
+ raw = raw.trim();
3816
+ if (!raw) {
3817
+ return [];
3818
+ }
3819
+ if (raw.startsWith("[") || raw.startsWith("{")) {
3820
+ return JSON.parse(raw);
3821
+ }
3822
+ return parseCSV(raw);
3823
+ }
3824
+ function splitField(val) {
3825
+ if (!val) {
3826
+ return [];
3827
+ }
3828
+ if (Array.isArray(val)) {
3829
+ return val;
3830
+ }
3831
+ return val.split(",").map((s) => s.trim()).filter(Boolean);
3832
+ }
3833
+ async function findContactByEmail(db, email) {
3834
+ const all = await db.select().from(contacts);
3835
+ for (const c of all) {
3836
+ const emails = safeJSON(c.emails);
3837
+ if (emails.some((e) => e.toLowerCase() === email.toLowerCase())) {
3838
+ return c;
3839
+ }
3840
+ }
3841
+ return null;
3842
+ }
3843
+
3844
+ // src/commands/report.ts
3845
+ import { eq as eq10 } from "drizzle-orm";
3846
+ function registerReportCommands(program) {
3847
+ const cmd = program.command("report").description("Reports");
3848
+ cmd.command("pipeline").action(async () => {
3849
+ const { db, config, fmt } = await getCtx();
3850
+ const deals2 = await db.select().from(deals);
3851
+ const summary = computePipeline(deals2, config.pipeline.stages);
3852
+ const total = {
3853
+ stage: "Total",
3854
+ count: deals2.length,
3855
+ value: deals2.reduce((s, d) => s + (d.value || 0), 0)
3856
+ };
3857
+ const data = [...summary, total];
3858
+ if (fmt === "json") {
3859
+ console.log(JSON.stringify(data, null, 2));
3860
+ } else {
3861
+ console.log(formatOutput(data, fmt, config));
3862
+ }
3863
+ });
3864
+ cmd.command("activity").option("--by <field>", "Group by (type or contact)").option("--period <period>", "Time period (e.g. 7d, 30d)").action(async (opts) => {
3865
+ const { db, config, fmt } = await getCtx();
3866
+ let activities2 = await db.select().from(activities);
3867
+ if (opts.period) {
3868
+ const cutoff = periodToDate(opts.period);
3869
+ if (cutoff) {
3870
+ activities2 = activities2.filter((a) => a.created_at >= cutoff);
3871
+ }
3872
+ }
3873
+ const groupBy = opts.by || "type";
3874
+ const groups = {};
3875
+ for (const a of activities2) {
3876
+ if (groupBy === "contact") {
3877
+ const contacts2 = safeJSON(a.contacts);
3878
+ if (contacts2.length === 0) {
3879
+ groups.none = (groups.none || 0) + 1;
3880
+ } else {
3881
+ for (const cid of contacts2) {
3882
+ groups[cid] = (groups[cid] || 0) + 1;
3883
+ }
3884
+ }
3885
+ } else {
3886
+ groups[a.type] = (groups[a.type] || 0) + 1;
3887
+ }
3888
+ }
3889
+ let data;
3890
+ if (groupBy === "contact") {
3891
+ const dataPromises = Object.entries(groups).map(async ([contact, count]) => {
3892
+ if (contact === "none") {
3893
+ return { contact: "(none)", count };
3894
+ }
3895
+ const results = await db.select({ name: contacts.name }).from(contacts).where(eq10(contacts.id, contact));
3896
+ const ct = results[0];
3897
+ return { contact: ct?.name || contact, count };
3898
+ });
3899
+ data = await Promise.all(dataPromises);
3900
+ } else {
3901
+ data = Object.entries(groups).map(([type, count]) => ({ type, count }));
3902
+ }
3903
+ if (fmt === "json") {
3904
+ console.log(JSON.stringify(data, null, 2));
3905
+ } else {
3906
+ console.log(formatOutput(data, fmt, config));
3907
+ }
3908
+ });
3909
+ cmd.command("stale").option("--days <n>", "Days threshold", "30").option("--type <type>", "Entity type (contact or deal)").action(async (opts) => {
3910
+ const { db, config, fmt } = await getCtx();
3911
+ const days = Number(opts.days);
3912
+ let results = await computeStale(db, config, days);
3913
+ if (opts.type) {
3914
+ results = results.filter((r) => r.type === opts.type);
3915
+ }
3916
+ if (fmt === "json") {
3917
+ console.log(JSON.stringify(results, null, 2));
3918
+ } else {
3919
+ if (results.length === 0) {
3920
+ console.log("No stale entities found.");
3921
+ return;
3922
+ }
3923
+ const lines = results.map((r) => `[${r.type}] ${r.name || r.title} (${r.id}) — last: ${r.last_activity || "never"}`);
3924
+ console.log(lines.join(`
3925
+ `));
3926
+ }
3927
+ });
3928
+ cmd.command("conversion").option("--since <date>", "Only count transitions after date (YYYY-MM-DD)").action(async (opts) => {
3929
+ const { db, config, fmt } = await getCtx();
3930
+ const data = await computeConversion(db, config.pipeline.stages, opts.since);
3931
+ if (fmt === "json") {
3932
+ console.log(JSON.stringify(data, null, 2));
3933
+ } else {
3934
+ console.log(formatOutput(data, fmt, config));
3935
+ }
3936
+ });
3937
+ cmd.command("velocity").option("--won-only", "Only count deals that were won").action(async (opts) => {
3938
+ const { db, config, fmt } = await getCtx();
3939
+ const wonStage = opts.wonOnly ? config.pipeline.won_stage : undefined;
3940
+ const data = await computeVelocity(db, config.pipeline.stages, wonStage);
3941
+ if (fmt === "json") {
3942
+ console.log(JSON.stringify(data, null, 2));
3943
+ } else {
3944
+ console.log(formatOutput(data.map((d) => ({
3945
+ stage: d.stage,
3946
+ avg_time: d.avg_display,
3947
+ deals: d.deals
3948
+ })), fmt, config));
3949
+ }
3950
+ });
3951
+ cmd.command("forecast").option("--period <period>", "Filter by expected close month (YYYY-MM) or days (30d)").action(async (opts) => {
3952
+ const { db, config, fmt } = await getCtx();
3953
+ let data = await computeForecast(db, config);
3954
+ if (opts.period) {
3955
+ if (opts.period.match(/^\d{4}-\d{2}$/)) {
3956
+ data = data.filter((d) => d.expected_close?.startsWith(opts.period));
3957
+ } else {
3958
+ const cutoff = periodToDate(opts.period);
3959
+ if (cutoff) {
3960
+ data = data.filter((d) => d.expected_close && d.expected_close >= cutoff);
3961
+ }
3962
+ }
3963
+ }
3964
+ if (fmt === "json") {
3965
+ console.log(JSON.stringify(data, null, 2));
3966
+ } else {
3967
+ console.log(formatOutput(data, fmt, config));
3968
+ }
3969
+ });
3970
+ cmd.command("won").option("--period <period>", "Time period (e.g. 30d)").action(async (opts) => {
3971
+ const { db, config, fmt } = await getCtx();
3972
+ let data = await computeWon(db, config);
3973
+ if (opts.period) {
3974
+ const cutoff = periodToDate(opts.period);
3975
+ if (cutoff) {
3976
+ data = data.filter((d) => d.updated_at >= cutoff);
3977
+ }
3978
+ }
3979
+ if (fmt === "json") {
3980
+ console.log(JSON.stringify(data, null, 2));
3981
+ } else {
3982
+ console.log(formatOutput(data, fmt, config));
3983
+ }
3984
+ });
3985
+ cmd.command("lost").option("--period <period>", "Time period").action(async (opts) => {
3986
+ const { db, config, fmt } = await getCtx();
3987
+ let data = await computeLost(db, config);
3988
+ if (opts.period) {
3989
+ const cutoff = periodToDate(opts.period);
3990
+ if (cutoff) {
3991
+ data = data.filter((d) => d.updated_at >= cutoff);
3992
+ }
3993
+ }
3994
+ if (fmt === "json") {
3995
+ console.log(JSON.stringify(data, null, 2));
3996
+ } else {
3997
+ console.log(formatOutput(data, fmt, config));
3998
+ }
3999
+ });
4000
+ }
4001
+ function periodToDate(period) {
4002
+ const m = period.match(/^(\d+)d$/);
4003
+ if (m) {
4004
+ return new Date(Date.now() - Number(m[1]) * 86400000).toISOString();
4005
+ }
4006
+ return null;
4007
+ }
4008
+
4009
+ // src/commands/search.ts
4010
+ import { eq as eq11, sql as sql4 } from "drizzle-orm";
4011
+ function registerSearchCommands(program) {
4012
+ program.command("search").description("Full-text search").argument("<query>").option("--type <type>").action(async (rawQuery, opts) => {
4013
+ const query = rawQuery.trim();
4014
+ const { db, config, fmt } = await getCtx();
4015
+ const results = [];
4016
+ try {
4017
+ const ftsRows = await db.all(sql4`SELECT * FROM search_index WHERE content MATCH ${query}`);
4018
+ for (const fr of ftsRows) {
4019
+ if (opts.type && fr.entity_type !== opts.type) {
4020
+ continue;
4021
+ }
4022
+ const entity = await lookupEntity(db, fr.entity_type, fr.entity_id);
4023
+ if (entity) {
4024
+ results.push(entity);
4025
+ }
4026
+ }
4027
+ } catch {
4028
+ const likeRows = await db.all(sql4`SELECT * FROM search_index WHERE content LIKE ${`%${query}%`}`);
4029
+ for (const fr of likeRows) {
4030
+ if (opts.type && fr.entity_type !== opts.type) {
4031
+ continue;
4032
+ }
4033
+ const entity = await lookupEntity(db, fr.entity_type, fr.entity_id);
4034
+ if (entity) {
4035
+ results.push(entity);
4036
+ }
4037
+ }
4038
+ }
4039
+ const capped = results.slice(0, config.mount.search_limit);
4040
+ if (fmt === "json") {
4041
+ console.log(JSON.stringify(capped, null, 2));
4042
+ } else {
4043
+ if (capped.length === 0) {
4044
+ console.log("");
4045
+ return;
4046
+ }
4047
+ const lines = capped.map((r) => {
4048
+ if (r.type === "contact") {
4049
+ return `[contact] ${r.name} (${r.id})`;
4050
+ }
4051
+ if (r.type === "company") {
4052
+ return `[company] ${r.name} (${r.id})`;
4053
+ }
4054
+ if (r.type === "deal") {
4055
+ return `[deal] ${r.title} (${r.id})`;
4056
+ }
4057
+ if (r.entity_type === "activity") {
4058
+ return `[activity] ${r.body} (${r.id})`;
4059
+ }
4060
+ return `[${r.type}] ${r.id}`;
4061
+ });
4062
+ console.log(lines.join(`
4063
+ `));
4064
+ }
4065
+ });
4066
+ program.command("find").description("Semantic search").argument("<query>").option("--type <type>").option("--limit <n>").option("--threshold <n>", "Minimum similarity score 0.0-1.0").action(async (rawQuery, opts) => {
4067
+ const query = rawQuery.trim();
4068
+ const { db, config, fmt } = await getCtx();
4069
+ const queryWords = query.toLowerCase().split(/\s+/);
4070
+ const allEntities = [];
4071
+ const indexRows = await db.all(sql4`SELECT * FROM search_index`);
4072
+ for (const row of indexRows) {
4073
+ if (opts.type && row.entity_type !== opts.type) {
4074
+ continue;
4075
+ }
4076
+ if (row.entity_type === "activity") {
4077
+ continue;
4078
+ }
4079
+ const content = (row.content || "").toLowerCase();
4080
+ let score = 0;
4081
+ for (const w of queryWords) {
4082
+ if (content.includes(w)) {
4083
+ score += 1;
4084
+ }
4085
+ }
4086
+ if (score > 0) {
4087
+ const normalized = queryWords.length > 0 ? score / queryWords.length : 0;
4088
+ allEntities.push({ ...row, score: normalized });
4089
+ }
4090
+ }
4091
+ allEntities.sort((a, b) => b.score - a.score);
4092
+ let limited = allEntities;
4093
+ if (opts.threshold) {
4094
+ const t = Number(opts.threshold);
4095
+ limited = limited.filter((e) => e.score >= t);
4096
+ }
4097
+ const maxResults = opts.limit ? Number(opts.limit) : config.mount.search_limit;
4098
+ limited = limited.slice(0, maxResults);
4099
+ const resultPromises = limited.map((r) => lookupEntity(db, r.entity_type, r.entity_id));
4100
+ const results = (await Promise.all(resultPromises)).filter(Boolean);
4101
+ if (fmt === "json") {
4102
+ console.log(JSON.stringify(results, null, 2));
4103
+ } else {
4104
+ if (results.length === 0) {
4105
+ console.log("");
4106
+ return;
4107
+ }
4108
+ const lines = results.map((r) => `[${r.type}] ${r.name || r.title} (${r.id})`);
4109
+ console.log(lines.join(`
4110
+ `));
4111
+ }
4112
+ });
4113
+ const idx = program.command("index").description("Search index management");
4114
+ idx.command("status").action(async () => {
4115
+ const { db } = await getCtx();
4116
+ const countRows = await db.all(sql4`SELECT entity_type, COUNT(*) as cnt FROM search_index GROUP BY entity_type`);
4117
+ const counts = {};
4118
+ for (const r of countRows) {
4119
+ counts[r.entity_type] = r.cnt;
4120
+ }
4121
+ const contactCount = (await db.select({ cnt: sql4`COUNT(*)` }).from(contacts))[0];
4122
+ const companyCount = (await db.select({ cnt: sql4`COUNT(*)` }).from(companies))[0];
4123
+ const dealCount = (await db.select({ cnt: sql4`COUNT(*)` }).from(deals))[0];
4124
+ console.log(`contacts: ${contactCount.cnt} (indexed: ${counts.contact || 0})`);
4125
+ console.log(`companies: ${companyCount.cnt} (indexed: ${counts.company || 0})`);
4126
+ console.log(`deals: ${dealCount.cnt} (indexed: ${counts.deal || 0})`);
4127
+ });
4128
+ idx.command("rebuild").action(async () => {
4129
+ const { db } = await getCtx();
4130
+ await rebuildSearchIndex(db);
4131
+ console.log("Index rebuilt");
4132
+ });
4133
+ }
4134
+ async function lookupEntity(db, entityType, id) {
4135
+ if (entityType === "contact") {
4136
+ const results = await db.select().from(contacts).where(eq11(contacts.id, id));
4137
+ const c = results[0];
4138
+ if (!c) {
4139
+ return null;
4140
+ }
4141
+ return { type: "contact", ...contactToRow(c) };
4142
+ }
4143
+ if (entityType === "company") {
4144
+ const results = await db.select().from(companies).where(eq11(companies.id, id));
4145
+ const c = results[0];
4146
+ if (!c) {
4147
+ return null;
4148
+ }
4149
+ return { type: "company", ...companyToRow(c) };
4150
+ }
4151
+ if (entityType === "deal") {
4152
+ const results = await db.select().from(deals).where(eq11(deals.id, id));
4153
+ const d = results[0];
4154
+ if (!d) {
4155
+ return null;
4156
+ }
4157
+ return { type: "deal", ...dealToRow(d) };
4158
+ }
4159
+ if (entityType === "activity") {
4160
+ const results = await db.select().from(activities).where(eq11(activities.id, id));
4161
+ const a = results[0];
4162
+ if (!a) {
4163
+ return null;
4164
+ }
4165
+ const row = activityToRow(a);
4166
+ return { entity_type: "activity", ...row };
4167
+ }
4168
+ return null;
4169
+ }
4170
+
4171
+ // src/commands/tag.ts
4172
+ import { eq as eq12 } from "drizzle-orm";
4173
+ function registerTagCommands(program) {
4174
+ program.command("tag").description("Tag an entity or list tags").argument("[args...]").option("--type <type>").action(async (args, opts) => {
4175
+ if (args[0] === "list") {
4176
+ return tagList(opts);
4177
+ }
4178
+ if (args.length < 2) {
4179
+ die("Error: usage: tag <ref> <tags...>");
4180
+ }
4181
+ const ref = args[0].trim();
4182
+ const tags = args.slice(1).map((t) => t.trim());
4183
+ const { db, config } = await getCtx();
4184
+ const resolved = await resolveEntity(db, ref, config);
4185
+ if (!resolved) {
4186
+ die(`Error: entity not found: ${ref}`);
4187
+ }
4188
+ const { type, entity } = resolved;
4189
+ const existing = safeJSON(entity.tags);
4190
+ for (const t of tags) {
4191
+ if (!existing.includes(t)) {
4192
+ existing.push(t);
4193
+ }
4194
+ }
4195
+ if (type === "contact") {
4196
+ await db.update(contacts).set({ tags: JSON.stringify(existing), updated_at: now() }).where(eq12(contacts.id, entity.id));
4197
+ } else if (type === "company") {
4198
+ await db.update(companies).set({ tags: JSON.stringify(existing), updated_at: now() }).where(eq12(companies.id, entity.id));
4199
+ } else {
4200
+ await db.update(deals).set({ tags: JSON.stringify(existing), updated_at: now() }).where(eq12(deals.id, entity.id));
4201
+ }
4202
+ });
4203
+ program.command("untag").description("Remove tags from an entity").argument("<ref>").argument("<tags...>").action(async (rawRef, rawTags) => {
4204
+ const ref = rawRef.trim();
4205
+ const tags = rawTags.map((t) => t.trim());
4206
+ const { db, config } = await getCtx();
4207
+ const resolved = await resolveEntity(db, ref, config);
4208
+ if (!resolved) {
4209
+ die(`Error: entity not found: ${ref}`);
4210
+ }
4211
+ const { type, entity } = resolved;
4212
+ let existing = safeJSON(entity.tags);
4213
+ for (const t of tags) {
4214
+ existing = existing.filter((v) => v !== t);
4215
+ }
4216
+ if (type === "contact") {
4217
+ await db.update(contacts).set({ tags: JSON.stringify(existing), updated_at: now() }).where(eq12(contacts.id, entity.id));
4218
+ } else if (type === "company") {
4219
+ await db.update(companies).set({ tags: JSON.stringify(existing), updated_at: now() }).where(eq12(companies.id, entity.id));
4220
+ } else {
4221
+ await db.update(deals).set({ tags: JSON.stringify(existing), updated_at: now() }).where(eq12(deals.id, entity.id));
4222
+ }
4223
+ });
4224
+ }
4225
+ async function tagList(opts) {
4226
+ const { db, config, fmt } = await getCtx();
4227
+ const tagMap = {};
4228
+ if (!opts.type || opts.type === "contact") {
4229
+ const rows = await db.select({ tags: contacts.tags }).from(contacts);
4230
+ for (const r of rows) {
4231
+ const tags = safeJSON(r.tags);
4232
+ for (const tag of tags) {
4233
+ tagMap[tag] = (tagMap[tag] || 0) + 1;
4234
+ }
4235
+ }
4236
+ }
4237
+ if (!opts.type || opts.type === "company") {
4238
+ const rows = await db.select({ tags: companies.tags }).from(companies);
4239
+ for (const r of rows) {
4240
+ const tags = safeJSON(r.tags);
4241
+ for (const tag of tags) {
4242
+ tagMap[tag] = (tagMap[tag] || 0) + 1;
4243
+ }
4244
+ }
4245
+ }
4246
+ if (!opts.type || opts.type === "deal") {
4247
+ const rows = await db.select({ tags: deals.tags }).from(deals);
4248
+ for (const r of rows) {
4249
+ const tags = safeJSON(r.tags);
4250
+ for (const tag of tags) {
4251
+ tagMap[tag] = (tagMap[tag] || 0) + 1;
4252
+ }
4253
+ }
4254
+ }
4255
+ const data = Object.entries(tagMap).map(([tag, count]) => ({ tag, count }));
4256
+ if (fmt === "json") {
4257
+ console.log(JSON.stringify(data, null, 2));
4258
+ } else {
4259
+ console.log(formatOutput(data, fmt, config));
4260
+ }
4261
+ }
4262
+
4263
+ // src/cli.ts
4264
+ var program = new Command;
4265
+ program.name("crm").description("Headless CLI-first CRM").version("0.1.0");
4266
+ program.exitOverride();
4267
+ registerContactCommands(program);
4268
+ registerCompanyCommands(program);
4269
+ registerDealCommands(program);
4270
+ registerPipelineCommand(program);
4271
+ registerLogCommand(program);
4272
+ registerActivityCommands(program);
4273
+ registerTagCommands(program);
4274
+ registerSearchCommands(program);
4275
+ registerReportCommands(program);
4276
+ registerImportExportCommands(program);
4277
+ registerDupesCommand(program);
4278
+ registerFuseCommands(program);
4279
+ try {
4280
+ program.parse(["node", "crm", ...cleanArgv]);
4281
+ } catch (e) {
4282
+ const err = e;
4283
+ if (err.exitCode !== undefined && err.exitCode === 0) {
4284
+ process.exit(0);
4285
+ }
4286
+ if (err.exitCode !== undefined) {
4287
+ process.exit(err.exitCode);
4288
+ }
4289
+ console.error(err.message || e);
4290
+ process.exit(1);
4291
+ }