@dzhng/crm.cli 0.2.0 → 0.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +16 -16
- package/dist/cli.js +1185 -20
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
**A headless, CLI-first CRM for developers who do sales.** Contacts, deals, and pipeline in a single SQLite file — queryable from your terminal, composable with Unix tools, and mountable as a virtual filesystem so any tool that reads files (Claude Code, Codex, grep, jq, vim) has full CRM access without any integration.
|
|
6
6
|
|
|
7
|
-
No server. No Docker. No accounts. No GUI. Just `
|
|
7
|
+
No server. No Docker. No accounts. No GUI. Just `npm install -g @dzhng/crm.cli` and go.
|
|
8
8
|
|
|
9
9
|
> **Sponsored by [Duet](https://duet.so)** — a cloud agent workspace with persistent AI. Set up crm.cli in your own private cloud computer and run it with Claude Code or Codex — no local setup required. [Try Duet →](https://duet.so)
|
|
10
10
|
|
|
@@ -44,11 +44,8 @@ crm dupes --threshold 0.5
|
|
|
44
44
|
## Install
|
|
45
45
|
|
|
46
46
|
```bash
|
|
47
|
-
|
|
48
|
-
bun install -g @dzhng/crm.cli
|
|
49
|
-
|
|
50
|
-
# Or npx without installing
|
|
51
|
-
bunx @dzhng/crm.cli contact list
|
|
47
|
+
npm install -g @dzhng/crm.cli
|
|
48
|
+
# or: bun install -g @dzhng/crm.cli
|
|
52
49
|
|
|
53
50
|
# Or install the compiled binary
|
|
54
51
|
curl -fsSL https://raw.githubusercontent.com/dzhng/crm.cli/main/install.sh | sh
|
|
@@ -1224,10 +1221,20 @@ max_recent_activity = 10 # how many activities to inline in entity files
|
|
|
1224
1221
|
search_limit = 20 # max results for search/ virtual files
|
|
1225
1222
|
```
|
|
1226
1223
|
|
|
1224
|
+
### How Mount Works
|
|
1225
|
+
|
|
1226
|
+
`crm mount` starts two background processes:
|
|
1227
|
+
|
|
1228
|
+
1. **Daemon** (`crm __daemon`) — a Unix socket server that handles all business logic (reads, writes, search, validation, normalization). The `crm` binary spawns itself with a hidden `__daemon` subcommand, so this works identically whether installed via npm, compiled binary, or running from source during development.
|
|
1229
|
+
|
|
1230
|
+
2. **Filesystem bridge** — connects the OS filesystem layer to the daemon:
|
|
1231
|
+
- **macOS:** An NFS v3 server (Rust binary, auto-compiled on first mount via Cargo) that translates NFS operations to daemon socket calls. Mounted via `mount_nfs`.
|
|
1232
|
+
- **Linux:** A FUSE helper (C binary, auto-compiled on first mount via gcc) that translates FUSE syscalls to daemon socket calls.
|
|
1233
|
+
|
|
1227
1234
|
### Platform Notes
|
|
1228
1235
|
|
|
1229
|
-
- **Linux:** Works out of the box (FUSE is in the kernel).
|
|
1230
|
-
- **macOS:** Requires
|
|
1236
|
+
- **Linux:** Works out of the box (FUSE is in the kernel). Requires `libfuse3-dev` and `gcc`.
|
|
1237
|
+
- **macOS:** Requires Rust toolchain for the NFS server (`curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh`). No FUSE or kernel extensions needed.
|
|
1231
1238
|
- **Windows:** Not supported (use WSL).
|
|
1232
1239
|
- **Containers/sandboxes:** FUSE requires `--privileged` or `--device /dev/fuse`. If unavailable, use the CLI with `--format json` instead.
|
|
1233
1240
|
|
|
@@ -1250,15 +1257,8 @@ search_limit = 20 # max results for search/ virtual files
|
|
|
1250
1257
|
### Package Manager
|
|
1251
1258
|
|
|
1252
1259
|
```bash
|
|
1253
|
-
# Install globally via bun (recommended)
|
|
1254
|
-
bun install -g @dzhng/crm.cli
|
|
1255
|
-
|
|
1256
|
-
# Or via npm
|
|
1257
1260
|
npm install -g @dzhng/crm.cli
|
|
1258
|
-
|
|
1259
|
-
# Or use without installing
|
|
1260
|
-
bunx @dzhng/crm.cli contact list
|
|
1261
|
-
npx @dzhng/crm.cli contact list
|
|
1261
|
+
# or: bun install -g @dzhng/crm.cli
|
|
1262
1262
|
```
|
|
1263
1263
|
|
|
1264
1264
|
### Compiled Binary
|
package/dist/cli.js
CHANGED
|
@@ -3255,6 +3255,13 @@ function ensureDir2(dir) {
|
|
|
3255
3255
|
mkdirSync4(dir, { recursive: true });
|
|
3256
3256
|
}
|
|
3257
3257
|
}
|
|
3258
|
+
function getDaemonArgs() {
|
|
3259
|
+
const script = process.argv[1];
|
|
3260
|
+
if (script && /\.[tj]s$/.test(script)) {
|
|
3261
|
+
return [script];
|
|
3262
|
+
}
|
|
3263
|
+
return [];
|
|
3264
|
+
}
|
|
3258
3265
|
async function mountDarwin(mp, config, _opts) {
|
|
3259
3266
|
const nfsHelperPath = join3(homedir2(), ".crm", "bin", "crm-nfs");
|
|
3260
3267
|
if (!existsSync3(nfsHelperPath)) {
|
|
@@ -3280,10 +3287,9 @@ ${compile.stderr?.toString() || ""}`);
|
|
|
3280
3287
|
]);
|
|
3281
3288
|
}
|
|
3282
3289
|
const socketPath = join3(tmpdir(), `crm-fuse-${slugify(mp)}.sock`);
|
|
3283
|
-
const
|
|
3284
|
-
|
|
3285
|
-
"
|
|
3286
|
-
daemonPath,
|
|
3290
|
+
const daemonProc = spawn(process.execPath, [
|
|
3291
|
+
...getDaemonArgs(),
|
|
3292
|
+
"__daemon",
|
|
3287
3293
|
socketPath,
|
|
3288
3294
|
config.database.path,
|
|
3289
3295
|
...config.pipeline.stages
|
|
@@ -3389,10 +3395,9 @@ ${compile.stderr?.toString() || ""}`);
|
|
|
3389
3395
|
}
|
|
3390
3396
|
}
|
|
3391
3397
|
const socketPath = join3(tmpdir(), `crm-fuse-${slugify(mp)}.sock`);
|
|
3392
|
-
const
|
|
3393
|
-
|
|
3394
|
-
"
|
|
3395
|
-
daemonPath,
|
|
3398
|
+
const daemonProc = spawn(process.execPath, [
|
|
3399
|
+
...getDaemonArgs(),
|
|
3400
|
+
"__daemon",
|
|
3396
3401
|
socketPath,
|
|
3397
3402
|
config.database.path,
|
|
3398
3403
|
...config.pipeline.stages
|
|
@@ -4260,9 +4265,1162 @@ async function tagList(opts) {
|
|
|
4260
4265
|
}
|
|
4261
4266
|
}
|
|
4262
4267
|
|
|
4268
|
+
// src/fuse-daemon.ts
|
|
4269
|
+
import { existsSync as existsSync4, unlinkSync as unlinkSync2 } from "node:fs";
|
|
4270
|
+
import { createServer } from "node:net";
|
|
4271
|
+
import { eq as eq13 } from "drizzle-orm";
|
|
4272
|
+
import { ulid as ulid2 } from "ulid";
|
|
4273
|
+
function makeId2(prefix) {
|
|
4274
|
+
return `${prefix}_${ulid2()}`;
|
|
4275
|
+
}
|
|
4276
|
+
function extractId(filename) {
|
|
4277
|
+
const dots = filename.indexOf("...");
|
|
4278
|
+
if (dots === -1) {
|
|
4279
|
+
return null;
|
|
4280
|
+
}
|
|
4281
|
+
return filename.slice(0, dots);
|
|
4282
|
+
}
|
|
4283
|
+
function stripJsonExt(s) {
|
|
4284
|
+
return s.endsWith(".json") ? s.slice(0, -5) : s;
|
|
4285
|
+
}
|
|
4286
|
+
function extractCompanyId(val) {
|
|
4287
|
+
if (typeof val === "object" && val !== null && "id" in val) {
|
|
4288
|
+
return val.id;
|
|
4289
|
+
}
|
|
4290
|
+
return val;
|
|
4291
|
+
}
|
|
4292
|
+
var CONTACT_WRITE_FIELDS = new Set([
|
|
4293
|
+
"id",
|
|
4294
|
+
"name",
|
|
4295
|
+
"emails",
|
|
4296
|
+
"email",
|
|
4297
|
+
"phones",
|
|
4298
|
+
"phone",
|
|
4299
|
+
"linkedin",
|
|
4300
|
+
"x",
|
|
4301
|
+
"bluesky",
|
|
4302
|
+
"telegram",
|
|
4303
|
+
"companies",
|
|
4304
|
+
"company",
|
|
4305
|
+
"tags",
|
|
4306
|
+
"custom_fields",
|
|
4307
|
+
"created_at",
|
|
4308
|
+
"updated_at",
|
|
4309
|
+
"title",
|
|
4310
|
+
"department",
|
|
4311
|
+
"source",
|
|
4312
|
+
"notes",
|
|
4313
|
+
"address",
|
|
4314
|
+
"birthday",
|
|
4315
|
+
"website",
|
|
4316
|
+
"job_title",
|
|
4317
|
+
"role",
|
|
4318
|
+
"position",
|
|
4319
|
+
"deals",
|
|
4320
|
+
"recent_activity"
|
|
4321
|
+
]);
|
|
4322
|
+
var COMPANY_WRITE_FIELDS = new Set([
|
|
4323
|
+
"id",
|
|
4324
|
+
"name",
|
|
4325
|
+
"websites",
|
|
4326
|
+
"website",
|
|
4327
|
+
"phones",
|
|
4328
|
+
"phone",
|
|
4329
|
+
"tags",
|
|
4330
|
+
"custom_fields",
|
|
4331
|
+
"created_at",
|
|
4332
|
+
"updated_at",
|
|
4333
|
+
"industry",
|
|
4334
|
+
"address",
|
|
4335
|
+
"notes",
|
|
4336
|
+
"source",
|
|
4337
|
+
"description",
|
|
4338
|
+
"size",
|
|
4339
|
+
"contacts",
|
|
4340
|
+
"deals"
|
|
4341
|
+
]);
|
|
4342
|
+
var DEAL_WRITE_FIELDS = new Set([
|
|
4343
|
+
"id",
|
|
4344
|
+
"title",
|
|
4345
|
+
"value",
|
|
4346
|
+
"stage",
|
|
4347
|
+
"contacts",
|
|
4348
|
+
"company",
|
|
4349
|
+
"expected_close",
|
|
4350
|
+
"probability",
|
|
4351
|
+
"tags",
|
|
4352
|
+
"custom_fields",
|
|
4353
|
+
"created_at",
|
|
4354
|
+
"updated_at",
|
|
4355
|
+
"stage_history"
|
|
4356
|
+
]);
|
|
4357
|
+
var ACTIVITY_WRITE_FIELDS = new Set([
|
|
4358
|
+
"id",
|
|
4359
|
+
"type",
|
|
4360
|
+
"body",
|
|
4361
|
+
"note",
|
|
4362
|
+
"contacts",
|
|
4363
|
+
"contact",
|
|
4364
|
+
"company",
|
|
4365
|
+
"deal",
|
|
4366
|
+
"entity_ref",
|
|
4367
|
+
"custom_fields",
|
|
4368
|
+
"created_at"
|
|
4369
|
+
]);
|
|
4370
|
+
var KNOWN_REPORTS = new Set([
|
|
4371
|
+
"pipeline.json",
|
|
4372
|
+
"stale.json",
|
|
4373
|
+
"forecast.json",
|
|
4374
|
+
"conversion.json",
|
|
4375
|
+
"velocity.json",
|
|
4376
|
+
"won.json",
|
|
4377
|
+
"lost.json"
|
|
4378
|
+
]);
|
|
4379
|
+
async function handleRequest(db, config, stages, req) {
|
|
4380
|
+
const { op, path } = req;
|
|
4381
|
+
const p = path.startsWith("/") ? path.slice(1) : path;
|
|
4382
|
+
switch (op) {
|
|
4383
|
+
case "getattr":
|
|
4384
|
+
return await handleGetattr(db, config, p, stages);
|
|
4385
|
+
case "readdir":
|
|
4386
|
+
return await handleReaddir(db, p, stages);
|
|
4387
|
+
case "read":
|
|
4388
|
+
return await handleRead(db, config, p, stages);
|
|
4389
|
+
case "write":
|
|
4390
|
+
return await handleWrite(db, config, p, req.data || "");
|
|
4391
|
+
case "unlink":
|
|
4392
|
+
return await handleUnlink(db, p);
|
|
4393
|
+
default:
|
|
4394
|
+
return { error: "ENOSYS" };
|
|
4395
|
+
}
|
|
4396
|
+
}
|
|
4397
|
+
async function handleGetattr(db, config, p, stages) {
|
|
4398
|
+
const result = await _handleGetattr(db, p, stages);
|
|
4399
|
+
if (result.type === "file") {
|
|
4400
|
+
const readResult = await handleRead(db, config, p, stages);
|
|
4401
|
+
if ("data" in readResult && typeof readResult.data === "string") {
|
|
4402
|
+
return { type: "file", size: Buffer.byteLength(readResult.data, "utf-8") };
|
|
4403
|
+
}
|
|
4404
|
+
}
|
|
4405
|
+
return result;
|
|
4406
|
+
}
|
|
4407
|
+
async function _handleGetattr(db, p, stages) {
|
|
4408
|
+
if (p === "") {
|
|
4409
|
+
return { type: "dir" };
|
|
4410
|
+
}
|
|
4411
|
+
if ([
|
|
4412
|
+
"contacts",
|
|
4413
|
+
"companies",
|
|
4414
|
+
"deals",
|
|
4415
|
+
"activities",
|
|
4416
|
+
"reports",
|
|
4417
|
+
"search"
|
|
4418
|
+
].includes(p)) {
|
|
4419
|
+
return { type: "dir" };
|
|
4420
|
+
}
|
|
4421
|
+
if (p === "pipeline.json" || p === "tags.json") {
|
|
4422
|
+
return { type: "file" };
|
|
4423
|
+
}
|
|
4424
|
+
const parts = p.split("/");
|
|
4425
|
+
if (parts.length === 2) {
|
|
4426
|
+
const [dir, file] = parts;
|
|
4427
|
+
if (["contacts", "companies", "deals", "activities"].includes(dir) && file.endsWith(".json")) {
|
|
4428
|
+
const id = extractId(file);
|
|
4429
|
+
if (id) {
|
|
4430
|
+
const exists = await entityExists(db, dir, id);
|
|
4431
|
+
return exists ? { type: "file" } : { error: "ENOENT" };
|
|
4432
|
+
}
|
|
4433
|
+
return { type: "file" };
|
|
4434
|
+
}
|
|
4435
|
+
if (file.startsWith("_by-")) {
|
|
4436
|
+
return { type: "dir" };
|
|
4437
|
+
}
|
|
4438
|
+
if (dir === "reports") {
|
|
4439
|
+
if (KNOWN_REPORTS.has(file)) {
|
|
4440
|
+
return { type: "file" };
|
|
4441
|
+
}
|
|
4442
|
+
return { error: "ENOENT" };
|
|
4443
|
+
}
|
|
4444
|
+
if (dir === "search" && file.endsWith(".json")) {
|
|
4445
|
+
return { type: "file" };
|
|
4446
|
+
}
|
|
4447
|
+
}
|
|
4448
|
+
if (parts.length === 3) {
|
|
4449
|
+
const [dir, byDir, item] = parts;
|
|
4450
|
+
if (dir === "contacts" && byDir === "_by-tag") {
|
|
4451
|
+
const exists = await tagExists(db, item);
|
|
4452
|
+
return exists ? { type: "dir" } : { error: "ENOENT" };
|
|
4453
|
+
}
|
|
4454
|
+
if (dir === "contacts" && byDir === "_by-company") {
|
|
4455
|
+
const exists = await companySlugExists(db, item);
|
|
4456
|
+
return exists ? { type: "dir" } : { error: "ENOENT" };
|
|
4457
|
+
}
|
|
4458
|
+
if (dir === "deals" && byDir === "_by-stage") {
|
|
4459
|
+
return stages.includes(item) ? { type: "dir" } : { error: "ENOENT" };
|
|
4460
|
+
}
|
|
4461
|
+
if (item.endsWith(".json")) {
|
|
4462
|
+
const val = stripJsonExt(item);
|
|
4463
|
+
const exists = await byIndexExists(db, dir, byDir, val);
|
|
4464
|
+
return exists ? { type: "file" } : { error: "ENOENT" };
|
|
4465
|
+
}
|
|
4466
|
+
}
|
|
4467
|
+
if (parts.length === 4 && parts[0] === "deals" && parts[1] === "_by-stage" && parts[3].endsWith(".json")) {
|
|
4468
|
+
const id = extractId(parts[3]);
|
|
4469
|
+
if (id) {
|
|
4470
|
+
const exists = await entityExists(db, "deals", id);
|
|
4471
|
+
return exists ? { type: "file" } : { error: "ENOENT" };
|
|
4472
|
+
}
|
|
4473
|
+
}
|
|
4474
|
+
if (parts.length === 4 && parts[0] === "contacts" && parts[1].startsWith("_by-") && parts[3].endsWith(".json")) {
|
|
4475
|
+
const id = extractId(parts[3]);
|
|
4476
|
+
if (id) {
|
|
4477
|
+
const exists = await entityExists(db, "contacts", id);
|
|
4478
|
+
return exists ? { type: "file" } : { error: "ENOENT" };
|
|
4479
|
+
}
|
|
4480
|
+
}
|
|
4481
|
+
return { error: "ENOENT" };
|
|
4482
|
+
}
|
|
4483
|
+
async function entityExists(db, entityDir, id) {
|
|
4484
|
+
switch (entityDir) {
|
|
4485
|
+
case "contacts": {
|
|
4486
|
+
const r = await db.select({ id: contacts.id }).from(contacts).where(eq13(contacts.id, id));
|
|
4487
|
+
return r.length > 0;
|
|
4488
|
+
}
|
|
4489
|
+
case "companies": {
|
|
4490
|
+
const r = await db.select({ id: companies.id }).from(companies).where(eq13(companies.id, id));
|
|
4491
|
+
return r.length > 0;
|
|
4492
|
+
}
|
|
4493
|
+
case "deals": {
|
|
4494
|
+
const r = await db.select({ id: deals.id }).from(deals).where(eq13(deals.id, id));
|
|
4495
|
+
return r.length > 0;
|
|
4496
|
+
}
|
|
4497
|
+
case "activities": {
|
|
4498
|
+
const r = await db.select({ id: activities.id }).from(activities).where(eq13(activities.id, id));
|
|
4499
|
+
return r.length > 0;
|
|
4500
|
+
}
|
|
4501
|
+
default:
|
|
4502
|
+
return false;
|
|
4503
|
+
}
|
|
4504
|
+
}
|
|
4505
|
+
async function tagExists(db, tag) {
|
|
4506
|
+
const contacts2 = await db.select({ tags: contacts.tags }).from(contacts);
|
|
4507
|
+
for (const c of contacts2) {
|
|
4508
|
+
const tags = safeJSON(c.tags);
|
|
4509
|
+
if (tags.includes(tag)) {
|
|
4510
|
+
return true;
|
|
4511
|
+
}
|
|
4512
|
+
}
|
|
4513
|
+
const companies2 = await db.select({ tags: companies.tags }).from(companies);
|
|
4514
|
+
for (const co of companies2) {
|
|
4515
|
+
const tags = safeJSON(co.tags);
|
|
4516
|
+
if (tags.includes(tag)) {
|
|
4517
|
+
return true;
|
|
4518
|
+
}
|
|
4519
|
+
}
|
|
4520
|
+
const deals2 = await db.select({ tags: deals.tags }).from(deals);
|
|
4521
|
+
for (const d of deals2) {
|
|
4522
|
+
const tags = safeJSON(d.tags);
|
|
4523
|
+
if (tags.includes(tag)) {
|
|
4524
|
+
return true;
|
|
4525
|
+
}
|
|
4526
|
+
}
|
|
4527
|
+
return false;
|
|
4528
|
+
}
|
|
4529
|
+
async function companySlugExists(db, slug) {
|
|
4530
|
+
const allCompanies = await db.select().from(companies);
|
|
4531
|
+
const contacts2 = await db.select({ companies: contacts.companies }).from(contacts);
|
|
4532
|
+
for (const c of contacts2) {
|
|
4533
|
+
const companyIds = safeJSON(c.companies);
|
|
4534
|
+
for (const compId of companyIds) {
|
|
4535
|
+
const co = allCompanies.find((x) => x.id === compId);
|
|
4536
|
+
if (co && slugify(co.name) === slug) {
|
|
4537
|
+
return true;
|
|
4538
|
+
}
|
|
4539
|
+
}
|
|
4540
|
+
}
|
|
4541
|
+
return false;
|
|
4542
|
+
}
|
|
4543
|
+
async function byIndexExists(db, dir, byDir, val) {
|
|
4544
|
+
if (dir === "contacts") {
|
|
4545
|
+
if (byDir === "_by-email") {
|
|
4546
|
+
const all = await db.select().from(contacts);
|
|
4547
|
+
return all.some((c) => safeJSON(c.emails).includes(val));
|
|
4548
|
+
}
|
|
4549
|
+
if (byDir === "_by-phone") {
|
|
4550
|
+
const all = await db.select().from(contacts);
|
|
4551
|
+
return all.some((c) => safeJSON(c.phones).includes(val));
|
|
4552
|
+
}
|
|
4553
|
+
if (byDir === "_by-linkedin") {
|
|
4554
|
+
const all = await db.select().from(contacts).where(eq13(contacts.linkedin, val));
|
|
4555
|
+
return all.length > 0;
|
|
4556
|
+
}
|
|
4557
|
+
if (byDir === "_by-x") {
|
|
4558
|
+
const all = await db.select().from(contacts).where(eq13(contacts.x, val));
|
|
4559
|
+
return all.length > 0;
|
|
4560
|
+
}
|
|
4561
|
+
if (byDir === "_by-bluesky") {
|
|
4562
|
+
const all = await db.select().from(contacts).where(eq13(contacts.bluesky, val));
|
|
4563
|
+
return all.length > 0;
|
|
4564
|
+
}
|
|
4565
|
+
if (byDir === "_by-telegram") {
|
|
4566
|
+
const all = await db.select().from(contacts).where(eq13(contacts.telegram, val));
|
|
4567
|
+
return all.length > 0;
|
|
4568
|
+
}
|
|
4569
|
+
}
|
|
4570
|
+
if (dir === "companies") {
|
|
4571
|
+
if (byDir === "_by-website") {
|
|
4572
|
+
const all = await db.select().from(companies);
|
|
4573
|
+
return all.some((co) => safeJSON(co.websites).includes(val));
|
|
4574
|
+
}
|
|
4575
|
+
if (byDir === "_by-phone") {
|
|
4576
|
+
const all = await db.select().from(companies);
|
|
4577
|
+
return all.some((co) => safeJSON(co.phones).includes(val));
|
|
4578
|
+
}
|
|
4579
|
+
}
|
|
4580
|
+
return false;
|
|
4581
|
+
}
|
|
4582
|
+
async function handleReaddir(db, p, stages) {
|
|
4583
|
+
if (p === "") {
|
|
4584
|
+
return {
|
|
4585
|
+
entries: [
|
|
4586
|
+
"llm.txt",
|
|
4587
|
+
"contacts",
|
|
4588
|
+
"companies",
|
|
4589
|
+
"deals",
|
|
4590
|
+
"activities",
|
|
4591
|
+
"reports",
|
|
4592
|
+
"search",
|
|
4593
|
+
"pipeline.json",
|
|
4594
|
+
"tags.json"
|
|
4595
|
+
]
|
|
4596
|
+
};
|
|
4597
|
+
}
|
|
4598
|
+
if (p === "contacts") {
|
|
4599
|
+
const contacts2 = await db.select().from(contacts);
|
|
4600
|
+
const files = contacts2.map((c) => `${c.id}...${slugify(c.name || "")}.json`);
|
|
4601
|
+
return {
|
|
4602
|
+
entries: [
|
|
4603
|
+
"_by-email",
|
|
4604
|
+
"_by-phone",
|
|
4605
|
+
"_by-linkedin",
|
|
4606
|
+
"_by-x",
|
|
4607
|
+
"_by-bluesky",
|
|
4608
|
+
"_by-telegram",
|
|
4609
|
+
"_by-company",
|
|
4610
|
+
"_by-tag",
|
|
4611
|
+
...files
|
|
4612
|
+
]
|
|
4613
|
+
};
|
|
4614
|
+
}
|
|
4615
|
+
if (p === "companies") {
|
|
4616
|
+
const companies2 = await db.select().from(companies);
|
|
4617
|
+
const files = companies2.map((co) => `${co.id}...${slugify(co.name || "")}.json`);
|
|
4618
|
+
return {
|
|
4619
|
+
entries: ["_by-website", "_by-phone", "_by-tag", ...files]
|
|
4620
|
+
};
|
|
4621
|
+
}
|
|
4622
|
+
if (p === "deals") {
|
|
4623
|
+
const deals2 = await db.select().from(deals);
|
|
4624
|
+
const files = deals2.map((d) => `${d.id}...${slugify(d.title || "")}.json`);
|
|
4625
|
+
return {
|
|
4626
|
+
entries: ["_by-stage", "_by-company", "_by-tag", ...files]
|
|
4627
|
+
};
|
|
4628
|
+
}
|
|
4629
|
+
if (p === "activities") {
|
|
4630
|
+
return {
|
|
4631
|
+
entries: ["_by-contact", "_by-company", "_by-deal", "_by-type"]
|
|
4632
|
+
};
|
|
4633
|
+
}
|
|
4634
|
+
if (p === "reports") {
|
|
4635
|
+
return {
|
|
4636
|
+
entries: [
|
|
4637
|
+
"pipeline.json",
|
|
4638
|
+
"stale.json",
|
|
4639
|
+
"forecast.json",
|
|
4640
|
+
"conversion.json",
|
|
4641
|
+
"velocity.json",
|
|
4642
|
+
"won.json",
|
|
4643
|
+
"lost.json"
|
|
4644
|
+
]
|
|
4645
|
+
};
|
|
4646
|
+
}
|
|
4647
|
+
if (p === "search") {
|
|
4648
|
+
return { entries: [] };
|
|
4649
|
+
}
|
|
4650
|
+
if (p === "deals/_by-stage") {
|
|
4651
|
+
return { entries: stages };
|
|
4652
|
+
}
|
|
4653
|
+
if (p.startsWith("deals/_by-stage/")) {
|
|
4654
|
+
const stage = p.slice("deals/_by-stage/".length);
|
|
4655
|
+
if (!stage.includes("/")) {
|
|
4656
|
+
const deals2 = await db.select().from(deals).where(eq13(deals.stage, stage));
|
|
4657
|
+
return {
|
|
4658
|
+
entries: deals2.map((d) => `${d.id}...${slugify(d.title || "")}.json`)
|
|
4659
|
+
};
|
|
4660
|
+
}
|
|
4661
|
+
}
|
|
4662
|
+
if (p === "contacts/_by-email") {
|
|
4663
|
+
const all = await db.select().from(contacts);
|
|
4664
|
+
const entries = [];
|
|
4665
|
+
for (const c of all) {
|
|
4666
|
+
for (const e of safeJSON(c.emails)) {
|
|
4667
|
+
entries.push(`${e}.json`);
|
|
4668
|
+
}
|
|
4669
|
+
}
|
|
4670
|
+
return { entries };
|
|
4671
|
+
}
|
|
4672
|
+
if (p === "contacts/_by-phone") {
|
|
4673
|
+
const all = await db.select().from(contacts);
|
|
4674
|
+
const entries = [];
|
|
4675
|
+
for (const c of all) {
|
|
4676
|
+
for (const ph of safeJSON(c.phones)) {
|
|
4677
|
+
entries.push(`${ph}.json`);
|
|
4678
|
+
}
|
|
4679
|
+
}
|
|
4680
|
+
return { entries };
|
|
4681
|
+
}
|
|
4682
|
+
for (const field of ["linkedin", "x", "bluesky", "telegram"]) {
|
|
4683
|
+
if (p === `contacts/_by-${field}`) {
|
|
4684
|
+
const all = await db.select().from(contacts);
|
|
4685
|
+
const entries = all.filter((c) => c[field]).map((c) => `${c[field]}.json`);
|
|
4686
|
+
return { entries };
|
|
4687
|
+
}
|
|
4688
|
+
}
|
|
4689
|
+
if (p === "contacts/_by-tag") {
|
|
4690
|
+
const all = await db.select().from(contacts);
|
|
4691
|
+
const tagSet = new Set;
|
|
4692
|
+
for (const c of all) {
|
|
4693
|
+
for (const t of safeJSON(c.tags)) {
|
|
4694
|
+
tagSet.add(t);
|
|
4695
|
+
}
|
|
4696
|
+
}
|
|
4697
|
+
return { entries: [...tagSet] };
|
|
4698
|
+
}
|
|
4699
|
+
if (p.startsWith("contacts/_by-tag/")) {
|
|
4700
|
+
const tag = p.slice("contacts/_by-tag/".length);
|
|
4701
|
+
if (!tag.includes("/")) {
|
|
4702
|
+
const all = await db.select().from(contacts);
|
|
4703
|
+
const entries = all.filter((c) => safeJSON(c.tags).includes(tag)).map((c) => `${c.id}...${slugify(c.name || "")}.json`);
|
|
4704
|
+
return { entries };
|
|
4705
|
+
}
|
|
4706
|
+
}
|
|
4707
|
+
if (p === "contacts/_by-company") {
|
|
4708
|
+
const allContacts = await db.select().from(contacts);
|
|
4709
|
+
const allCompanies = await db.select().from(companies);
|
|
4710
|
+
const slugSet = new Set;
|
|
4711
|
+
for (const c of allContacts) {
|
|
4712
|
+
for (const compId of safeJSON(c.companies)) {
|
|
4713
|
+
const co = allCompanies.find((x) => x.id === compId);
|
|
4714
|
+
if (co) {
|
|
4715
|
+
slugSet.add(slugify(co.name));
|
|
4716
|
+
}
|
|
4717
|
+
}
|
|
4718
|
+
}
|
|
4719
|
+
return { entries: [...slugSet] };
|
|
4720
|
+
}
|
|
4721
|
+
if (p.startsWith("contacts/_by-company/")) {
|
|
4722
|
+
const cslug = p.slice("contacts/_by-company/".length);
|
|
4723
|
+
if (!cslug.includes("/")) {
|
|
4724
|
+
const allContacts = await db.select().from(contacts);
|
|
4725
|
+
const allCompanies = await db.select().from(companies);
|
|
4726
|
+
const entries = allContacts.filter((c) => {
|
|
4727
|
+
const compIds = safeJSON(c.companies);
|
|
4728
|
+
return compIds.some((compId) => {
|
|
4729
|
+
const co = allCompanies.find((x) => x.id === compId);
|
|
4730
|
+
return co && slugify(co.name) === cslug;
|
|
4731
|
+
});
|
|
4732
|
+
}).map((c) => `${c.id}...${slugify(c.name || "")}.json`);
|
|
4733
|
+
return { entries };
|
|
4734
|
+
}
|
|
4735
|
+
}
|
|
4736
|
+
if (p === "companies/_by-website") {
|
|
4737
|
+
const all = await db.select().from(companies);
|
|
4738
|
+
const entries = [];
|
|
4739
|
+
for (const co of all) {
|
|
4740
|
+
for (const w of safeJSON(co.websites)) {
|
|
4741
|
+
entries.push(`${w}.json`);
|
|
4742
|
+
}
|
|
4743
|
+
}
|
|
4744
|
+
return { entries };
|
|
4745
|
+
}
|
|
4746
|
+
if (p === "companies/_by-phone") {
|
|
4747
|
+
const all = await db.select().from(companies);
|
|
4748
|
+
const entries = [];
|
|
4749
|
+
for (const co of all) {
|
|
4750
|
+
for (const ph of safeJSON(co.phones)) {
|
|
4751
|
+
entries.push(`${ph}.json`);
|
|
4752
|
+
}
|
|
4753
|
+
}
|
|
4754
|
+
return { entries };
|
|
4755
|
+
}
|
|
4756
|
+
if (p === "companies/_by-tag") {
|
|
4757
|
+
const all = await db.select().from(companies);
|
|
4758
|
+
const tagSet = new Set;
|
|
4759
|
+
for (const co of all) {
|
|
4760
|
+
for (const t of safeJSON(co.tags)) {
|
|
4761
|
+
tagSet.add(t);
|
|
4762
|
+
}
|
|
4763
|
+
}
|
|
4764
|
+
return { entries: [...tagSet] };
|
|
4765
|
+
}
|
|
4766
|
+
return { error: "ENOENT" };
|
|
4767
|
+
}
|
|
4768
|
+
async function handleRead(db, config, p, stages) {
|
|
4769
|
+
if (p === "llm.txt") {
|
|
4770
|
+
return { data: LLM_TXT };
|
|
4771
|
+
}
|
|
4772
|
+
if (p === "pipeline.json" || p === "reports/pipeline.json") {
|
|
4773
|
+
const deals2 = await db.select().from(deals);
|
|
4774
|
+
const data = stages.map((stage) => ({
|
|
4775
|
+
stage,
|
|
4776
|
+
count: deals2.filter((d) => d.stage === stage).length,
|
|
4777
|
+
value: deals2.filter((d) => d.stage === stage).reduce((s, d) => s + (d.value || 0), 0)
|
|
4778
|
+
}));
|
|
4779
|
+
return { data: JSON.stringify(data) };
|
|
4780
|
+
}
|
|
4781
|
+
if (p === "tags.json") {
|
|
4782
|
+
const tagCounts = {};
|
|
4783
|
+
const contacts2 = await db.select().from(contacts);
|
|
4784
|
+
for (const c of contacts2) {
|
|
4785
|
+
for (const t of safeJSON(c.tags)) {
|
|
4786
|
+
tagCounts[t] = (tagCounts[t] || 0) + 1;
|
|
4787
|
+
}
|
|
4788
|
+
}
|
|
4789
|
+
const companies2 = await db.select().from(companies);
|
|
4790
|
+
for (const co of companies2) {
|
|
4791
|
+
for (const t of safeJSON(co.tags)) {
|
|
4792
|
+
tagCounts[t] = (tagCounts[t] || 0) + 1;
|
|
4793
|
+
}
|
|
4794
|
+
}
|
|
4795
|
+
const deals2 = await db.select().from(deals);
|
|
4796
|
+
for (const d of deals2) {
|
|
4797
|
+
for (const t of safeJSON(d.tags)) {
|
|
4798
|
+
tagCounts[t] = (tagCounts[t] || 0) + 1;
|
|
4799
|
+
}
|
|
4800
|
+
}
|
|
4801
|
+
const data = Object.entries(tagCounts).map(([tag, count]) => ({
|
|
4802
|
+
tag,
|
|
4803
|
+
count
|
|
4804
|
+
}));
|
|
4805
|
+
return { data: JSON.stringify(data) };
|
|
4806
|
+
}
|
|
4807
|
+
if (p === "reports/stale.json") {
|
|
4808
|
+
const data = await computeStale(db, config);
|
|
4809
|
+
return { data: JSON.stringify(data) };
|
|
4810
|
+
}
|
|
4811
|
+
if (p === "reports/conversion.json") {
|
|
4812
|
+
const data = await computeConversion(db, stages);
|
|
4813
|
+
return { data: JSON.stringify(data) };
|
|
4814
|
+
}
|
|
4815
|
+
if (p === "reports/velocity.json") {
|
|
4816
|
+
const data = await computeVelocity(db, stages);
|
|
4817
|
+
return { data: JSON.stringify(data) };
|
|
4818
|
+
}
|
|
4819
|
+
if (p === "reports/forecast.json") {
|
|
4820
|
+
const data = await computeForecast(db, config);
|
|
4821
|
+
return { data: JSON.stringify(data) };
|
|
4822
|
+
}
|
|
4823
|
+
if (p === "reports/won.json") {
|
|
4824
|
+
const data = await computeWon(db, config);
|
|
4825
|
+
return { data: JSON.stringify(data) };
|
|
4826
|
+
}
|
|
4827
|
+
if (p === "reports/lost.json") {
|
|
4828
|
+
const data = await computeLost(db, config);
|
|
4829
|
+
return { data: JSON.stringify(data) };
|
|
4830
|
+
}
|
|
4831
|
+
if (p.startsWith("search/") && p.endsWith(".json")) {
|
|
4832
|
+
const query = stripJsonExt(p.slice("search/".length));
|
|
4833
|
+
return handleSearch(db, query);
|
|
4834
|
+
}
|
|
4835
|
+
if (p.startsWith("contacts/")) {
|
|
4836
|
+
return readContactPath(db, config, p.slice("contacts/".length));
|
|
4837
|
+
}
|
|
4838
|
+
if (p.startsWith("companies/")) {
|
|
4839
|
+
return readCompanyPath(db, p.slice("companies/".length));
|
|
4840
|
+
}
|
|
4841
|
+
if (p.startsWith("deals/")) {
|
|
4842
|
+
return readDealPath(db, p.slice("deals/".length));
|
|
4843
|
+
}
|
|
4844
|
+
if (p.startsWith("activities/")) {
|
|
4845
|
+
return readActivityPath(db, p.slice("activities/".length));
|
|
4846
|
+
}
|
|
4847
|
+
return { error: "ENOENT" };
|
|
4848
|
+
}
|
|
4849
|
+
async function readContactPath(db, config, sub) {
|
|
4850
|
+
if (sub.startsWith("_by-email/")) {
|
|
4851
|
+
const email = stripJsonExt(sub.slice("_by-email/".length));
|
|
4852
|
+
const all = await db.select().from(contacts);
|
|
4853
|
+
const c = all.find((x) => safeJSON(x.emails).includes(email));
|
|
4854
|
+
if (!c) {
|
|
4855
|
+
return { error: "ENOENT" };
|
|
4856
|
+
}
|
|
4857
|
+
return { data: JSON.stringify(await buildContactJSON(db, c, config)) };
|
|
4858
|
+
}
|
|
4859
|
+
if (sub.startsWith("_by-phone/")) {
|
|
4860
|
+
const phone = stripJsonExt(sub.slice("_by-phone/".length));
|
|
4861
|
+
const all = await db.select().from(contacts);
|
|
4862
|
+
const c = all.find((x) => safeJSON(x.phones).includes(phone));
|
|
4863
|
+
if (!c) {
|
|
4864
|
+
return { error: "ENOENT" };
|
|
4865
|
+
}
|
|
4866
|
+
return { data: JSON.stringify(await buildContactJSON(db, c, config)) };
|
|
4867
|
+
}
|
|
4868
|
+
for (const field of ["linkedin", "x", "bluesky", "telegram"]) {
|
|
4869
|
+
if (sub.startsWith(`_by-${field}/`)) {
|
|
4870
|
+
const handle = stripJsonExt(sub.slice(`_by-${field}/`.length));
|
|
4871
|
+
const results = await db.select().from(contacts).where(eq13(contacts[field], handle));
|
|
4872
|
+
if (!results[0]) {
|
|
4873
|
+
return { error: "ENOENT" };
|
|
4874
|
+
}
|
|
4875
|
+
return {
|
|
4876
|
+
data: JSON.stringify(await buildContactJSON(db, results[0], config))
|
|
4877
|
+
};
|
|
4878
|
+
}
|
|
4879
|
+
}
|
|
4880
|
+
if (sub.startsWith("_by-tag/") || sub.startsWith("_by-company/")) {
|
|
4881
|
+
const lastSlash = sub.lastIndexOf("/");
|
|
4882
|
+
const file = sub.slice(lastSlash + 1);
|
|
4883
|
+
const id2 = extractId(file);
|
|
4884
|
+
if (id2) {
|
|
4885
|
+
const results = await db.select().from(contacts).where(eq13(contacts.id, id2));
|
|
4886
|
+
if (!results[0]) {
|
|
4887
|
+
return { error: "ENOENT" };
|
|
4888
|
+
}
|
|
4889
|
+
return {
|
|
4890
|
+
data: JSON.stringify(await buildContactJSON(db, results[0], config))
|
|
4891
|
+
};
|
|
4892
|
+
}
|
|
4893
|
+
}
|
|
4894
|
+
const id = extractId(sub);
|
|
4895
|
+
if (id) {
|
|
4896
|
+
const results = await db.select().from(contacts).where(eq13(contacts.id, id));
|
|
4897
|
+
if (!results[0]) {
|
|
4898
|
+
return { error: "ENOENT" };
|
|
4899
|
+
}
|
|
4900
|
+
return {
|
|
4901
|
+
data: JSON.stringify(await buildContactJSON(db, results[0], config))
|
|
4902
|
+
};
|
|
4903
|
+
}
|
|
4904
|
+
return { error: "ENOENT" };
|
|
4905
|
+
}
|
|
4906
|
+
async function readCompanyPath(db, sub) {
|
|
4907
|
+
if (sub.startsWith("_by-website/")) {
|
|
4908
|
+
const website = stripJsonExt(sub.slice("_by-website/".length));
|
|
4909
|
+
const all = await db.select().from(companies);
|
|
4910
|
+
const co = all.find((x) => safeJSON(x.websites).includes(website));
|
|
4911
|
+
if (!co) {
|
|
4912
|
+
return { error: "ENOENT" };
|
|
4913
|
+
}
|
|
4914
|
+
return { data: JSON.stringify(await buildCompanyJSON(db, co)) };
|
|
4915
|
+
}
|
|
4916
|
+
if (sub.startsWith("_by-phone/")) {
|
|
4917
|
+
const phone = stripJsonExt(sub.slice("_by-phone/".length));
|
|
4918
|
+
const all = await db.select().from(companies);
|
|
4919
|
+
const co = all.find((x) => safeJSON(x.phones).includes(phone));
|
|
4920
|
+
if (!co) {
|
|
4921
|
+
return { error: "ENOENT" };
|
|
4922
|
+
}
|
|
4923
|
+
return { data: JSON.stringify(await buildCompanyJSON(db, co)) };
|
|
4924
|
+
}
|
|
4925
|
+
const id = extractId(sub);
|
|
4926
|
+
if (id) {
|
|
4927
|
+
const results = await db.select().from(companies).where(eq13(companies.id, id));
|
|
4928
|
+
if (!results[0]) {
|
|
4929
|
+
return { error: "ENOENT" };
|
|
4930
|
+
}
|
|
4931
|
+
return { data: JSON.stringify(await buildCompanyJSON(db, results[0])) };
|
|
4932
|
+
}
|
|
4933
|
+
return { error: "ENOENT" };
|
|
4934
|
+
}
|
|
4935
|
+
async function readDealPath(db, sub) {
|
|
4936
|
+
if (sub.startsWith("_by-stage/")) {
|
|
4937
|
+
const rest = sub.slice("_by-stage/".length);
|
|
4938
|
+
const slash = rest.indexOf("/");
|
|
4939
|
+
if (slash !== -1) {
|
|
4940
|
+
const file = rest.slice(slash + 1);
|
|
4941
|
+
const id2 = extractId(file);
|
|
4942
|
+
if (id2) {
|
|
4943
|
+
const results = await db.select().from(deals).where(eq13(deals.id, id2));
|
|
4944
|
+
if (!results[0]) {
|
|
4945
|
+
return { error: "ENOENT" };
|
|
4946
|
+
}
|
|
4947
|
+
return { data: JSON.stringify(await buildDealJSON(db, results[0])) };
|
|
4948
|
+
}
|
|
4949
|
+
}
|
|
4950
|
+
}
|
|
4951
|
+
const id = extractId(sub);
|
|
4952
|
+
if (id) {
|
|
4953
|
+
const results = await db.select().from(deals).where(eq13(deals.id, id));
|
|
4954
|
+
if (!results[0]) {
|
|
4955
|
+
return { error: "ENOENT" };
|
|
4956
|
+
}
|
|
4957
|
+
return { data: JSON.stringify(await buildDealJSON(db, results[0])) };
|
|
4958
|
+
}
|
|
4959
|
+
return { error: "ENOENT" };
|
|
4960
|
+
}
|
|
4961
|
+
async function readActivityPath(db, sub) {
|
|
4962
|
+
const id = extractId(sub);
|
|
4963
|
+
if (id) {
|
|
4964
|
+
const results = await db.select().from(activities).where(eq13(activities.id, id));
|
|
4965
|
+
if (!results[0]) {
|
|
4966
|
+
return { error: "ENOENT" };
|
|
4967
|
+
}
|
|
4968
|
+
return { data: JSON.stringify(buildActivityJSON(results[0])) };
|
|
4969
|
+
}
|
|
4970
|
+
return { error: "ENOENT" };
|
|
4971
|
+
}
|
|
4972
|
+
async function handleSearch(db, query) {
|
|
4973
|
+
const contacts2 = await db.select().from(contacts);
|
|
4974
|
+
const companies2 = await db.select().from(companies);
|
|
4975
|
+
const deals2 = await db.select().from(deals);
|
|
4976
|
+
const results = [];
|
|
4977
|
+
for (const c of contacts2) {
|
|
4978
|
+
if (c.name?.includes(query) || safeJSON(c.emails).some((e) => e.includes(query))) {
|
|
4979
|
+
results.push({
|
|
4980
|
+
type: "contact",
|
|
4981
|
+
id: c.id,
|
|
4982
|
+
name: c.name,
|
|
4983
|
+
emails: safeJSON(c.emails)
|
|
4984
|
+
});
|
|
4985
|
+
}
|
|
4986
|
+
}
|
|
4987
|
+
for (const co of companies2) {
|
|
4988
|
+
if (co.name?.includes(query)) {
|
|
4989
|
+
results.push({ type: "company", id: co.id, name: co.name });
|
|
4990
|
+
}
|
|
4991
|
+
}
|
|
4992
|
+
for (const d of deals2) {
|
|
4993
|
+
if (d.title?.includes(query)) {
|
|
4994
|
+
results.push({ type: "deal", id: d.id, title: d.title });
|
|
4995
|
+
}
|
|
4996
|
+
}
|
|
4997
|
+
return { data: JSON.stringify(results) };
|
|
4998
|
+
}
|
|
4999
|
+
async function handleWrite(db, config, p, rawData) {
|
|
5000
|
+
let data;
|
|
5001
|
+
try {
|
|
5002
|
+
data = JSON.parse(rawData);
|
|
5003
|
+
} catch {
|
|
5004
|
+
return { error: "EINVAL", msg: "malformed JSON" };
|
|
5005
|
+
}
|
|
5006
|
+
if (typeof data !== "object" || data === null || Array.isArray(data)) {
|
|
5007
|
+
return { error: "EINVAL", msg: "expected JSON object" };
|
|
5008
|
+
}
|
|
5009
|
+
if (p.startsWith("contacts/")) {
|
|
5010
|
+
return await writeContact(db, config, p.slice("contacts/".length), data);
|
|
5011
|
+
}
|
|
5012
|
+
if (p.startsWith("companies/")) {
|
|
5013
|
+
return await writeCompany(db, config, p.slice("companies/".length), data);
|
|
5014
|
+
}
|
|
5015
|
+
if (p.startsWith("deals/")) {
|
|
5016
|
+
return await writeDeal(db, config, p.slice("deals/".length), data);
|
|
5017
|
+
}
|
|
5018
|
+
if (p.startsWith("activities/")) {
|
|
5019
|
+
return await writeActivity(db, config, p.slice("activities/".length), data);
|
|
5020
|
+
}
|
|
5021
|
+
return { error: "EPERM" };
|
|
5022
|
+
}
|
|
5023
|
+
async function writeContact(db, config, sub, data) {
|
|
5024
|
+
const customFieldKeys = [];
|
|
5025
|
+
for (const key of Object.keys(data)) {
|
|
5026
|
+
if (!CONTACT_WRITE_FIELDS.has(key)) {
|
|
5027
|
+
return { error: "EINVAL", msg: `unknown field: ${key}` };
|
|
5028
|
+
}
|
|
5029
|
+
if ([
|
|
5030
|
+
"title",
|
|
5031
|
+
"department",
|
|
5032
|
+
"source",
|
|
5033
|
+
"notes",
|
|
5034
|
+
"address",
|
|
5035
|
+
"birthday",
|
|
5036
|
+
"website",
|
|
5037
|
+
"job_title",
|
|
5038
|
+
"role",
|
|
5039
|
+
"position"
|
|
5040
|
+
].includes(key)) {
|
|
5041
|
+
customFieldKeys.push(key);
|
|
5042
|
+
}
|
|
5043
|
+
}
|
|
5044
|
+
if (data.emails !== undefined && !Array.isArray(data.emails)) {
|
|
5045
|
+
return { error: "EINVAL", msg: "emails must be an array" };
|
|
5046
|
+
}
|
|
5047
|
+
if (data.phones !== undefined && !Array.isArray(data.phones)) {
|
|
5048
|
+
return { error: "EINVAL", msg: "phones must be an array" };
|
|
5049
|
+
}
|
|
5050
|
+
let phones = data.phones || data.phone;
|
|
5051
|
+
if (typeof phones === "string") {
|
|
5052
|
+
phones = [phones];
|
|
5053
|
+
}
|
|
5054
|
+
if (phones && Array.isArray(phones)) {
|
|
5055
|
+
const normalized = [];
|
|
5056
|
+
for (const p of phones) {
|
|
5057
|
+
try {
|
|
5058
|
+
normalized.push(normalizePhone(p, config.phone.default_country));
|
|
5059
|
+
} catch {
|
|
5060
|
+
return { error: "EINVAL", msg: `invalid phone: ${p}` };
|
|
5061
|
+
}
|
|
5062
|
+
}
|
|
5063
|
+
phones = normalized;
|
|
5064
|
+
}
|
|
5065
|
+
const id = extractId(sub);
|
|
5066
|
+
const now2 = new Date().toISOString();
|
|
5067
|
+
let customFields = data.custom_fields || {};
|
|
5068
|
+
for (const key of customFieldKeys) {
|
|
5069
|
+
customFields = { ...customFields, [key]: data[key] };
|
|
5070
|
+
}
|
|
5071
|
+
const cfStr = Object.keys(customFields).length > 0 ? JSON.stringify(customFields) : "{}";
|
|
5072
|
+
let emails = data.emails;
|
|
5073
|
+
if (!emails && data.email) {
|
|
5074
|
+
emails = Array.isArray(data.email) ? data.email : [data.email];
|
|
5075
|
+
}
|
|
5076
|
+
let companies2 = data.companies;
|
|
5077
|
+
if (!companies2 && data.company) {
|
|
5078
|
+
companies2 = Array.isArray(data.company) ? data.company : [data.company];
|
|
5079
|
+
}
|
|
5080
|
+
if (companies2 && Array.isArray(companies2)) {
|
|
5081
|
+
const resolved = [];
|
|
5082
|
+
for (const c of companies2) {
|
|
5083
|
+
if (typeof c === "object" && c !== null && "id" in c) {
|
|
5084
|
+
resolved.push(c.id);
|
|
5085
|
+
} else if (typeof c === "string" && c.startsWith("co_")) {
|
|
5086
|
+
resolved.push(c);
|
|
5087
|
+
} else if (typeof c === "string") {
|
|
5088
|
+
const coId = await getOrCreateCompanyId(db, c);
|
|
5089
|
+
resolved.push(coId);
|
|
5090
|
+
}
|
|
5091
|
+
}
|
|
5092
|
+
companies2 = resolved;
|
|
5093
|
+
}
|
|
5094
|
+
if (id) {
|
|
5095
|
+
const existing = await db.select().from(contacts).where(eq13(contacts.id, id));
|
|
5096
|
+
if (!existing[0]) {
|
|
5097
|
+
return { error: "ENOENT" };
|
|
5098
|
+
}
|
|
5099
|
+
await db.update(contacts).set({
|
|
5100
|
+
name: data.name || existing[0].name,
|
|
5101
|
+
emails: JSON.stringify(emails || safeJSON(existing[0].emails)),
|
|
5102
|
+
phones: JSON.stringify(phones || safeJSON(existing[0].phones)),
|
|
5103
|
+
companies: JSON.stringify(companies2 ?? safeJSON(existing[0].companies)),
|
|
5104
|
+
linkedin: data.linkedin === undefined ? existing[0].linkedin : data.linkedin,
|
|
5105
|
+
x: data.x === undefined ? existing[0].x : data.x,
|
|
5106
|
+
bluesky: data.bluesky === undefined ? existing[0].bluesky : data.bluesky,
|
|
5107
|
+
telegram: data.telegram === undefined ? existing[0].telegram : data.telegram,
|
|
5108
|
+
tags: data.tags === undefined ? existing[0].tags : JSON.stringify(data.tags),
|
|
5109
|
+
custom_fields: cfStr,
|
|
5110
|
+
updated_at: now2
|
|
5111
|
+
}).where(eq13(contacts.id, id));
|
|
5112
|
+
return { ok: true };
|
|
5113
|
+
}
|
|
5114
|
+
if (!data.name) {
|
|
5115
|
+
return { error: "EINVAL", msg: "missing required field: name" };
|
|
5116
|
+
}
|
|
5117
|
+
const newId = makeId2("ct");
|
|
5118
|
+
await db.insert(contacts).values({
|
|
5119
|
+
id: newId,
|
|
5120
|
+
name: data.name,
|
|
5121
|
+
emails: JSON.stringify(emails || []),
|
|
5122
|
+
phones: JSON.stringify(phones || []),
|
|
5123
|
+
companies: JSON.stringify(companies2 || []),
|
|
5124
|
+
linkedin: data.linkedin || null,
|
|
5125
|
+
x: data.x || null,
|
|
5126
|
+
bluesky: data.bluesky || null,
|
|
5127
|
+
telegram: data.telegram || null,
|
|
5128
|
+
tags: JSON.stringify(data.tags || []),
|
|
5129
|
+
custom_fields: cfStr,
|
|
5130
|
+
created_at: now2,
|
|
5131
|
+
updated_at: now2
|
|
5132
|
+
});
|
|
5133
|
+
return { ok: true };
|
|
5134
|
+
}
|
|
5135
|
+
async function writeCompany(db, config, sub, data) {
|
|
5136
|
+
const customFieldKeys = [];
|
|
5137
|
+
for (const key of Object.keys(data)) {
|
|
5138
|
+
if (!COMPANY_WRITE_FIELDS.has(key)) {
|
|
5139
|
+
return { error: "EINVAL", msg: `unknown field: ${key}` };
|
|
5140
|
+
}
|
|
5141
|
+
if ([
|
|
5142
|
+
"industry",
|
|
5143
|
+
"address",
|
|
5144
|
+
"notes",
|
|
5145
|
+
"source",
|
|
5146
|
+
"description",
|
|
5147
|
+
"size"
|
|
5148
|
+
].includes(key)) {
|
|
5149
|
+
customFieldKeys.push(key);
|
|
5150
|
+
}
|
|
5151
|
+
}
|
|
5152
|
+
let phones = data.phones || data.phone;
|
|
5153
|
+
if (typeof phones === "string") {
|
|
5154
|
+
phones = [phones];
|
|
5155
|
+
}
|
|
5156
|
+
if (phones && Array.isArray(phones)) {
|
|
5157
|
+
const normalized = [];
|
|
5158
|
+
for (const p of phones) {
|
|
5159
|
+
try {
|
|
5160
|
+
normalized.push(normalizePhone(p, config.phone.default_country));
|
|
5161
|
+
} catch {
|
|
5162
|
+
return { error: "EINVAL", msg: `invalid phone: ${p}` };
|
|
5163
|
+
}
|
|
5164
|
+
}
|
|
5165
|
+
phones = normalized;
|
|
5166
|
+
}
|
|
5167
|
+
let customFields = data.custom_fields || {};
|
|
5168
|
+
for (const key of customFieldKeys) {
|
|
5169
|
+
customFields = { ...customFields, [key]: data[key] };
|
|
5170
|
+
}
|
|
5171
|
+
const cfStr = Object.keys(customFields).length > 0 ? JSON.stringify(customFields) : "{}";
|
|
5172
|
+
let websites = data.websites;
|
|
5173
|
+
if (!websites && data.website) {
|
|
5174
|
+
websites = Array.isArray(data.website) ? data.website : [data.website];
|
|
5175
|
+
}
|
|
5176
|
+
const id = extractId(sub);
|
|
5177
|
+
const now2 = new Date().toISOString();
|
|
5178
|
+
if (id) {
|
|
5179
|
+
const existing = await db.select().from(companies).where(eq13(companies.id, id));
|
|
5180
|
+
if (!existing[0]) {
|
|
5181
|
+
return { error: "ENOENT" };
|
|
5182
|
+
}
|
|
5183
|
+
await db.update(companies).set({
|
|
5184
|
+
name: data.name || existing[0].name,
|
|
5185
|
+
websites: JSON.stringify(websites || safeJSON(existing[0].websites)),
|
|
5186
|
+
phones: JSON.stringify(phones || safeJSON(existing[0].phones)),
|
|
5187
|
+
tags: data.tags === undefined ? existing[0].tags : JSON.stringify(data.tags),
|
|
5188
|
+
custom_fields: cfStr,
|
|
5189
|
+
updated_at: now2
|
|
5190
|
+
}).where(eq13(companies.id, id));
|
|
5191
|
+
return { ok: true };
|
|
5192
|
+
}
|
|
5193
|
+
if (!data.name) {
|
|
5194
|
+
return { error: "EINVAL", msg: "missing required field: name" };
|
|
5195
|
+
}
|
|
5196
|
+
const newId = makeId2("co");
|
|
5197
|
+
await db.insert(companies).values({
|
|
5198
|
+
id: newId,
|
|
5199
|
+
name: data.name,
|
|
5200
|
+
websites: JSON.stringify(websites || []),
|
|
5201
|
+
phones: JSON.stringify(phones || []),
|
|
5202
|
+
tags: JSON.stringify(data.tags || []),
|
|
5203
|
+
custom_fields: cfStr,
|
|
5204
|
+
created_at: now2,
|
|
5205
|
+
updated_at: now2
|
|
5206
|
+
});
|
|
5207
|
+
return { ok: true };
|
|
5208
|
+
}
|
|
5209
|
+
async function writeDeal(db, config, sub, data) {
|
|
5210
|
+
for (const key of Object.keys(data)) {
|
|
5211
|
+
if (!DEAL_WRITE_FIELDS.has(key)) {
|
|
5212
|
+
return { error: "EINVAL", msg: `unknown field: ${key}` };
|
|
5213
|
+
}
|
|
5214
|
+
}
|
|
5215
|
+
const id = extractId(sub);
|
|
5216
|
+
const now2 = new Date().toISOString();
|
|
5217
|
+
if (id) {
|
|
5218
|
+
const existing = await db.select().from(deals).where(eq13(deals.id, id));
|
|
5219
|
+
if (!existing[0]) {
|
|
5220
|
+
return { error: "ENOENT" };
|
|
5221
|
+
}
|
|
5222
|
+
if (data.stage && data.stage !== existing[0].stage) {
|
|
5223
|
+
const acId = makeId2("ac");
|
|
5224
|
+
await db.insert(activities).values({
|
|
5225
|
+
id: acId,
|
|
5226
|
+
type: "stage-change",
|
|
5227
|
+
body: `from ${existing[0].stage} to ${data.stage}`,
|
|
5228
|
+
deal: id,
|
|
5229
|
+
contacts: "[]",
|
|
5230
|
+
company: null,
|
|
5231
|
+
custom_fields: "{}",
|
|
5232
|
+
created_at: now2
|
|
5233
|
+
});
|
|
5234
|
+
}
|
|
5235
|
+
await db.update(deals).set({
|
|
5236
|
+
title: data.title || existing[0].title,
|
|
5237
|
+
value: data.value === undefined ? existing[0].value : data.value,
|
|
5238
|
+
stage: data.stage || existing[0].stage,
|
|
5239
|
+
contacts: data.contacts === undefined ? existing[0].contacts : JSON.stringify(Array.isArray(data.contacts) ? data.contacts.map((c) => typeof c === "object" && c !== null && ("id" in c) ? c.id : c) : data.contacts),
|
|
5240
|
+
company: data.company === undefined ? existing[0].company : extractCompanyId(data.company),
|
|
5241
|
+
expected_close: data.expected_close === undefined ? existing[0].expected_close : data.expected_close,
|
|
5242
|
+
probability: data.probability === undefined ? existing[0].probability : data.probability,
|
|
5243
|
+
tags: data.tags === undefined ? existing[0].tags : JSON.stringify(data.tags),
|
|
5244
|
+
custom_fields: data.custom_fields === undefined ? existing[0].custom_fields : JSON.stringify(data.custom_fields),
|
|
5245
|
+
updated_at: now2
|
|
5246
|
+
}).where(eq13(deals.id, id));
|
|
5247
|
+
return { ok: true };
|
|
5248
|
+
}
|
|
5249
|
+
if (!data.title) {
|
|
5250
|
+
return { error: "EINVAL", msg: "missing required field: title" };
|
|
5251
|
+
}
|
|
5252
|
+
const newId = makeId2("dl");
|
|
5253
|
+
await db.insert(deals).values({
|
|
5254
|
+
id: newId,
|
|
5255
|
+
title: data.title,
|
|
5256
|
+
value: data.value || null,
|
|
5257
|
+
stage: data.stage || config.pipeline.stages[0] || "lead",
|
|
5258
|
+
contacts: JSON.stringify(data.contacts || []),
|
|
5259
|
+
company: data.company || null,
|
|
5260
|
+
expected_close: data.expected_close || null,
|
|
5261
|
+
probability: data.probability || null,
|
|
5262
|
+
tags: JSON.stringify(data.tags || []),
|
|
5263
|
+
custom_fields: JSON.stringify(data.custom_fields || {}),
|
|
5264
|
+
created_at: now2,
|
|
5265
|
+
updated_at: now2
|
|
5266
|
+
});
|
|
5267
|
+
return { ok: true };
|
|
5268
|
+
}
|
|
5269
|
+
async function writeActivity(db, config, _sub, data) {
|
|
5270
|
+
for (const key of Object.keys(data)) {
|
|
5271
|
+
if (!ACTIVITY_WRITE_FIELDS.has(key)) {
|
|
5272
|
+
return { error: "EINVAL", msg: `unknown field: ${key}` };
|
|
5273
|
+
}
|
|
5274
|
+
}
|
|
5275
|
+
if (!data.type) {
|
|
5276
|
+
return { error: "EINVAL", msg: "missing required field: type" };
|
|
5277
|
+
}
|
|
5278
|
+
const contacts2 = [];
|
|
5279
|
+
if (data.contacts && Array.isArray(data.contacts)) {
|
|
5280
|
+
contacts2.push(...data.contacts);
|
|
5281
|
+
} else if (data.contact) {
|
|
5282
|
+
contacts2.push(data.contact);
|
|
5283
|
+
}
|
|
5284
|
+
if (data.entity_ref && contacts2.length === 0) {
|
|
5285
|
+
const resolved = await resolveContact(db, data.entity_ref, config);
|
|
5286
|
+
if (resolved) {
|
|
5287
|
+
contacts2.push(resolved.id);
|
|
5288
|
+
}
|
|
5289
|
+
}
|
|
5290
|
+
const body = data.note || data.body || "";
|
|
5291
|
+
const now2 = new Date().toISOString();
|
|
5292
|
+
const newId = makeId2("ac");
|
|
5293
|
+
await db.insert(activities).values({
|
|
5294
|
+
id: newId,
|
|
5295
|
+
type: data.type,
|
|
5296
|
+
body,
|
|
5297
|
+
contacts: JSON.stringify(contacts2),
|
|
5298
|
+
company: data.company || null,
|
|
5299
|
+
deal: data.deal || null,
|
|
5300
|
+
custom_fields: JSON.stringify(data.custom_fields || {}),
|
|
5301
|
+
created_at: now2
|
|
5302
|
+
});
|
|
5303
|
+
return { ok: true };
|
|
5304
|
+
}
|
|
5305
|
+
async function handleUnlink(db, p) {
|
|
5306
|
+
if (p.startsWith("contacts/_by-") || p.startsWith("companies/_by-") || p.startsWith("deals/_by-")) {
|
|
5307
|
+
return { error: "EPERM" };
|
|
5308
|
+
}
|
|
5309
|
+
if (p.startsWith("contacts/")) {
|
|
5310
|
+
const file = p.slice("contacts/".length);
|
|
5311
|
+
const id = extractId(file);
|
|
5312
|
+
if (id) {
|
|
5313
|
+
await db.delete(contacts).where(eq13(contacts.id, id));
|
|
5314
|
+
await removeSearchIndex(db, id);
|
|
5315
|
+
return { ok: true };
|
|
5316
|
+
}
|
|
5317
|
+
}
|
|
5318
|
+
if (p.startsWith("companies/")) {
|
|
5319
|
+
const file = p.slice("companies/".length);
|
|
5320
|
+
const id = extractId(file);
|
|
5321
|
+
if (id) {
|
|
5322
|
+
await db.delete(companies).where(eq13(companies.id, id));
|
|
5323
|
+
await removeSearchIndex(db, id);
|
|
5324
|
+
return { ok: true };
|
|
5325
|
+
}
|
|
5326
|
+
}
|
|
5327
|
+
if (p.startsWith("deals/")) {
|
|
5328
|
+
const file = p.slice("deals/".length);
|
|
5329
|
+
const id = extractId(file);
|
|
5330
|
+
if (id) {
|
|
5331
|
+
await db.delete(deals).where(eq13(deals.id, id));
|
|
5332
|
+
await removeSearchIndex(db, id);
|
|
5333
|
+
return { ok: true };
|
|
5334
|
+
}
|
|
5335
|
+
}
|
|
5336
|
+
if (p.startsWith("activities/")) {
|
|
5337
|
+
const file = p.slice("activities/".length);
|
|
5338
|
+
const id = extractId(file);
|
|
5339
|
+
if (id) {
|
|
5340
|
+
await db.delete(activities).where(eq13(activities.id, id));
|
|
5341
|
+
await removeSearchIndex(db, id);
|
|
5342
|
+
return { ok: true };
|
|
5343
|
+
}
|
|
5344
|
+
}
|
|
5345
|
+
return { error: "EPERM" };
|
|
5346
|
+
}
|
|
5347
|
+
async function startDaemon(daemonArgs) {
|
|
5348
|
+
if (daemonArgs.length < 2) {
|
|
5349
|
+
console.error("Usage: crm __daemon <socket-path> <db-path> [stage1 stage2 ...]");
|
|
5350
|
+
process.exit(1);
|
|
5351
|
+
}
|
|
5352
|
+
const socketPath = daemonArgs[0];
|
|
5353
|
+
const dbPath = daemonArgs[1];
|
|
5354
|
+
const stages = daemonArgs.slice(2);
|
|
5355
|
+
if (stages.length === 0) {
|
|
5356
|
+
stages.push("lead", "qualified", "proposal", "negotiation", "closed-won", "closed-lost");
|
|
5357
|
+
}
|
|
5358
|
+
if (existsSync4(socketPath)) {
|
|
5359
|
+
unlinkSync2(socketPath);
|
|
5360
|
+
}
|
|
5361
|
+
const db = await openDB(dbPath);
|
|
5362
|
+
const config = loadConfig({ dbPath });
|
|
5363
|
+
config.database.path = dbPath;
|
|
5364
|
+
const server = createServer((conn) => {
|
|
5365
|
+
let buffer = "";
|
|
5366
|
+
conn.on("data", (chunk) => {
|
|
5367
|
+
buffer += chunk.toString();
|
|
5368
|
+
for (;; ) {
|
|
5369
|
+
const newlineIdx = buffer.indexOf(`
|
|
5370
|
+
`);
|
|
5371
|
+
if (newlineIdx === -1) {
|
|
5372
|
+
break;
|
|
5373
|
+
}
|
|
5374
|
+
const line = buffer.slice(0, newlineIdx);
|
|
5375
|
+
buffer = buffer.slice(newlineIdx + 1);
|
|
5376
|
+
if (line.trim()) {
|
|
5377
|
+
processLine(conn, db, config, stages, line);
|
|
5378
|
+
}
|
|
5379
|
+
}
|
|
5380
|
+
});
|
|
5381
|
+
conn.on("error", () => {});
|
|
5382
|
+
});
|
|
5383
|
+
server.listen(socketPath, () => {
|
|
5384
|
+
process.stdout.write(`READY
|
|
5385
|
+
`);
|
|
5386
|
+
});
|
|
5387
|
+
process.on("SIGTERM", () => {
|
|
5388
|
+
server.close();
|
|
5389
|
+
if (existsSync4(socketPath)) {
|
|
5390
|
+
unlinkSync2(socketPath);
|
|
5391
|
+
}
|
|
5392
|
+
process.exit(0);
|
|
5393
|
+
});
|
|
5394
|
+
process.on("SIGINT", () => {
|
|
5395
|
+
server.close();
|
|
5396
|
+
if (existsSync4(socketPath)) {
|
|
5397
|
+
unlinkSync2(socketPath);
|
|
5398
|
+
}
|
|
5399
|
+
process.exit(0);
|
|
5400
|
+
});
|
|
5401
|
+
}
|
|
5402
|
+
async function processLine(conn, db, config, stages, line) {
|
|
5403
|
+
try {
|
|
5404
|
+
const req = JSON.parse(line);
|
|
5405
|
+
const resp = await handleRequest(db, config, stages, req);
|
|
5406
|
+
conn.write(`${JSON.stringify(resp)}
|
|
5407
|
+
`);
|
|
5408
|
+
} catch (err) {
|
|
5409
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
5410
|
+
conn.write(`${JSON.stringify({ error: "EIO", msg })}
|
|
5411
|
+
`);
|
|
5412
|
+
}
|
|
5413
|
+
}
|
|
5414
|
+
if (process.argv[1]?.endsWith("fuse-daemon.ts")) {
|
|
5415
|
+
startDaemon(process.argv.slice(2)).catch((err) => {
|
|
5416
|
+
console.error("fuse-daemon fatal:", err);
|
|
5417
|
+
process.exit(1);
|
|
5418
|
+
});
|
|
5419
|
+
}
|
|
5420
|
+
|
|
4263
5421
|
// src/cli.ts
|
|
4264
5422
|
var program = new Command;
|
|
4265
|
-
program.name("crm").description("Headless CLI-first CRM").version("0.1
|
|
5423
|
+
program.name("crm").description("Headless CLI-first CRM").version("0.3.1");
|
|
4266
5424
|
program.exitOverride();
|
|
4267
5425
|
registerContactCommands(program);
|
|
4268
5426
|
registerCompanyCommands(program);
|
|
@@ -4276,16 +5434,23 @@ registerReportCommands(program);
|
|
|
4276
5434
|
registerImportExportCommands(program);
|
|
4277
5435
|
registerDupesCommand(program);
|
|
4278
5436
|
registerFuseCommands(program);
|
|
4279
|
-
|
|
4280
|
-
|
|
4281
|
-
|
|
4282
|
-
|
|
4283
|
-
|
|
4284
|
-
|
|
4285
|
-
|
|
4286
|
-
|
|
4287
|
-
|
|
5437
|
+
if (cleanArgv[0] === "__daemon") {
|
|
5438
|
+
startDaemon(cleanArgv.slice(1)).catch((err) => {
|
|
5439
|
+
console.error("fuse-daemon fatal:", err);
|
|
5440
|
+
process.exit(1);
|
|
5441
|
+
});
|
|
5442
|
+
} else {
|
|
5443
|
+
try {
|
|
5444
|
+
program.parse(["node", "crm", ...cleanArgv]);
|
|
5445
|
+
} catch (e) {
|
|
5446
|
+
const err = e;
|
|
5447
|
+
if (err.exitCode !== undefined && err.exitCode === 0) {
|
|
5448
|
+
process.exit(0);
|
|
5449
|
+
}
|
|
5450
|
+
if (err.exitCode !== undefined) {
|
|
5451
|
+
process.exit(err.exitCode);
|
|
5452
|
+
}
|
|
5453
|
+
console.error(err.message || e);
|
|
5454
|
+
process.exit(1);
|
|
4288
5455
|
}
|
|
4289
|
-
console.error(err.message || e);
|
|
4290
|
-
process.exit(1);
|
|
4291
5456
|
}
|