@crmy/cli 0.6.0 → 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 +378 -55
  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 Command28 } 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
  }
@@ -137,8 +137,71 @@ function validateEmail(input) {
137
137
  function validatePassword(input) {
138
138
  return input.length >= 12 ? true : "Password must be at least 12 characters";
139
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
+ }
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,15 +359,15 @@ 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");
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");
252
363
  const envEmail = process.env.CRMY_ADMIN_EMAIL;
253
364
  const envPassword = process.env.CRMY_ADMIN_PASSWORD;
254
365
  const envName = process.env.CRMY_ADMIN_NAME;
255
- const isInteractive = process.stdin.isTTY !== false && !envEmail;
366
+ const useEnvVars = yesMode || !isInteractive || !!envEmail;
256
367
  let name;
257
368
  let email;
258
369
  let password;
259
- if (isInteractive) {
370
+ if (!useEnvVars) {
260
371
  console.log(" Create the first admin account for the CRMy web UI and CLI.\n");
261
372
  console.log(
262
373
  " \x1B[33mNOTE:\x1B[0m These are your \x1B[1mCRMy login credentials\x1B[0m \u2014 NOT your database credentials.\n"
@@ -294,7 +405,7 @@ function initCommand() {
294
405
  } else {
295
406
  if (!envEmail || !envPassword) {
296
407
  console.error(
297
- "\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"
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"
298
409
  );
299
410
  await closePool();
300
411
  process.exit(1);
@@ -321,6 +432,7 @@ function initCommand() {
321
432
  console.log(" Creating admin account from environment variables\u2026\n");
322
433
  }
323
434
  spinner = createSpinner("Creating admin account\u2026");
435
+ let rawKey = "";
324
436
  try {
325
437
  const salt = crypto.randomBytes(16);
326
438
  const hash = crypto.scryptSync(password, salt, 64, { N: 16384, r: 8, p: 1 });
@@ -333,7 +445,7 @@ function initCommand() {
333
445
  [tenantId, email.trim(), name, passwordHash]
334
446
  );
335
447
  const userId = userResult.rows[0].id;
336
- const rawKey = "crmy_" + crypto.randomBytes(32).toString("hex");
448
+ rawKey = "crmy_" + crypto.randomBytes(32).toString("hex");
337
449
  const keyHash = crypto.createHash("sha256").update(rawKey).digest("hex");
338
450
  await db.query(
339
451
  `INSERT INTO api_keys (tenant_id, user_id, key_hash, label, scopes)
@@ -367,8 +479,8 @@ function initCommand() {
367
479
  await closePool();
368
480
  process.exit(1);
369
481
  }
370
- let seedDemo = false;
371
- if (isInteractive) {
482
+ let seedDemo = yesMode;
483
+ if (!yesMode && isInteractive) {
372
484
  const { loadDemo } = await inquirer.prompt([
373
485
  {
374
486
  type: "confirm",
@@ -379,23 +491,61 @@ function initCommand() {
379
491
  ]);
380
492
  seedDemo = loadDemo;
381
493
  }
382
- await closePool();
383
494
  if (seedDemo) {
384
495
  console.log("");
385
- const { execSync } = await import("child_process");
496
+ spinner = createSpinner("Seeding demo data\u2026");
386
497
  try {
387
- execSync(`DATABASE_URL="${databaseUrl}" npx tsx ${path2.resolve(path2.dirname(new URL(import.meta.url).pathname), "../../../../scripts/seed-demo.ts")}`, {
388
- stdio: "inherit",
389
- env: { ...process.env, DATABASE_URL: databaseUrl }
390
- });
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");
391
537
  } catch {
392
- console.log(" \x1B[33m\u26A0\x1B[0m Demo data seeding failed \u2014 you can run it later with: crmy seed-demo\n");
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");
393
540
  }
394
541
  }
542
+ await closePool();
543
+ const keyPreview = rawKey.substring(0, 16) + "\u2026";
395
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");
396
545
  console.log(" \u2502 \x1B[32m\u2713\x1B[0m CRMy is ready! \u2502");
397
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");
398
547
  console.log(` Admin account: ${email.trim()}`);
548
+ console.log(` API key: ${keyPreview} \x1B[2m(full key in .crmy.json)\x1B[0m`);
399
549
  console.log(" Config saved: .crmy.json \x1B[2m(added to .gitignore)\x1B[0m\n");
400
550
  console.log(" Next steps:\n");
401
551
  console.log(" Start the server:");
@@ -494,7 +644,7 @@ function getCLIVersion() {
494
644
  );
495
645
  return pkg.version;
496
646
  } catch {
497
- return "0.5.5";
647
+ return "unknown";
498
648
  }
499
649
  }
500
650
  function printReadyBox(port) {
@@ -563,6 +713,10 @@ function serverCommand() {
563
713
  spinner.fail(`${stepLabels[step] ?? step} failed`);
564
714
  }
565
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
+ };
566
720
  let hitlInterval;
567
721
  try {
568
722
  const result = await createApp(serverConfig);
@@ -833,8 +987,8 @@ function createHttpClient(serverUrl, token) {
833
987
  if (!mapping) {
834
988
  throw new Error(`Unknown tool: ${toolName} (no REST mapping)`);
835
989
  }
836
- const { method, path: path6 } = mapping;
837
- const url = `${serverUrl.replace(/\/$/, "")}${path6(input)}`;
990
+ const { method, path: path7 } = mapping;
991
+ const url = `${serverUrl.replace(/\/$/, "")}${path7(input)}`;
838
992
  const headers = {
839
993
  "Authorization": `Bearer ${token}`,
840
994
  "Content-Type": "application/json"
@@ -2654,19 +2808,187 @@ Key points: (1) Acme spending 15 hrs/week on manual CRM data entry. (2) Failed S
2654
2808
  });
2655
2809
  }
2656
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
+
2657
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
+ }
2658
2980
  var _require2 = createRequire2(import.meta.url);
2659
2981
  function getCLIVersion2() {
2660
2982
  try {
2661
2983
  const pkg = _require2(
2662
- path5.resolve(path5.dirname(fileURLToPath2(import.meta.url)), "../package.json")
2984
+ path6.resolve(path6.dirname(fileURLToPath2(import.meta.url)), "../package.json")
2663
2985
  );
2664
2986
  return pkg.version;
2665
2987
  } catch {
2666
- return "0.5.5";
2988
+ return "unknown";
2667
2989
  }
2668
2990
  }
2669
- var program = new Command28();
2991
+ var program = new Command29();
2670
2992
  program.name("crmy").description("CRMy \u2014 The agent-first open source CRM").version(getCLIVersion2());
2671
2993
  program.addCommand(authCommand());
2672
2994
  program.addCommand(initCommand());
@@ -2694,6 +3016,7 @@ program.addCommand(activityTypesCommand());
2694
3016
  program.addCommand(contextTypesCommand());
2695
3017
  program.addCommand(briefingCommand());
2696
3018
  program.addCommand(seedDemoCommand());
3019
+ program.addCommand(doctorCommand());
2697
3020
  program.addCommand(helpCommand());
2698
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) => {
2699
3022
  const args = ["auth", "login"];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@crmy/cli",
3
- "version": "0.6.0",
3
+ "version": "0.6.1",
4
4
  "description": "CRMy CLI — Local CLI + stdio MCP server",
5
5
  "type": "module",
6
6
  "files": [