@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.
- package/dist/index.js +638 -68
- 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
|
|
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
|
|
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 >=
|
|
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
|
-
|
|
153
|
-
|
|
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: "
|
|
156
|
-
name: "
|
|
157
|
-
message: "
|
|
158
|
-
default:
|
|
251
|
+
type: "input",
|
|
252
|
+
name: "databaseUrl",
|
|
253
|
+
message: " PostgreSQL connection string:",
|
|
254
|
+
default: "postgresql://localhost:5432/crmy"
|
|
159
255
|
}
|
|
160
256
|
]);
|
|
161
|
-
|
|
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
|
-
|
|
168
|
-
|
|
169
|
-
console.log("
|
|
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
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
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
|
|
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
|
-
|
|
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 "
|
|
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:
|
|
764
|
-
const url = `${serverUrl.replace(/\/$/, "")}${
|
|
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
|
-
|
|
2984
|
+
path6.resolve(path6.dirname(fileURLToPath2(import.meta.url)), "../package.json")
|
|
2417
2985
|
);
|
|
2418
2986
|
return pkg.version;
|
|
2419
2987
|
} catch {
|
|
2420
|
-
return "
|
|
2988
|
+
return "unknown";
|
|
2421
2989
|
}
|
|
2422
2990
|
}
|
|
2423
|
-
var program = new
|
|
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"];
|