@crmy/cli 0.5.9 → 0.6.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.
Files changed (2) hide show
  1. package/dist/index.js +638 -68
  2. package/package.json +1 -1
package/dist/index.js CHANGED
@@ -1,10 +1,10 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  // src/index.ts
4
- import { Command as Command27 } from "commander";
4
+ import { Command as Command29 } from "commander";
5
5
  import { createRequire as createRequire2 } from "module";
6
6
  import { fileURLToPath as fileURLToPath2 } from "url";
7
- import path5 from "path";
7
+ import path6 from "path";
8
8
 
9
9
  // src/commands/init.ts
10
10
  import { Command } from "commander";
@@ -22,10 +22,10 @@ function createSpinner(initialMessage) {
22
22
  if (message) console.log(` ... ${message}`);
23
23
  },
24
24
  succeed(message) {
25
- console.log(` \u2713 ${message}`);
25
+ console.log(` \x1B[32m\u2713\x1B[0m ${message}`);
26
26
  },
27
27
  fail(message) {
28
- console.log(` \u2717 ${message}`);
28
+ console.log(` \x1B[31m\u2717\x1B[0m ${message}`);
29
29
  },
30
30
  stop() {
31
31
  }
@@ -135,10 +135,73 @@ function validateEmail(input) {
135
135
  return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(input.trim()) ? true : "Please enter a valid email address (e.g. you@example.com)";
136
136
  }
137
137
  function validatePassword(input) {
138
- return input.length >= 8 ? true : "Password must be at least 8 characters";
138
+ return input.length >= 12 ? true : "Password must be at least 12 characters";
139
+ }
140
+ async function ensureDatabaseExists(databaseUrl, interactive) {
141
+ let parsed;
142
+ try {
143
+ parsed = new URL(databaseUrl);
144
+ } catch {
145
+ return;
146
+ }
147
+ const dbName = parsed.pathname.replace(/^\//, "");
148
+ if (!dbName) return;
149
+ const isLocal = parsed.hostname === "localhost" || parsed.hostname === "127.0.0.1" || parsed.hostname === "::1";
150
+ if (!isLocal) return;
151
+ const maintUrl = new URL(databaseUrl);
152
+ maintUrl.pathname = "/postgres";
153
+ const pg = await import("pg");
154
+ const client = new pg.default.Client({ connectionString: maintUrl.toString() });
155
+ try {
156
+ await client.connect();
157
+ } catch {
158
+ return;
159
+ }
160
+ try {
161
+ const result = await client.query(
162
+ "SELECT 1 FROM pg_database WHERE datname = $1",
163
+ [dbName]
164
+ );
165
+ if (result.rows.length > 0) return;
166
+ if (interactive) {
167
+ const { default: inquirer } = await import("inquirer");
168
+ console.log(`
169
+ \x1B[33m\u26A0\x1B[0m Database \x1B[1m${dbName}\x1B[0m does not exist.
170
+ `);
171
+ const { create } = await inquirer.prompt([
172
+ {
173
+ type: "confirm",
174
+ name: "create",
175
+ message: ` Create database "${dbName}" now?`,
176
+ default: true
177
+ }
178
+ ]);
179
+ if (!create) {
180
+ console.log(`
181
+ Create it manually: createdb ${dbName}
182
+ `);
183
+ process.exit(1);
184
+ }
185
+ }
186
+ const spinner = createSpinner(`Creating database "${dbName}"\u2026`);
187
+ try {
188
+ await client.query(`CREATE DATABASE "${dbName}"`);
189
+ spinner.succeed(`Database "${dbName}" created`);
190
+ } catch (err) {
191
+ spinner.fail(`Failed to create database "${dbName}"`);
192
+ console.error(`
193
+ Error: ${err.message}`);
194
+ console.error(` Create it manually: createdb ${dbName}
195
+ `);
196
+ process.exit(1);
197
+ }
198
+ } finally {
199
+ await client.end();
200
+ }
139
201
  }
140
202
  function initCommand() {
141
- return new Command("init").description("Interactive setup wizard: database, migrations, admin account").action(async () => {
203
+ return new Command("init").description("Interactive setup wizard: database, migrations, admin account").option("-y, --yes", "Accept defaults non-interactively (requires CRMY_ADMIN_EMAIL + CRMY_ADMIN_PASSWORD env vars)").action(async (opts) => {
204
+ const yesMode = !!opts.yes;
142
205
  const { default: inquirer } = await import("inquirer");
143
206
  console.log("\n \u250C\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510");
144
207
  console.log(" \u2502 crmy.ai \u2014 Setup Wizard \u2502");
@@ -149,39 +212,53 @@ function initCommand() {
149
212
  console.log(" Step 3 \u2014 Create your admin account\n");
150
213
  const localConfigPath = path2.join(process.cwd(), ".crmy.json");
151
214
  if (fs2.existsSync(localConfigPath)) {
152
- console.log(" \x1B[33m\u26A0\x1B[0m A .crmy.json already exists in this directory.\n");
153
- const { overwrite } = await inquirer.prompt([
215
+ if (yesMode) {
216
+ console.log(" \x1B[33m\u26A0\x1B[0m Overwriting existing .crmy.json (--yes mode)\n");
217
+ } else {
218
+ console.log(" \x1B[33m\u26A0\x1B[0m A .crmy.json already exists in this directory.\n");
219
+ const { overwrite } = await inquirer.prompt([
220
+ {
221
+ type: "confirm",
222
+ name: "overwrite",
223
+ message: " Overwrite it and run setup again?",
224
+ default: false
225
+ }
226
+ ]);
227
+ if (!overwrite) {
228
+ console.log("\n Setup cancelled. Your existing config was not changed.\n");
229
+ process.exit(0);
230
+ }
231
+ console.log("");
232
+ }
233
+ }
234
+ console.log(" \u2500\u2500 Step 1 of 3: Database Connection \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n");
235
+ let databaseUrl;
236
+ if (yesMode) {
237
+ databaseUrl = process.env.DATABASE_URL ?? "postgresql://localhost:5432/crmy";
238
+ console.log(` Using database URL: ${databaseUrl}
239
+ `);
240
+ } else {
241
+ console.log(" Enter your PostgreSQL connection string.");
242
+ console.log(" Format: postgresql://user:password@host:5432/dbname\n");
243
+ console.log(" Options:");
244
+ console.log(" \u2022 Local install: postgresql://localhost:5432/crmy");
245
+ console.log(" \u2022 Docker: postgresql://postgres:postgres@localhost:5432/crmy");
246
+ console.log(" \u2022 Supabase: Project Settings \u2192 Database \u2192 Connection String");
247
+ console.log(" \u2022 Neon: Dashboard \u2192 Connection Details\n");
248
+ console.log(" \x1B[2mNote: the wizard will retry the connection up to 5 times.\x1B[0m\n");
249
+ const answers = await inquirer.prompt([
154
250
  {
155
- type: "confirm",
156
- name: "overwrite",
157
- message: " Overwrite it and run setup again?",
158
- default: false
251
+ type: "input",
252
+ name: "databaseUrl",
253
+ message: " PostgreSQL connection string:",
254
+ default: "postgresql://localhost:5432/crmy"
159
255
  }
160
256
  ]);
161
- if (!overwrite) {
162
- console.log("\n Setup cancelled. Your existing config was not changed.\n");
163
- process.exit(0);
164
- }
165
- console.log("");
257
+ databaseUrl = answers.databaseUrl;
166
258
  }
167
- console.log(" \u2500\u2500 Step 1 of 3: Database Connection \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n");
168
- console.log(" Enter your PostgreSQL connection string.");
169
- console.log(" Format: postgresql://user:password@host:5432/dbname\n");
170
- console.log(" Options:");
171
- console.log(" \u2022 Local install: postgresql://localhost:5432/crmy");
172
- console.log(" \u2022 Docker: postgresql://postgres:postgres@localhost:5432/crmy");
173
- console.log(" \u2022 Supabase: Project Settings \u2192 Database \u2192 Connection String");
174
- console.log(" \u2022 Neon: Dashboard \u2192 Connection Details\n");
175
- console.log(" \x1B[2mNote: the wizard will retry the connection up to 5 times.\x1B[0m\n");
176
- const { databaseUrl } = await inquirer.prompt([
177
- {
178
- type: "input",
179
- name: "databaseUrl",
180
- message: " PostgreSQL connection string:",
181
- default: "postgresql://localhost:5432/crmy"
182
- }
183
- ]);
184
- console.log("\n \u2500\u2500 Step 2 of 3: Database Setup \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n");
259
+ const isInteractive = process.stdin.isTTY !== false && !yesMode;
260
+ await ensureDatabaseExists(databaseUrl, isInteractive);
261
+ console.log("\n \u2500\u2500 Step 2 of 3: Database Setup \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n");
185
262
  process.env.CRMY_IMPORTED = "1";
186
263
  const { initPool, closePool, runMigrations } = await import("@crmy/server");
187
264
  let spinner = createSpinner("Connecting to database\u2026");
@@ -208,7 +285,9 @@ function initCommand() {
208
285
  spinner = createSpinner("Running database migrations\u2026");
209
286
  let ran = [];
210
287
  try {
211
- ran = await runMigrations(db);
288
+ ran = await runMigrations(db, (name2, index, total) => {
289
+ spinner.update(`Running migration ${index + 1}/${total}: ${name2}\u2026`);
290
+ });
212
291
  if (ran.length > 0) {
213
292
  spinner.succeed(`Migrations complete \x1B[2m(${ran.length} applied)\x1B[0m`);
214
293
  } else {
@@ -230,6 +309,38 @@ function initCommand() {
230
309
  await closePool();
231
310
  process.exit(1);
232
311
  }
312
+ if (process.env.ENABLE_PGVECTOR !== "true" && !yesMode && isInteractive) {
313
+ console.log("");
314
+ const { enablePgvector } = await inquirer.prompt([
315
+ {
316
+ type: "confirm",
317
+ name: "enablePgvector",
318
+ message: " Enable semantic search? (requires pgvector extension in PostgreSQL)",
319
+ default: false
320
+ }
321
+ ]);
322
+ if (enablePgvector) {
323
+ process.env.ENABLE_PGVECTOR = "true";
324
+ spinner = createSpinner("Running pgvector migration\u2026");
325
+ try {
326
+ const pgRan = await runMigrations(db);
327
+ if (pgRan.length > 0) {
328
+ spinner.succeed("Semantic search enabled \x1B[2m(pgvector)\x1B[0m");
329
+ } else {
330
+ spinner.succeed("Semantic search \x1B[2m(already enabled)\x1B[0m");
331
+ }
332
+ } catch (err) {
333
+ spinner.fail("pgvector migration failed");
334
+ console.log(
335
+ `
336
+ \x1B[33m\u26A0\x1B[0m ${err.message}
337
+ This is non-fatal \u2014 CRMy works without semantic search.
338
+ You can enable it later by setting ENABLE_PGVECTOR=true and running: crmy migrate
339
+ `
340
+ );
341
+ }
342
+ }
343
+ }
233
344
  spinner = createSpinner("Seeding default tenant\u2026");
234
345
  let tenantId;
235
346
  try {
@@ -248,34 +359,84 @@ function initCommand() {
248
359
  await closePool();
249
360
  process.exit(1);
250
361
  }
251
- console.log("\n \u2500\u2500 Step 3 of 3: Admin Account \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n");
252
- console.log(" Create the first admin account for the CRMy web UI and CLI.\n");
253
- console.log(
254
- " \x1B[33mNOTE:\x1B[0m These are your \x1B[1mCRMy login credentials\x1B[0m \u2014 NOT your database credentials.\n"
255
- );
256
- const { name, email, password } = await inquirer.prompt([
257
- {
258
- type: "input",
259
- name: "name",
260
- message: " Your full name:"
261
- },
262
- {
263
- type: "input",
264
- name: "email",
265
- message: " Email address (used to log in):",
266
- validate: validateEmail
267
- },
268
- {
269
- type: "password",
270
- name: "password",
271
- message: " Password (min 8 characters):",
272
- mask: "*",
273
- validate: validatePassword
362
+ console.log("\n \u2500\u2500 Step 3 of 3: Admin Account \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n");
363
+ const envEmail = process.env.CRMY_ADMIN_EMAIL;
364
+ const envPassword = process.env.CRMY_ADMIN_PASSWORD;
365
+ const envName = process.env.CRMY_ADMIN_NAME;
366
+ const useEnvVars = yesMode || !isInteractive || !!envEmail;
367
+ let name;
368
+ let email;
369
+ let password;
370
+ if (!useEnvVars) {
371
+ console.log(" Create the first admin account for the CRMy web UI and CLI.\n");
372
+ console.log(
373
+ " \x1B[33mNOTE:\x1B[0m These are your \x1B[1mCRMy login credentials\x1B[0m \u2014 NOT your database credentials.\n"
374
+ );
375
+ const answers = await inquirer.prompt([
376
+ {
377
+ type: "input",
378
+ name: "name",
379
+ message: " Your full name:"
380
+ },
381
+ {
382
+ type: "input",
383
+ name: "email",
384
+ message: " Admin email:",
385
+ validate: validateEmail
386
+ },
387
+ {
388
+ type: "password",
389
+ name: "password",
390
+ message: " Admin password (min 12 chars):",
391
+ mask: "*",
392
+ validate: validatePassword
393
+ },
394
+ {
395
+ type: "password",
396
+ name: "confirmPassword",
397
+ message: " Confirm password:",
398
+ mask: "*",
399
+ validate: (input, answers2) => input === answers2?.password ? true : "Passwords do not match"
400
+ }
401
+ ]);
402
+ name = answers.name;
403
+ email = answers.email;
404
+ password = answers.password;
405
+ } else {
406
+ if (!envEmail || !envPassword) {
407
+ console.error(
408
+ "\n Error: Non-interactive environment detected.\n\n Set CRMY_ADMIN_EMAIL and CRMY_ADMIN_PASSWORD environment variables\n to create the admin account without interactive prompts.\n (Also required when using --yes flag)\n"
409
+ );
410
+ await closePool();
411
+ process.exit(1);
274
412
  }
275
- ]);
413
+ const emailValid = validateEmail(envEmail);
414
+ if (emailValid !== true) {
415
+ console.error(`
416
+ Error: CRMY_ADMIN_EMAIL is invalid \u2014 ${emailValid}
417
+ `);
418
+ await closePool();
419
+ process.exit(1);
420
+ }
421
+ const pwValid = validatePassword(envPassword);
422
+ if (pwValid !== true) {
423
+ console.error(`
424
+ Error: CRMY_ADMIN_PASSWORD is invalid \u2014 ${pwValid}
425
+ `);
426
+ await closePool();
427
+ process.exit(1);
428
+ }
429
+ name = envName ?? "Admin";
430
+ email = envEmail;
431
+ password = envPassword;
432
+ console.log(" Creating admin account from environment variables\u2026\n");
433
+ }
276
434
  spinner = createSpinner("Creating admin account\u2026");
435
+ let rawKey = "";
277
436
  try {
278
- const passwordHash = crypto.createHash("sha256").update(password).digest("hex");
437
+ const salt = crypto.randomBytes(16);
438
+ const hash = crypto.scryptSync(password, salt, 64, { N: 16384, r: 8, p: 1 });
439
+ const passwordHash = `scrypt:${salt.toString("hex")}:${hash.toString("hex")}`;
279
440
  const userResult = await db.query(
280
441
  `INSERT INTO users (tenant_id, email, name, role, password_hash)
281
442
  VALUES ($1, $2, $3, 'owner', $4)
@@ -284,7 +445,7 @@ function initCommand() {
284
445
  [tenantId, email.trim(), name, passwordHash]
285
446
  );
286
447
  const userId = userResult.rows[0].id;
287
- const rawKey = "crmy_" + crypto.randomBytes(32).toString("hex");
448
+ rawKey = "crmy_" + crypto.randomBytes(32).toString("hex");
288
449
  const keyHash = crypto.createHash("sha256").update(rawKey).digest("hex");
289
450
  await db.query(
290
451
  `INSERT INTO api_keys (tenant_id, user_id, key_hash, label, scopes)
@@ -318,11 +479,73 @@ function initCommand() {
318
479
  await closePool();
319
480
  process.exit(1);
320
481
  }
482
+ let seedDemo = yesMode;
483
+ if (!yesMode && isInteractive) {
484
+ const { loadDemo } = await inquirer.prompt([
485
+ {
486
+ type: "confirm",
487
+ name: "loadDemo",
488
+ message: " Load demo data to explore CRMy?",
489
+ default: true
490
+ }
491
+ ]);
492
+ seedDemo = loadDemo;
493
+ }
494
+ if (seedDemo) {
495
+ console.log("");
496
+ spinner = createSpinner("Seeding demo data\u2026");
497
+ try {
498
+ const serverModule = await import("@crmy/server");
499
+ const IDS = {
500
+ ACTOR_CODY: "d0000000-0000-4000-a000-000000000001",
501
+ ACTOR_SARAH_R: "d0000000-0000-4000-a000-000000000002",
502
+ ACTOR_OUTREACH: "d0000000-0000-4000-a000-000000000003",
503
+ ACTOR_RESEARCH: "d0000000-0000-4000-a000-000000000004",
504
+ ACCT_ACME: "d0000000-0000-4000-b000-000000000001",
505
+ ACCT_BRIGHTSIDE: "d0000000-0000-4000-b000-000000000002",
506
+ ACCT_VERTEX: "d0000000-0000-4000-b000-000000000003",
507
+ CT_SARAH_CHEN: "d0000000-0000-4000-c000-000000000001",
508
+ CT_MARCUS_WEBB: "d0000000-0000-4000-c000-000000000002",
509
+ CT_PRIYA_NAIR: "d0000000-0000-4000-c000-000000000003",
510
+ CT_JORDAN_LIU: "d0000000-0000-4000-c000-000000000004",
511
+ CT_TOMAS_RIVERA: "d0000000-0000-4000-c000-000000000005",
512
+ CT_KEIKO_YAMAMOTO: "d0000000-0000-4000-c000-000000000006",
513
+ OPP_ACME: "d0000000-0000-4000-d000-000000000001",
514
+ OPP_BRIGHTSIDE: "d0000000-0000-4000-d000-000000000002",
515
+ OPP_VERTEX: "d0000000-0000-4000-d000-000000000003"
516
+ };
517
+ const check = await db.query("SELECT id FROM actors WHERE id = $1", [IDS.ACTOR_CODY]);
518
+ if (check.rows.length === 0) {
519
+ await db.query(`INSERT INTO actors (id, tenant_id, actor_type, display_name, email) VALUES ($1, $2, 'human', 'Cody Harris', 'cody@crmy.ai') ON CONFLICT (id) DO NOTHING`, [IDS.ACTOR_CODY, tenantId]);
520
+ await db.query(`INSERT INTO actors (id, tenant_id, actor_type, display_name, email) VALUES ($1, $2, 'human', 'Sarah Reeves', 'sarah@crmy.ai') ON CONFLICT (id) DO NOTHING`, [IDS.ACTOR_SARAH_R, tenantId]);
521
+ await db.query(`INSERT INTO actors (id, tenant_id, actor_type, display_name, email, agent_identifier, agent_model) VALUES ($1, $2, 'agent', 'Outreach Agent', NULL, 'outreach-v1', 'claude-sonnet-4-20250514') ON CONFLICT (id) DO NOTHING`, [IDS.ACTOR_OUTREACH, tenantId]);
522
+ await db.query(`INSERT INTO actors (id, tenant_id, actor_type, display_name, email, agent_identifier, agent_model) VALUES ($1, $2, 'agent', 'Research Agent', NULL, 'research-v1', 'claude-sonnet-4-20250514') ON CONFLICT (id) DO NOTHING`, [IDS.ACTOR_RESEARCH, tenantId]);
523
+ await db.query(`INSERT INTO accounts (id, tenant_id, name, industry, health_score, annual_revenue, domain, website) VALUES ($1, $2, 'Acme Corp', 'SaaS', 72, 180000, 'acme.com', 'https://acme.com') ON CONFLICT (id) DO NOTHING`, [IDS.ACCT_ACME, tenantId]);
524
+ await db.query(`INSERT INTO accounts (id, tenant_id, name, industry, health_score, annual_revenue, domain, website) VALUES ($1, $2, 'Brightside Health', 'Healthcare', 45, 96000, 'brightsidehealth.com', 'https://brightsidehealth.com') ON CONFLICT (id) DO NOTHING`, [IDS.ACCT_BRIGHTSIDE, tenantId]);
525
+ await db.query(`INSERT INTO accounts (id, tenant_id, name, industry, health_score, annual_revenue, domain, website) VALUES ($1, $2, 'Vertex Logistics', 'Logistics', 88, 240000, 'vertex.io', 'https://vertex.io') ON CONFLICT (id) DO NOTHING`, [IDS.ACCT_VERTEX, tenantId]);
526
+ await db.query(`INSERT INTO contacts (id, tenant_id, first_name, last_name, email, title, account_id, lifecycle_stage) VALUES ($1, $2, 'Sarah', 'Chen', 'sarah.chen@acme.com', 'VP Engineering', $3, 'prospect') ON CONFLICT (id) DO NOTHING`, [IDS.CT_SARAH_CHEN, tenantId, IDS.ACCT_ACME]);
527
+ await db.query(`INSERT INTO contacts (id, tenant_id, first_name, last_name, email, title, account_id, lifecycle_stage) VALUES ($1, $2, 'Marcus', 'Webb', 'marcus.webb@acme.com', 'CFO', $3, 'prospect') ON CONFLICT (id) DO NOTHING`, [IDS.CT_MARCUS_WEBB, tenantId, IDS.ACCT_ACME]);
528
+ await db.query(`INSERT INTO contacts (id, tenant_id, first_name, last_name, email, title, account_id, lifecycle_stage) VALUES ($1, $2, 'Priya', 'Nair', 'p.nair@brightsidehealth.com', 'CTO', $3, 'active') ON CONFLICT (id) DO NOTHING`, [IDS.CT_PRIYA_NAIR, tenantId, IDS.ACCT_BRIGHTSIDE]);
529
+ await db.query(`INSERT INTO contacts (id, tenant_id, first_name, last_name, email, title, account_id, lifecycle_stage) VALUES ($1, $2, 'Jordan', 'Liu', 'j.liu@brightsidehealth.com', 'RevOps Lead', $3, 'active') ON CONFLICT (id) DO NOTHING`, [IDS.CT_JORDAN_LIU, tenantId, IDS.ACCT_BRIGHTSIDE]);
530
+ await db.query(`INSERT INTO contacts (id, tenant_id, first_name, last_name, email, title, account_id, lifecycle_stage) VALUES ($1, $2, 'Tom\xE1s', 'Rivera', 't.rivera@vertex.io', 'Head of Sales Ops', $3, 'champion') ON CONFLICT (id) DO NOTHING`, [IDS.CT_TOMAS_RIVERA, tenantId, IDS.ACCT_VERTEX]);
531
+ await db.query(`INSERT INTO contacts (id, tenant_id, first_name, last_name, email, title, account_id, lifecycle_stage) VALUES ($1, $2, 'Keiko', 'Yamamoto', 'k.yamamoto@vertex.io', 'CEO', $3, 'champion') ON CONFLICT (id) DO NOTHING`, [IDS.CT_KEIKO_YAMAMOTO, tenantId, IDS.ACCT_VERTEX]);
532
+ await db.query(`INSERT INTO opportunities (id, tenant_id, name, account_id, stage, amount, close_date) VALUES ($1, $2, 'Acme Corp Enterprise Deal', $3, 'Discovery', 180000, '2026-06-30') ON CONFLICT (id) DO NOTHING`, [IDS.OPP_ACME, tenantId, IDS.ACCT_ACME]);
533
+ await db.query(`INSERT INTO opportunities (id, tenant_id, name, account_id, stage, amount, close_date) VALUES ($1, $2, 'Brightside Health Platform Deal', $3, 'PoC', 96000, '2026-05-15') ON CONFLICT (id) DO NOTHING`, [IDS.OPP_BRIGHTSIDE, tenantId, IDS.ACCT_BRIGHTSIDE]);
534
+ await db.query(`INSERT INTO opportunities (id, tenant_id, name, account_id, stage, amount, close_date) VALUES ($1, $2, 'Vertex Logistics Expansion', $3, 'Negotiation', 240000, '2026-04-30') ON CONFLICT (id) DO NOTHING`, [IDS.OPP_VERTEX, tenantId, IDS.ACCT_VERTEX]);
535
+ }
536
+ spinner.succeed("Demo data seeded \x1B[2m(3 accounts, 6 contacts, 3 opportunities)\x1B[0m");
537
+ } catch {
538
+ spinner.fail("Demo data seeding failed");
539
+ console.log(" \x1B[33m\u26A0\x1B[0m You can run it later with: crmy seed-demo\n");
540
+ }
541
+ }
321
542
  await closePool();
543
+ const keyPreview = rawKey.substring(0, 16) + "\u2026";
322
544
  console.log("\n \u250C\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510");
323
545
  console.log(" \u2502 \x1B[32m\u2713\x1B[0m CRMy is ready! \u2502");
324
546
  console.log(" \u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518\n");
325
547
  console.log(` Admin account: ${email.trim()}`);
548
+ console.log(` API key: ${keyPreview} \x1B[2m(full key in .crmy.json)\x1B[0m`);
326
549
  console.log(" Config saved: .crmy.json \x1B[2m(added to .gitignore)\x1B[0m\n");
327
550
  console.log(" Next steps:\n");
328
551
  console.log(" Start the server:");
@@ -421,7 +644,7 @@ function getCLIVersion() {
421
644
  );
422
645
  return pkg.version;
423
646
  } catch {
424
- return "0.5.5";
647
+ return "unknown";
425
648
  }
426
649
  }
427
650
  function printReadyBox(port) {
@@ -490,6 +713,10 @@ function serverCommand() {
490
713
  spinner.fail(`${stepLabels[step] ?? step} failed`);
491
714
  }
492
715
  };
716
+ serverConfig.onMigration = (name, index, total) => {
717
+ spinner.update(`Running migration ${index + 1}/${total}: ${name}\u2026`);
718
+ logToFile(`[MIGRATION] ${index + 1}/${total}: ${name}`);
719
+ };
493
720
  let hitlInterval;
494
721
  try {
495
722
  const result = await createApp(serverConfig);
@@ -760,8 +987,8 @@ function createHttpClient(serverUrl, token) {
760
987
  if (!mapping) {
761
988
  throw new Error(`Unknown tool: ${toolName} (no REST mapping)`);
762
989
  }
763
- const { method, path: path6 } = mapping;
764
- const url = `${serverUrl.replace(/\/$/, "")}${path6(input)}`;
990
+ const { method, path: path7 } = mapping;
991
+ const url = `${serverUrl.replace(/\/$/, "")}${path7(input)}`;
765
992
  const headers = {
766
993
  "Authorization": `Bearer ${token}`,
767
994
  "Content-Type": "application/json"
@@ -2408,19 +2635,360 @@ function helpCommand() {
2408
2635
  });
2409
2636
  }
2410
2637
 
2638
+ // src/commands/seed-demo.ts
2639
+ import { Command as Command27 } from "commander";
2640
+ function seedDemoCommand() {
2641
+ return new Command27("seed-demo").description("Seed rich demo data for exploring CRMy (idempotent)").option("--reset", "Drop and re-seed demo data (dev only)").action(async (opts) => {
2642
+ const config = loadConfigFile();
2643
+ const databaseUrl = config.database?.url ?? process.env.DATABASE_URL;
2644
+ if (!databaseUrl) {
2645
+ console.error(
2646
+ "\n Error: No database URL found.\n\n Either run `crmy init` first or set DATABASE_URL in your environment.\n"
2647
+ );
2648
+ process.exit(1);
2649
+ }
2650
+ const spinner = createSpinner("Seeding demo data\u2026");
2651
+ try {
2652
+ let daysAgo2 = function(n) {
2653
+ const d = /* @__PURE__ */ new Date();
2654
+ d.setDate(d.getDate() - n);
2655
+ return d.toISOString();
2656
+ }, daysFromNow2 = function(n) {
2657
+ const d = /* @__PURE__ */ new Date();
2658
+ d.setDate(d.getDate() + n);
2659
+ return d.toISOString().slice(0, 10);
2660
+ };
2661
+ var daysAgo = daysAgo2, daysFromNow = daysFromNow2;
2662
+ const pgMod = await import("pg");
2663
+ const { Pool } = pgMod.default ?? pgMod;
2664
+ const pool = new Pool({ connectionString: databaseUrl });
2665
+ const tenantRes = await pool.query(`SELECT id FROM tenants LIMIT 1`);
2666
+ if (tenantRes.rows.length === 0) {
2667
+ spinner.fail("No tenant found");
2668
+ console.error("\n Run `crmy init` first to create the database schema.\n");
2669
+ await pool.end();
2670
+ process.exit(1);
2671
+ }
2672
+ const tenantId = tenantRes.rows[0].id;
2673
+ const IDS = {
2674
+ ACTOR_CODY: "d0000000-0000-4000-a000-000000000001",
2675
+ ACTOR_SARAH_R: "d0000000-0000-4000-a000-000000000002",
2676
+ ACTOR_OUTREACH: "d0000000-0000-4000-a000-000000000003",
2677
+ ACTOR_RESEARCH: "d0000000-0000-4000-a000-000000000004",
2678
+ ACCT_ACME: "d0000000-0000-4000-b000-000000000001",
2679
+ ACCT_BRIGHTSIDE: "d0000000-0000-4000-b000-000000000002",
2680
+ ACCT_VERTEX: "d0000000-0000-4000-b000-000000000003",
2681
+ CT_SARAH_CHEN: "d0000000-0000-4000-c000-000000000001",
2682
+ CT_MARCUS_WEBB: "d0000000-0000-4000-c000-000000000002",
2683
+ CT_PRIYA_NAIR: "d0000000-0000-4000-c000-000000000003",
2684
+ CT_JORDAN_LIU: "d0000000-0000-4000-c000-000000000004",
2685
+ CT_TOMAS_RIVERA: "d0000000-0000-4000-c000-000000000005",
2686
+ CT_KEIKO_YAMAMOTO: "d0000000-0000-4000-c000-000000000006",
2687
+ OPP_ACME: "d0000000-0000-4000-d000-000000000001",
2688
+ OPP_BRIGHTSIDE: "d0000000-0000-4000-d000-000000000002",
2689
+ OPP_VERTEX: "d0000000-0000-4000-d000-000000000003",
2690
+ ACT_1: "d0000000-0000-4000-e000-000000000001",
2691
+ ACT_2: "d0000000-0000-4000-e000-000000000002",
2692
+ ACT_3: "d0000000-0000-4000-e000-000000000003",
2693
+ ACT_4: "d0000000-0000-4000-e000-000000000004",
2694
+ ACT_5: "d0000000-0000-4000-e000-000000000005",
2695
+ ACT_6: "d0000000-0000-4000-e000-000000000006",
2696
+ ACT_7: "d0000000-0000-4000-e000-000000000007",
2697
+ ACT_8: "d0000000-0000-4000-e000-000000000008",
2698
+ ACT_9: "d0000000-0000-4000-e000-000000000009",
2699
+ ACT_10: "d0000000-0000-4000-e000-000000000010",
2700
+ CTX_1: "d0000000-0000-4000-f000-000000000001",
2701
+ CTX_2: "d0000000-0000-4000-f000-000000000002",
2702
+ CTX_3: "d0000000-0000-4000-f000-000000000003",
2703
+ CTX_4: "d0000000-0000-4000-f000-000000000004",
2704
+ CTX_5: "d0000000-0000-4000-f000-000000000005",
2705
+ CTX_6: "d0000000-0000-4000-f000-000000000006",
2706
+ CTX_7: "d0000000-0000-4000-f000-000000000007",
2707
+ CTX_8: "d0000000-0000-4000-f000-000000000008",
2708
+ CTX_9: "d0000000-0000-4000-f000-000000000009",
2709
+ CTX_10: "d0000000-0000-4000-f000-000000000010",
2710
+ CTX_11: "d0000000-0000-4000-f000-000000000011",
2711
+ CTX_12: "d0000000-0000-4000-f000-000000000012",
2712
+ ASSIGN_1: "d0000000-0000-4000-f100-000000000001",
2713
+ ASSIGN_2: "d0000000-0000-4000-f100-000000000002",
2714
+ ASSIGN_3: "d0000000-0000-4000-f100-000000000003"
2715
+ };
2716
+ if (opts.reset) {
2717
+ const demoIds = Object.values(IDS);
2718
+ await pool.query(`DELETE FROM assignments WHERE id = ANY($1::uuid[])`, [demoIds]);
2719
+ await pool.query(`DELETE FROM context_entries WHERE id = ANY($1::uuid[])`, [demoIds]);
2720
+ await pool.query(`DELETE FROM activities WHERE id = ANY($1::uuid[])`, [demoIds]);
2721
+ await pool.query(`DELETE FROM opportunities WHERE id = ANY($1::uuid[])`, [demoIds]);
2722
+ await pool.query(`DELETE FROM contacts WHERE id = ANY($1::uuid[])`, [demoIds]);
2723
+ await pool.query(`DELETE FROM accounts WHERE id = ANY($1::uuid[])`, [demoIds]);
2724
+ await pool.query(`DELETE FROM actors WHERE id = ANY($1::uuid[])`, [demoIds]);
2725
+ }
2726
+ await pool.query(
2727
+ `INSERT INTO actors (id, tenant_id, actor_type, display_name, email) VALUES ($1, $2, 'human', 'Cody Harris', 'cody@crmy.ai') ON CONFLICT (id) DO NOTHING`,
2728
+ [IDS.ACTOR_CODY, tenantId]
2729
+ );
2730
+ await pool.query(
2731
+ `INSERT INTO actors (id, tenant_id, actor_type, display_name, email) VALUES ($1, $2, 'human', 'Sarah Reeves', 'sarah@crmy.ai') ON CONFLICT (id) DO NOTHING`,
2732
+ [IDS.ACTOR_SARAH_R, tenantId]
2733
+ );
2734
+ await pool.query(
2735
+ `INSERT INTO actors (id, tenant_id, actor_type, display_name, email, agent_identifier, agent_model) VALUES ($1, $2, 'agent', 'Outreach Agent', NULL, 'outreach-v1', 'claude-sonnet-4-20250514') ON CONFLICT (id) DO NOTHING`,
2736
+ [IDS.ACTOR_OUTREACH, tenantId]
2737
+ );
2738
+ await pool.query(
2739
+ `INSERT INTO actors (id, tenant_id, actor_type, display_name, email, agent_identifier, agent_model) VALUES ($1, $2, 'agent', 'Research Agent', NULL, 'research-v1', 'claude-sonnet-4-20250514') ON CONFLICT (id) DO NOTHING`,
2740
+ [IDS.ACTOR_RESEARCH, tenantId]
2741
+ );
2742
+ await pool.query(
2743
+ `INSERT INTO accounts (id, tenant_id, name, industry, health_score, annual_revenue, domain, website) VALUES ($1, $2, 'Acme Corp', 'SaaS', 72, 180000, 'acme.com', 'https://acme.com') ON CONFLICT (id) DO NOTHING`,
2744
+ [IDS.ACCT_ACME, tenantId]
2745
+ );
2746
+ await pool.query(
2747
+ `INSERT INTO accounts (id, tenant_id, name, industry, health_score, annual_revenue, domain, website) VALUES ($1, $2, 'Brightside Health', 'Healthcare', 45, 96000, 'brightsidehealth.com', 'https://brightsidehealth.com') ON CONFLICT (id) DO NOTHING`,
2748
+ [IDS.ACCT_BRIGHTSIDE, tenantId]
2749
+ );
2750
+ await pool.query(
2751
+ `INSERT INTO accounts (id, tenant_id, name, industry, health_score, annual_revenue, domain, website) VALUES ($1, $2, 'Vertex Logistics', 'Logistics', 88, 240000, 'vertex.io', 'https://vertex.io') ON CONFLICT (id) DO NOTHING`,
2752
+ [IDS.ACCT_VERTEX, tenantId]
2753
+ );
2754
+ await pool.query(`INSERT INTO contacts (id, tenant_id, first_name, last_name, email, title, account_id, lifecycle_stage) VALUES ($1, $2, 'Sarah', 'Chen', 'sarah.chen@acme.com', 'VP Engineering', $3, 'prospect') ON CONFLICT (id) DO NOTHING`, [IDS.CT_SARAH_CHEN, tenantId, IDS.ACCT_ACME]);
2755
+ await pool.query(`INSERT INTO contacts (id, tenant_id, first_name, last_name, email, title, account_id, lifecycle_stage) VALUES ($1, $2, 'Marcus', 'Webb', 'marcus.webb@acme.com', 'CFO', $3, 'prospect') ON CONFLICT (id) DO NOTHING`, [IDS.CT_MARCUS_WEBB, tenantId, IDS.ACCT_ACME]);
2756
+ await pool.query(`INSERT INTO contacts (id, tenant_id, first_name, last_name, email, title, account_id, lifecycle_stage) VALUES ($1, $2, 'Priya', 'Nair', 'p.nair@brightsidehealth.com', 'CTO', $3, 'active') ON CONFLICT (id) DO NOTHING`, [IDS.CT_PRIYA_NAIR, tenantId, IDS.ACCT_BRIGHTSIDE]);
2757
+ await pool.query(`INSERT INTO contacts (id, tenant_id, first_name, last_name, email, title, account_id, lifecycle_stage) VALUES ($1, $2, 'Jordan', 'Liu', 'j.liu@brightsidehealth.com', 'RevOps Lead', $3, 'active') ON CONFLICT (id) DO NOTHING`, [IDS.CT_JORDAN_LIU, tenantId, IDS.ACCT_BRIGHTSIDE]);
2758
+ await pool.query(`INSERT INTO contacts (id, tenant_id, first_name, last_name, email, title, account_id, lifecycle_stage) VALUES ($1, $2, 'Tom\xE1s', 'Rivera', 't.rivera@vertex.io', 'Head of Sales Ops', $3, 'champion') ON CONFLICT (id) DO NOTHING`, [IDS.CT_TOMAS_RIVERA, tenantId, IDS.ACCT_VERTEX]);
2759
+ await pool.query(`INSERT INTO contacts (id, tenant_id, first_name, last_name, email, title, account_id, lifecycle_stage) VALUES ($1, $2, 'Keiko', 'Yamamoto', 'k.yamamoto@vertex.io', 'CEO', $3, 'champion') ON CONFLICT (id) DO NOTHING`, [IDS.CT_KEIKO_YAMAMOTO, tenantId, IDS.ACCT_VERTEX]);
2760
+ await pool.query(`INSERT INTO opportunities (id, tenant_id, name, account_id, stage, amount, close_date) VALUES ($1, $2, 'Acme Corp Enterprise Deal', $3, 'Discovery', 180000, '2026-06-30') ON CONFLICT (id) DO NOTHING`, [IDS.OPP_ACME, tenantId, IDS.ACCT_ACME]);
2761
+ await pool.query(`INSERT INTO opportunities (id, tenant_id, name, account_id, stage, amount, close_date) VALUES ($1, $2, 'Brightside Health Platform Deal', $3, 'PoC', 96000, '2026-05-15') ON CONFLICT (id) DO NOTHING`, [IDS.OPP_BRIGHTSIDE, tenantId, IDS.ACCT_BRIGHTSIDE]);
2762
+ await pool.query(`INSERT INTO opportunities (id, tenant_id, name, account_id, stage, amount, close_date) VALUES ($1, $2, 'Vertex Logistics Expansion', $3, 'Negotiation', 240000, '2026-04-30') ON CONFLICT (id) DO NOTHING`, [IDS.OPP_VERTEX, tenantId, IDS.ACCT_VERTEX]);
2763
+ await pool.query(`INSERT INTO activities (id, tenant_id, type, subject, body, performed_by, subject_type, subject_id, occurred_at, outcome, detail) VALUES ($1, $2, 'outreach_email', 'Initial outreach to Sarah Chen', 'Personalized email introducing CRMy platform capabilities and requesting a discovery call.', $3, 'contact', $4, $5, 'replied', $6) ON CONFLICT (id) DO NOTHING`, [IDS.ACT_1, tenantId, IDS.ACTOR_OUTREACH, IDS.CT_SARAH_CHEN, daysAgo2(14), JSON.stringify({ to: "sarah.chen@acme.com", subject: "Scaling your sales ops after Series B", channel: "email" })]);
2764
+ await pool.query(`INSERT INTO activities (id, tenant_id, type, subject, body, performed_by, subject_type, subject_id, related_type, related_id, occurred_at, outcome, detail) VALUES ($1, $2, 'meeting_held', 'Discovery call \u2014 Acme Corp', 'Discussed current pain points with Salesforce. Sarah is the champion. Marcus asked pointed questions about 6-month ROI.', $3, 'opportunity', $4, 'account', $5, $6, 'completed', $7) ON CONFLICT (id) DO NOTHING`, [IDS.ACT_2, tenantId, IDS.ACTOR_CODY, IDS.OPP_ACME, IDS.ACCT_ACME, daysAgo2(10), JSON.stringify({ duration_minutes: 45, attendees: ["sarah.chen@acme.com", "marcus.webb@acme.com", "cody@crmy.ai"] })]);
2765
+ await pool.query(`INSERT INTO activities (id, tenant_id, type, subject, body, performed_by, subject_type, subject_id, occurred_at, outcome, detail) VALUES ($1, $2, 'proposal_drafted', 'Draft proposal for Acme Corp Enterprise Deal', 'Generated initial proposal with three tiers. Included ROI projections and the Vertex case study.', $3, 'opportunity', $4, $5, 'completed', $6) ON CONFLICT (id) DO NOTHING`, [IDS.ACT_3, tenantId, IDS.ACTOR_OUTREACH, IDS.OPP_ACME, daysAgo2(7), JSON.stringify({ document_type: "proposal", version: "v1-draft" })]);
2766
+ await pool.query(`INSERT INTO activities (id, tenant_id, type, subject, body, performed_by, subject_type, subject_id, occurred_at, outcome, detail) VALUES ($1, $2, 'research_completed', 'Deep research on Brightside Health', 'Compiled competitive landscape, org chart, recent funding, and technology stack analysis.', $3, 'account', $4, $5, 'completed', $6) ON CONFLICT (id) DO NOTHING`, [IDS.ACT_4, tenantId, IDS.ACTOR_RESEARCH, IDS.ACCT_BRIGHTSIDE, daysAgo2(5), JSON.stringify({ sources: ["crunchbase", "linkedin", "company_blog"], findings_count: 8 })]);
2767
+ await pool.query(`INSERT INTO activities (id, tenant_id, type, subject, body, performed_by, subject_type, subject_id, occurred_at, outcome, detail) VALUES ($1, $2, 'outreach_call', 'Follow-up call to Dr. Priya Nair', 'Attempted direct call. Went to voicemail. Left message highlighting open-source model and self-hosting.', $3, 'contact', $4, $5, 'voicemail', $6) ON CONFLICT (id) DO NOTHING`, [IDS.ACT_5, tenantId, IDS.ACTOR_CODY, IDS.CT_PRIYA_NAIR, daysAgo2(3), JSON.stringify({ duration_minutes: 2, phone: "+1-555-0147", voicemail_left: true })]);
2768
+ await pool.query(`INSERT INTO activities (id, tenant_id, type, subject, body, performed_by, subject_type, subject_id, occurred_at, outcome, detail) VALUES ($1, $2, 'meeting_scheduled', 'Executive alignment call \u2014 Vertex Logistics', 'Scheduled 30-minute call with Keiko Yamamoto and Tom\xE1s Rivera for next week.', $3, 'opportunity', $4, $5, 'scheduled', $6) ON CONFLICT (id) DO NOTHING`, [IDS.ACT_6, tenantId, IDS.ACTOR_OUTREACH, IDS.OPP_VERTEX, daysAgo2(2), JSON.stringify({ meeting_date: daysFromNow2(5), duration_minutes: 30 })]);
2769
+ await pool.query(`INSERT INTO activities (id, tenant_id, type, subject, body, performed_by, subject_type, subject_id, occurred_at, outcome, detail) VALUES ($1, $2, 'stage_change', 'Vertex Logistics \u2192 Negotiation', 'Advanced deal from Qualification to Negotiation after PoC exceeded throughput targets by 22%.', $3, 'opportunity', $4, $5, 'completed', $6) ON CONFLICT (id) DO NOTHING`, [IDS.ACT_7, tenantId, IDS.ACTOR_CODY, IDS.OPP_VERTEX, daysAgo2(1), JSON.stringify({ from_stage: "Qualification", to_stage: "Negotiation" })]);
2770
+ await pool.query(`INSERT INTO activities (id, tenant_id, type, subject, body, performed_by, subject_type, subject_id, occurred_at, outcome, detail) VALUES ($1, $2, 'outreach_email', 'Technical deep-dive request to Jordan Liu', 'Follow-up with API docs and sandbox link. Positioned around their pipeline update pain point.', $3, 'contact', $4, $5, 'opened', $6) ON CONFLICT (id) DO NOTHING`, [IDS.ACT_8, tenantId, IDS.ACTOR_OUTREACH, IDS.CT_JORDAN_LIU, daysAgo2(4), JSON.stringify({ to: "j.liu@brightsidehealth.com", subject: "CRMy sandbox access + MCP integration docs", channel: "email" })]);
2771
+ await pool.query(`INSERT INTO activities (id, tenant_id, type, subject, body, performed_by, subject_type, subject_id, occurred_at, outcome, detail) VALUES ($1, $2, 'review', 'Internal review of Acme Corp proposal', 'Reviewed draft with Sarah Reeves. Identified three areas for revision.', $3, 'opportunity', $4, $5, 'completed', $6) ON CONFLICT (id) DO NOTHING`, [IDS.ACT_9, tenantId, IDS.ACTOR_SARAH_R, IDS.OPP_ACME, daysAgo2(6), JSON.stringify({ reviewers: ["sarah@crmy.ai", "cody@crmy.ai"], revision_items: 3 })]);
2772
+ await pool.query(`INSERT INTO activities (id, tenant_id, type, subject, body, performed_by, subject_type, subject_id, occurred_at, outcome, detail) VALUES ($1, $2, 'research_completed', 'Competitive analysis \u2014 Vertex Logistics', 'Vertex using custom Airtable CRM with Zapier. 20 hrs/week manual updates. Attio lacks MCP.', $3, 'account', $4, $5, 'completed', $6) ON CONFLICT (id) DO NOTHING`, [IDS.ACT_10, tenantId, IDS.ACTOR_RESEARCH, IDS.ACCT_VERTEX, daysAgo2(8), JSON.stringify({ sources: ["linkedin", "company_website", "glassdoor"], findings_count: 5 })]);
2773
+ await pool.query(`INSERT INTO context_entries (id, tenant_id, subject_type, subject_id, context_type, authored_by, title, body, confidence, source, valid_until, tags) VALUES ($1, $2, 'account', $3, 'objection', $4, 'CFO skeptical of ROI timeline', 'CFO Marcus Webb is skeptical about 6-month ROI claims \u2014 referenced a failed Salesforce implementation in 2023 that took 14 months to break even instead of the promised 6. He explicitly said "I will not sign off on anything that leads with theoretical projections." Approach with concrete case studies from similar-sized SaaS companies, not projections. The Vertex case study (40% ramp reduction in 90 days) is the strongest proof point we have.', 0.95, 'discovery_call', $5, $6) ON CONFLICT (id) DO NOTHING`, [IDS.CTX_1, tenantId, IDS.ACCT_ACME, IDS.ACTOR_CODY, daysFromNow2(60), JSON.stringify(["roi", "cfo", "salesforce-migration"])]);
2774
+ await pool.query(`INSERT INTO context_entries (id, tenant_id, subject_type, subject_id, context_type, authored_by, title, body, confidence, source, valid_until, tags) VALUES ($1, $2, 'account', $3, 'objection', $4, 'Concern about vendor lock-in with proprietary MCP', 'Dr. Nair raised concerns about MCP being a proprietary protocol controlled by Anthropic. She asked specifically whether CRMy would work with non-Anthropic models and whether the MCP specification is truly open. This objection can likely be addressed by showing the open-source MCP spec and demonstrating multi-model support, but we have not had the opportunity to do so yet.', 0.7, 'research', $5, $6) ON CONFLICT (id) DO NOTHING`, [IDS.CTX_2, tenantId, IDS.ACCT_BRIGHTSIDE, IDS.ACTOR_RESEARCH, daysFromNow2(30), JSON.stringify(["lock-in", "open-source", "mcp"])]);
2775
+ await pool.query(`INSERT INTO context_entries (id, tenant_id, subject_type, subject_id, context_type, authored_by, title, body, confidence, source, valid_until, tags) VALUES ($1, $2, 'account', $3, 'competitive_intel', $4, 'Brightside evaluating HubSpot and Attio', 'Brightside Health is actively evaluating HubSpot (Enterprise tier, $3,600/mo) and Attio (Growth plan, $1,200/mo) alongside CRMy. Neither has MCP support. Our differentiator is the open-source, self-hosted model with native MCP.', 0.85, 'linkedin_research', $5, $6) ON CONFLICT (id) DO NOTHING`, [IDS.CTX_3, tenantId, IDS.ACCT_BRIGHTSIDE, IDS.ACTOR_RESEARCH, daysFromNow2(45), JSON.stringify(["hubspot", "attio", "competitive"])]);
2776
+ await pool.query(`INSERT INTO context_entries (id, tenant_id, subject_type, subject_id, context_type, authored_by, title, body, confidence, source, valid_until, tags) VALUES ($1, $2, 'account', $3, 'competitive_intel', $4, 'Vertex considering Attio as alternative', 'Vertex Logistics was evaluating Attio as a potential CRM replacement. This intelligence is from initial conversations and may be outdated \u2014 Tom\xE1s has since expressed strong preference for CRMy after the PoC results.', 0.6, 'initial_call', $5, $6) ON CONFLICT (id) DO NOTHING`, [IDS.CTX_4, tenantId, IDS.ACCT_VERTEX, IDS.ACTOR_CODY, daysAgo2(10).slice(0, 10), JSON.stringify(["attio", "competitive", "airtable"])]);
2777
+ await pool.query(`INSERT INTO context_entries (id, tenant_id, subject_type, subject_id, context_type, authored_by, title, body, confidence, source, tags) VALUES ($1, $2, 'contact', $3, 'preference', $4, 'Sarah Chen communication preferences', 'Sarah Chen prefers async communication (Slack or email) over calls. She responds fastest to technical content \u2014 architecture diagrams, API documentation, and code examples. Avoid scheduling calls before 10am PT. She is the internal champion at Acme.', 0.9, 'observation', $5) ON CONFLICT (id) DO NOTHING`, [IDS.CTX_5, tenantId, IDS.CT_SARAH_CHEN, IDS.ACTOR_OUTREACH, JSON.stringify(["communication", "async", "technical"])]);
2778
+ await pool.query(`INSERT INTO context_entries (id, tenant_id, subject_type, subject_id, context_type, authored_by, title, body, confidence, source, tags) VALUES ($1, $2, 'contact', $3, 'preference', $4, 'Marcus Webb decision-making style', 'Marcus Webb makes decisions based on quantitative data and peer references, not product demos. He explicitly asked for "three CFOs I can call who switched from Salesforce to CRMy." Lead every communication with a specific number or case study result.', 0.85, 'discovery_call', $5) ON CONFLICT (id) DO NOTHING`, [IDS.CTX_6, tenantId, IDS.CT_MARCUS_WEBB, IDS.ACTOR_CODY, JSON.stringify(["decision-maker", "data-driven", "brevity"])]);
2779
+ await pool.query(`INSERT INTO context_entries (id, tenant_id, subject_type, subject_id, context_type, authored_by, title, body, confidence, source, tags) VALUES ($1, $2, 'account', $3, 'relationship_map', $4, 'Acme Corp internal dynamics', 'Sarah Chen (VP Engineering) is the champion. Marcus Webb (CFO) is the economic buyer and final decision maker. There is a third stakeholder \u2014 their VP Sales (name unknown) who would be the primary end user.', 0.8, 'discovery_call', $5) ON CONFLICT (id) DO NOTHING`, [IDS.CTX_7, tenantId, IDS.ACCT_ACME, IDS.ACTOR_CODY, JSON.stringify(["champion", "buyer", "stakeholder-map"])]);
2780
+ await pool.query(`INSERT INTO context_entries (id, tenant_id, subject_type, subject_id, context_type, authored_by, title, body, confidence, source, tags) VALUES ($1, $2, 'account', $3, 'relationship_map', $4, 'Vertex Logistics buying committee', 'Tom\xE1s Rivera is the champion and day-to-day contact. Keiko Yamamoto (CEO) has final sign-off authority on all annual contracts over $100K. Tom\xE1s warned: "Don''t oversell to Keiko \u2014 she values directness."', 0.9, 'partner_feedback', $5) ON CONFLICT (id) DO NOTHING`, [IDS.CTX_8, tenantId, IDS.ACCT_VERTEX, IDS.ACTOR_OUTREACH, JSON.stringify(["champion", "ceo", "buying-committee"])]);
2781
+ await pool.query(`INSERT INTO context_entries (id, tenant_id, subject_type, subject_id, context_type, authored_by, title, body, confidence, source, valid_until, tags) VALUES ($1, $2, 'account', $3, 'research', $4, 'Brightside Health org chart and tech stack', 'Brightside Health uses microservices on AWS (EKS). CTO Dr. Priya Nair reports to CEO. Engineering team of ~40, with 6 dedicated to internal tools. New CTO hire rumored for Q1 2026.', 0.65, 'web_research', $5, $6) ON CONFLICT (id) DO NOTHING`, [IDS.CTX_9, tenantId, IDS.ACCT_BRIGHTSIDE, IDS.ACTOR_RESEARCH, daysAgo2(45).slice(0, 10), JSON.stringify(["org-chart", "tech-stack", "aws"])]);
2782
+ await pool.query(`INSERT INTO context_entries (id, tenant_id, subject_type, subject_id, context_type, authored_by, title, body, confidence, source, tags) VALUES ($1, $2, 'account', $3, 'summary', $4, 'Vertex Logistics account summary', 'Vertex Logistics is our highest-probability deal ($240K ARR, Negotiation stage). The PoC exceeded throughput targets by 22%. Remaining gate: CEO Keiko Yamamoto sign-off. Next action: executive alignment call scheduled.', 0.9, 'agent_synthesis', $5) ON CONFLICT (id) DO NOTHING`, [IDS.CTX_10, tenantId, IDS.ACCT_VERTEX, IDS.ACTOR_RESEARCH, JSON.stringify(["deal-summary", "high-priority"])]);
2783
+ await pool.query(`INSERT INTO context_entries (id, tenant_id, subject_type, subject_id, context_type, authored_by, title, body, confidence, source, tags) VALUES ($1, $2, 'opportunity', $3, 'agent_reasoning', $4, 'Outreach strategy decision for Acme proposal revision', 'Decision: Lead revised Acme proposal with Vertex case study (40% ramp reduction in 90 days) rather than original ROI projections. Marcus Webb explicitly rejected theoretical projections. Sarah Chen confirmed concrete examples carry more weight.', 0.85, 'agent_analysis', $5) ON CONFLICT (id) DO NOTHING`, [IDS.CTX_11, tenantId, IDS.OPP_ACME, IDS.ACTOR_OUTREACH, JSON.stringify(["strategy", "proposal", "reasoning"])]);
2784
+ await pool.query(`INSERT INTO context_entries (id, tenant_id, subject_type, subject_id, context_type, authored_by, title, body, confidence, source, source_activity_id, tags) VALUES ($1, $2, 'opportunity', $3, 'meeting_notes', $4, 'Acme Corp discovery call notes \u2014 2026-03-16', E'Attendees: Cody Harris, Sarah Chen, Marcus Webb. Duration: 45 min.
2785
+
2786
+ Key points: (1) Acme spending 15 hrs/week on manual CRM data entry. (2) Failed Salesforce impl in 2023. (3) Sarah wants API-first CRM for AI agents. (4) Marcus wants reference customers, not projections. (5) Sarah offered 2-week pilot.', 1.0, 'meeting_transcript', $5, $6) ON CONFLICT (id) DO NOTHING`, [IDS.CTX_12, tenantId, IDS.OPP_ACME, IDS.ACTOR_CODY, IDS.ACT_2, JSON.stringify(["discovery", "meeting", "acme"])]);
2787
+ await pool.query(`INSERT INTO assignments (id, tenant_id, title, description, assignment_type, assigned_by, assigned_to, subject_type, subject_id, status, priority, context) VALUES ($1, $2, 'Send revised proposal to Acme Corp \u2014 address Marcus Webb''s ROI concern', 'Review and send the revised proposal that leads with the Vertex case study.', 'send', $3, $4, 'opportunity', $5, 'pending', 'high', 'Marcus pushed back on the 6-month ROI claim in the last call. I''ve drafted a revised proposal in section 3 that leads with the Vertex case study (40% ramp reduction in 90 days). Sarah Chen is the champion \u2014 copy her on send. Do not cc Marcus directly.') ON CONFLICT (id) DO NOTHING`, [IDS.ASSIGN_1, tenantId, IDS.ACTOR_OUTREACH, IDS.ACTOR_CODY, IDS.OPP_ACME]);
2788
+ await pool.query(`INSERT INTO assignments (id, tenant_id, title, description, assignment_type, assigned_by, assigned_to, subject_type, subject_id, status, priority, context) VALUES ($1, $2, 'Review stale research on Brightside Health before next call', 'The research entry from January is past its valid_until date and needs verification.', 'review', $3, $4, 'account', $5, 'pending', 'normal', 'The research entry from January is past its valid_until date. Dr. Nair mentioned a new CTO hire at their last board meeting \u2014 the org chart context entry may be wrong.') ON CONFLICT (id) DO NOTHING`, [IDS.ASSIGN_2, tenantId, IDS.ACTOR_RESEARCH, IDS.ACTOR_SARAH_R, IDS.ACCT_BRIGHTSIDE]);
2789
+ await pool.query(`INSERT INTO assignments (id, tenant_id, title, description, assignment_type, assigned_by, assigned_to, subject_type, subject_id, status, priority, context) VALUES ($1, $2, 'Schedule executive alignment call with Keiko Yamamoto at Vertex', 'Coordinate schedules for a 15-minute executive alignment call.', 'call', $3, $4, 'opportunity', $5, 'accepted', 'urgent', 'Tomas has given us the green light but Keiko needs to sign off. Use the Calendly link in my preferences. Propose Tues/Thurs mornings PT.') ON CONFLICT (id) DO NOTHING`, [IDS.ASSIGN_3, tenantId, IDS.ACTOR_CODY, IDS.ACTOR_OUTREACH, IDS.OPP_VERTEX]);
2790
+ spinner.succeed("Demo data seeded successfully");
2791
+ await pool.end();
2792
+ } catch (err) {
2793
+ spinner.fail("Failed to seed demo data");
2794
+ console.error(`
2795
+ Error: ${err.message}
2796
+ `);
2797
+ process.exit(1);
2798
+ }
2799
+ console.log(" 4 actors (2 humans, 2 agents)");
2800
+ console.log(" 3 accounts \xB7 6 contacts \xB7 3 opportunities");
2801
+ console.log(" 10 activities \xB7 12 context entries \xB7 3 assignments");
2802
+ console.log("");
2803
+ console.log("Try it:");
2804
+ console.log(" crmy briefing contact:d0000000-0000-4000-c000-000000000001");
2805
+ console.log(" crmy briefing account:d0000000-0000-4000-b000-000000000001");
2806
+ console.log(" crmy briefing opportunity:d0000000-0000-4000-d000-000000000001");
2807
+ console.log("");
2808
+ });
2809
+ }
2810
+
2811
+ // src/commands/doctor.ts
2812
+ import { Command as Command28 } from "commander";
2813
+ import fs4 from "fs";
2814
+ import path5 from "path";
2815
+ import net from "net";
2816
+ var KNOWN_BAD_SECRETS = ["change-me-in-production", "dev-secret", "secret", ""];
2817
+ function pass(msg) {
2818
+ console.log(` \x1B[32m\u2713\x1B[0m ${msg}`);
2819
+ }
2820
+ function fail(msg, fix) {
2821
+ console.log(` \x1B[31m\u2717\x1B[0m ${msg}`);
2822
+ if (fix) console.log(` \x1B[2m\u2192 ${fix}\x1B[0m`);
2823
+ }
2824
+ function info(msg) {
2825
+ console.log(` \x1B[36m\u2139\x1B[0m ${msg}`);
2826
+ }
2827
+ async function checkPort(port) {
2828
+ return new Promise((resolve) => {
2829
+ const server = net.createServer();
2830
+ server.once("error", () => resolve(false));
2831
+ server.once("listening", () => {
2832
+ server.close(() => resolve(true));
2833
+ });
2834
+ server.listen(port);
2835
+ });
2836
+ }
2837
+ function doctorCommand() {
2838
+ return new Command28("doctor").description("Check your CRMy setup for common issues").option("--port <port>", "Port to check availability for", "3000").action(async (opts) => {
2839
+ console.log("\n CRMy Doctor\n \u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\n");
2840
+ let passed = 0;
2841
+ let failed = 0;
2842
+ const [major] = process.versions.node.split(".").map(Number);
2843
+ if (major >= 20) {
2844
+ pass(`Node.js ${process.version}`);
2845
+ passed++;
2846
+ } else {
2847
+ fail(`Node.js ${process.version} \u2014 CRMy requires >= 20.0.0`, "Install from https://nodejs.org");
2848
+ failed++;
2849
+ }
2850
+ const localPath = path5.join(process.cwd(), ".crmy.json");
2851
+ const hasLocal = fs4.existsSync(localPath);
2852
+ const hasGlobal = fs4.existsSync(GLOBAL_CONFIG);
2853
+ let config = {};
2854
+ if (hasLocal || hasGlobal) {
2855
+ try {
2856
+ config = loadConfigFile();
2857
+ const source = hasLocal ? ".crmy.json (local)" : "~/.crmy/config.json (global)";
2858
+ pass(`Config found: ${source}`);
2859
+ passed++;
2860
+ } catch {
2861
+ fail("Config file exists but is not valid JSON", "Run: crmy init");
2862
+ failed++;
2863
+ }
2864
+ } else {
2865
+ fail("No config file found", "Run: crmy init");
2866
+ failed++;
2867
+ }
2868
+ const dbUrl = config.database?.url ?? process.env.DATABASE_URL;
2869
+ if (dbUrl) {
2870
+ try {
2871
+ process.env.CRMY_IMPORTED = "1";
2872
+ const { initPool, closePool } = await import("@crmy/server");
2873
+ const db = await initPool(dbUrl, 2);
2874
+ await db.query("SELECT 1");
2875
+ pass("PostgreSQL reachable");
2876
+ passed++;
2877
+ try {
2878
+ const { getMigrationStatus } = await import("@crmy/server/db/migrate");
2879
+ const status = await getMigrationStatus(db);
2880
+ if (status.pending.length === 0) {
2881
+ pass(`Migrations up to date (${status.applied.length} applied)`);
2882
+ passed++;
2883
+ } else {
2884
+ const realPending = status.pending.filter(
2885
+ (f) => f !== "022_pgvector.sql" || process.env.ENABLE_PGVECTOR === "true"
2886
+ );
2887
+ if (realPending.length === 0) {
2888
+ pass(`Migrations up to date (${status.applied.length} applied, pgvector optional)`);
2889
+ passed++;
2890
+ } else {
2891
+ fail(`${realPending.length} pending migration(s)`, "Run: crmy migrate");
2892
+ failed++;
2893
+ }
2894
+ }
2895
+ } catch {
2896
+ try {
2897
+ const applied = await db.query("SELECT name FROM _migrations ORDER BY name");
2898
+ pass(`Migrations table exists (${applied.rows.length} applied)`);
2899
+ passed++;
2900
+ } catch {
2901
+ fail("Migrations table not found", "Run: crmy init");
2902
+ failed++;
2903
+ }
2904
+ }
2905
+ try {
2906
+ const users = await db.query("SELECT id FROM users LIMIT 1");
2907
+ if (users.rows.length > 0) {
2908
+ pass("Admin user exists");
2909
+ passed++;
2910
+ } else {
2911
+ fail("No users found", "Run: crmy init (or POST /auth/register)");
2912
+ failed++;
2913
+ }
2914
+ } catch {
2915
+ fail("Could not check users table", "Run: crmy init");
2916
+ failed++;
2917
+ }
2918
+ try {
2919
+ const pgv = await db.query("SELECT 1 FROM pg_extension WHERE extname = 'vector'");
2920
+ if (pgv.rows.length > 0) {
2921
+ pass("pgvector extension installed (semantic search available)");
2922
+ passed++;
2923
+ } else {
2924
+ info("pgvector not installed (semantic search disabled \u2014 this is optional)");
2925
+ }
2926
+ } catch {
2927
+ info("Could not check pgvector status");
2928
+ }
2929
+ await closePool();
2930
+ } catch (err) {
2931
+ fail(`PostgreSQL unreachable: ${err.message}`, "Check that PostgreSQL is running and DATABASE_URL is correct");
2932
+ failed++;
2933
+ }
2934
+ } else {
2935
+ fail("No DATABASE_URL configured", "Run: crmy init");
2936
+ failed++;
2937
+ }
2938
+ const port = parseInt(opts.port, 10);
2939
+ const portAvailable = await checkPort(port);
2940
+ if (portAvailable) {
2941
+ pass(`Port ${port} is available`);
2942
+ passed++;
2943
+ } else {
2944
+ fail(`Port ${port} is in use`, `Try: crmy server --port ${port + 1}`);
2945
+ failed++;
2946
+ }
2947
+ const jwt = config.jwtSecret ?? process.env.JWT_SECRET;
2948
+ if (jwt && !KNOWN_BAD_SECRETS.includes(jwt)) {
2949
+ pass("JWT_SECRET is set and not a known default");
2950
+ passed++;
2951
+ } else if (jwt && KNOWN_BAD_SECRETS.includes(jwt)) {
2952
+ fail("JWT_SECRET is a known default value", "Generate a new one: openssl rand -hex 32");
2953
+ failed++;
2954
+ } else {
2955
+ fail("JWT_SECRET not configured", "Run: crmy init");
2956
+ failed++;
2957
+ }
2958
+ console.log("\n \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500");
2959
+ if (failed === 0) {
2960
+ console.log(` \x1B[32m${passed} checks passed\x1B[0m \u2014 everything looks good!
2961
+ `);
2962
+ } else {
2963
+ console.log(` ${passed} passed, \x1B[31m${failed} issue(s) found\x1B[0m
2964
+ `);
2965
+ }
2966
+ });
2967
+ }
2968
+
2411
2969
  // src/index.ts
2970
+ var [_nodeMajor] = process.versions.node.split(".").map(Number);
2971
+ if (_nodeMajor < 20) {
2972
+ console.error(
2973
+ `
2974
+ CRMy requires Node.js >= 20.0.0 (you have ${process.version})
2975
+ Install the latest LTS: https://nodejs.org
2976
+ `
2977
+ );
2978
+ process.exit(1);
2979
+ }
2412
2980
  var _require2 = createRequire2(import.meta.url);
2413
2981
  function getCLIVersion2() {
2414
2982
  try {
2415
2983
  const pkg = _require2(
2416
- path5.resolve(path5.dirname(fileURLToPath2(import.meta.url)), "../package.json")
2984
+ path6.resolve(path6.dirname(fileURLToPath2(import.meta.url)), "../package.json")
2417
2985
  );
2418
2986
  return pkg.version;
2419
2987
  } catch {
2420
- return "0.5.5";
2988
+ return "unknown";
2421
2989
  }
2422
2990
  }
2423
- var program = new Command27();
2991
+ var program = new Command29();
2424
2992
  program.name("crmy").description("CRMy \u2014 The agent-first open source CRM").version(getCLIVersion2());
2425
2993
  program.addCommand(authCommand());
2426
2994
  program.addCommand(initCommand());
@@ -2447,6 +3015,8 @@ program.addCommand(contextCommand());
2447
3015
  program.addCommand(activityTypesCommand());
2448
3016
  program.addCommand(contextTypesCommand());
2449
3017
  program.addCommand(briefingCommand());
3018
+ program.addCommand(seedDemoCommand());
3019
+ program.addCommand(doctorCommand());
2450
3020
  program.addCommand(helpCommand());
2451
3021
  program.command("login").description("Sign in to a CRMy server (shortcut for `crmy auth login`)").option("-e, --email <email>", "Email address").option("-p, --password <password>", "Password").action(async (opts) => {
2452
3022
  const args = ["auth", "login"];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@crmy/cli",
3
- "version": "0.5.9",
3
+ "version": "0.6.1",
4
4
  "description": "CRMy CLI — Local CLI + stdio MCP server",
5
5
  "type": "module",
6
6
  "files": [